Commit 42643f5a81

42643f5a812427f30df62316b7026ce086ef11e8

parent: 24d9882997

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 01:21 UTC

fix git repository settings updates for empty repos

Layout: unified · split

Hutch/Views/Repositories/RepositorySettingsViewModel.swift +62 −12
@@ -6,12 +6,20 @@ private struct UpdateRepoResponse: Decodable, Sendable {
6 let updateRepository: UpdatedRepo 6 let updateRepository: UpdatedRepo
7} 7}
8 8
9private struct UpdateRepoInfoResponse: Decodable, Sendable {
10 let updateRepository: UpdatedRepoInfo
11}
12
9private struct UpdatedRepo: Decodable, Sendable { 13private struct UpdatedRepo: Decodable, Sendable {
10 let id: Int 14 let id: Int?
11 let rid: String 15 let rid: String?
12 let name: String 16 let name: String
13 let description: String? 17 let description: String?
14 let visibility: Visibility 18 let visibility: Visibility?
19}
20
21private struct UpdatedRepoInfo: Decodable, Sendable {
22 let id: Int
15} 23}
16 24
17private struct ACLResponse: Decodable, Sendable { 25private struct ACLResponse: Decodable, Sendable {
@@ -71,6 +79,7 @@ final class RepositorySettingsViewModel {
71 var editedDescription: String 79 var editedDescription: String
72 var editedVisibility: Visibility 80 var editedVisibility: Visibility
73 var editedHead: String 81 var editedHead: String
82 private let originalEditedHead: String
74 var isSavingInfo = false 83 var isSavingInfo = false
75 84
76 // MARK: - Rename fields 85 // MARK: - Rename fields
@@ -116,11 +125,14 @@ final class RepositorySettingsViewModel {
116 self.branches = branches 125 self.branches = branches
117 126
118 // Extract branch name from HEAD reference 127 // Extract branch name from HEAD reference
128 let initialEditedHead: String
119 if let head = repository.head?.name { 129 if let head = repository.head?.name {
120 self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "") 130 initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
121 } else { 131 } else {
122 self.editedHead = "main" 132 initialEditedHead = "main"
123 } 133 }
134 self.editedHead = initialEditedHead
135 self.originalEditedHead = initialEditedHead
124 } 136 }
125 137
126 // MARK: - Update Repository Info 138 // MARK: - Update Repository Info
@@ -133,22 +145,32 @@ final class RepositorySettingsViewModel {
133 } 145 }
134 """ 146 """
135 147
148 private static let updateRepoInfoMutation = """
149 mutation updateRepository($id: Int!, $input: RepoInput!) {
150 updateRepository(id: $id, input: $input) {
151 id
152 }
153 }
154 """
155
136 func saveInfo() async { 156 func saveInfo() async {
137 isSavingInfo = true 157 isSavingInfo = true
138 defer { isSavingInfo = false } 158 defer { isSavingInfo = false }
139 error = nil 159 error = nil
140 160
141 do { 161 do {
142 let input: [String: any Sendable] = [ 162 var input: [String: any Sendable] = [
143 "description": editedDescription, 163 "description": editedDescription,
144 "visibility": editedVisibility.rawValue, 164 "visibility": editedVisibility.rawValue
145 "HEAD": editedHead
146 ] 165 ]
166 if let headReference = selectedHeadReferenceForSave() {
167 input["HEAD"] = headReference
168 }
147 _ = try await client.execute( 169 _ = try await client.execute(
148 service: service, 170 service: service,
149 query: Self.updateRepoMutation, 171 query: Self.updateRepoInfoMutation,
150 variables: ["id": repositoryId, "input": input], 172 variables: ["id": repositoryId, "input": input],
151 responseType: UpdateRepoResponse.self 173 responseType: UpdateRepoInfoResponse.self
152 ) 174 )
153 } catch { 175 } catch {
154 self.error = error.localizedDescription 176 self.error = error.localizedDescription
@@ -238,8 +260,9 @@ final class RepositorySettingsViewModel {
238 } 260 }
239 261
240 func addACL() async { 262 func addACL() async {
241 let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) 263 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
242 guard !entity.isEmpty else { return } 264 guard !rawEntity.isEmpty else { return }
265 let entity = Self.gitCanonicalEntity(from: rawEntity)
243 isAddingACL = true 266 isAddingACL = true
244 defer { isAddingACL = false } 267 defer { isAddingACL = false }
245 error = nil 268 error = nil
@@ -267,6 +290,33 @@ final class RepositorySettingsViewModel {
267 } 290 }
268 } 291 }
269 292
293 static func gitCanonicalEntity(from input: String) -> String {
294 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
295 guard !trimmed.isEmpty else { return trimmed }
296 let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
297 return "~\(username)"
298 }
299
300 static func gitHeadReference(from input: String) -> String {
301 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
302 guard !trimmed.isEmpty else { return trimmed }
303 if trimmed.hasPrefix("refs/") {
304 return trimmed
305 }
306 return "refs/heads/\(trimmed)"
307 }
308
309 func selectedHeadReferenceForSave() -> String? {
310 let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
311 guard normalizedEditedHead != originalEditedHead else {
312 return nil
313 }
314
315 return branches.first {
316 $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
317 }?.name
318 }
319
270 func deleteACL(_ entry: ACLEntry) async { 320 func deleteACL(_ entry: ACLEntry) async {
271 isDeletingACL = true 321 isDeletingACL = true
272 defer { isDeletingACL = false } 322 defer { isDeletingACL = false }
HutchTests/RepositorySettingsViewModelTests.swift added +104
@@ -0,0 +1,104 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5private struct UpdateRepositoryInfoEnvelope: Decodable {
6 let updateRepository: UpdateRepositoryInfoPayload
7}
8
9private struct UpdateRepositoryInfoPayload: Decodable {
10 let id: Int
11}
12
13struct RepositorySettingsViewModelTests {
14
15 @Test
16 func saveInfoResponseDecodesMinimalRepositoryPayload() throws {
17 let json = """
18 {
19 "data": {
20 "updateRepository": {
21 "id": 42
22 }
23 }
24 }
25 """
26
27 let decoded = try JSONDecoder().decode(
28 GraphQLResponse<UpdateRepositoryInfoEnvelope>.self,
29 from: Data(json.utf8)
30 )
31
32 #expect(decoded.data?.updateRepository.id == 42)
33 }
34
35 @Test
36 func gitCanonicalEntityAddsMissingTilde() {
37 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice")
38 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice")
39 #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice")
40 }
41
42 @Test
43 func gitHeadReferenceUsesFullBranchRef() {
44 #expect(RepositorySettingsViewModel.gitHeadReference(from: "main") == "refs/heads/main")
45 #expect(RepositorySettingsViewModel.gitHeadReference(from: " refs/heads/dev ") == "refs/heads/dev")
46 }
47
48 @Test
49 @MainActor
50 func unchangedHeadIsOmittedFromSaveInput() {
51 let viewModel = RepositorySettingsViewModel(
52 repository: makeRepository(headName: "refs/heads/main"),
53 branches: [Reference(name: "refs/heads/main", target: nil)],
54 client: SRHTClient(token: "test-token")
55 )
56
57 #expect(viewModel.selectedHeadReferenceForSave() == nil)
58 }
59
60 @Test
61 @MainActor
62 func changedHeadUsesSelectedBranchReference() {
63 let viewModel = RepositorySettingsViewModel(
64 repository: makeRepository(headName: "refs/heads/main"),
65 branches: [
66 Reference(name: "refs/heads/main", target: nil),
67 Reference(name: "refs/heads/dev", target: nil)
68 ],
69 client: SRHTClient(token: "test-token")
70 )
71 viewModel.editedHead = "dev"
72
73 #expect(viewModel.selectedHeadReferenceForSave() == "refs/heads/dev")
74 }
75
76 @Test
77 @MainActor
78 func bareRepositoryOmitsHeadWhenNoBranchExists() {
79 let viewModel = RepositorySettingsViewModel(
80 repository: makeRepository(headName: nil),
81 branches: [],
82 client: SRHTClient(token: "test-token")
83 )
84
85 #expect(viewModel.selectedHeadReferenceForSave() == nil)
86 viewModel.editedHead = "main"
87 #expect(viewModel.selectedHeadReferenceForSave() == nil)
88 }
89
90 @MainActor
91 private func makeRepository(headName: String?) -> RepositorySummary {
92 RepositorySummary(
93 id: 1,
94 rid: "rid-1",
95 service: .git,
96 name: "repo",
97 description: "desc",
98 visibility: .public,
99 updated: .now,
100 owner: Entity(canonicalName: "~owner"),
101 head: headName.map { Reference(name: $0, target: nil) }
102 )
103 }
104}