krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

4dd920558a1c7174c0e9a5b872325df36e363516

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T01:21:47Z

fix git repository settings updates for empty repos
 .../Repositories/RepositorySettingsViewModel.swift |  74 ++++++++++++---
 HutchTests/RepositorySettingsViewModelTests.swift  | 104 +++++++++++++++++++++
 2 files changed, 166 insertions(+), 12 deletions(-)

diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index d6f3300..71d6ab6 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -6,12 +6,20 @@ private struct UpdateRepoResponse: Decodable, Sendable {
     let updateRepository: UpdatedRepo
 }
 
+private struct UpdateRepoInfoResponse: Decodable, Sendable {
+    let updateRepository: UpdatedRepoInfo
+}
+
 private struct UpdatedRepo: Decodable, Sendable {
-    let id: Int
-    let rid: String
+    let id: Int?
+    let rid: String?
     let name: String
     let description: String?
-    let visibility: Visibility
+    let visibility: Visibility?
+}
+
+private struct UpdatedRepoInfo: Decodable, Sendable {
+    let id: Int
 }
 
 private struct ACLResponse: Decodable, Sendable {
@@ -71,6 +79,7 @@ final class RepositorySettingsViewModel {
     var editedDescription: String
     var editedVisibility: Visibility
     var editedHead: String
+    private let originalEditedHead: String
     var isSavingInfo = false
 
     // MARK: - Rename fields
@@ -116,11 +125,14 @@ final class RepositorySettingsViewModel {
         self.branches = branches
 
         // Extract branch name from HEAD reference
+        let initialEditedHead: String
         if let head = repository.head?.name {
-            self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
+            initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
         } else {
-            self.editedHead = "main"
+            initialEditedHead = "main"
         }
+        self.editedHead = initialEditedHead
+        self.originalEditedHead = initialEditedHead
     }
 
     // MARK: - Update Repository Info
@@ -133,22 +145,32 @@ final class RepositorySettingsViewModel {
     }
     """
 
+    private static let updateRepoInfoMutation = """
+    mutation updateRepository($id: Int!, $input: RepoInput!) {
+        updateRepository(id: $id, input: $input) {
+            id
+        }
+    }
+    """
+
     func saveInfo() async {
         isSavingInfo = true
         defer { isSavingInfo = false }
         error = nil
 
         do {
-            let input: [String: any Sendable] = [
+            var input: [String: any Sendable] = [
                 "description": editedDescription,
-                "visibility": editedVisibility.rawValue,
-                "HEAD": editedHead
+                "visibility": editedVisibility.rawValue
             ]
+            if let headReference = selectedHeadReferenceForSave() {
+                input["HEAD"] = headReference
+            }
             _ = try await client.execute(
                 service: service,
-                query: Self.updateRepoMutation,
+                query: Self.updateRepoInfoMutation,
                 variables: ["id": repositoryId, "input": input],
-                responseType: UpdateRepoResponse.self
+                responseType: UpdateRepoInfoResponse.self
             )
         } catch {
             self.error = error.localizedDescription
@@ -238,8 +260,9 @@ final class RepositorySettingsViewModel {
     }
 
     func addACL() async {
-        let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !entity.isEmpty else { return }
+        let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !rawEntity.isEmpty else { return }
+        let entity = Self.gitCanonicalEntity(from: rawEntity)
         isAddingACL = true
         defer { isAddingACL = false }
         error = nil
@@ -267,6 +290,33 @@ final class RepositorySettingsViewModel {
         }
     }
 
+    static func gitCanonicalEntity(from input: String) -> String {
+        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return trimmed }
+        let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+        return "~\(username)"
+    }
+
+    static func gitHeadReference(from input: String) -> String {
+        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return trimmed }
+        if trimmed.hasPrefix("refs/") {
+            return trimmed
+        }
+        return "refs/heads/\(trimmed)"
+    }
+
+    func selectedHeadReferenceForSave() -> String? {
+        let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard normalizedEditedHead != originalEditedHead else {
+            return nil
+        }
+
+        return branches.first {
+            $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
+        }?.name
+    }
+
     func deleteACL(_ entry: ACLEntry) async {
         isDeletingACL = true
         defer { isDeletingACL = false }
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
new file mode 100644
index 0000000..a2c88ce
--- /dev/null
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -0,0 +1,104 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct UpdateRepositoryInfoEnvelope: Decodable {
+    let updateRepository: UpdateRepositoryInfoPayload
+}
+
+private struct UpdateRepositoryInfoPayload: Decodable {
+    let id: Int
+}
+
+struct RepositorySettingsViewModelTests {
+
+    @Test
+    func saveInfoResponseDecodesMinimalRepositoryPayload() throws {
+        let json = """
+        {
+            "data": {
+                "updateRepository": {
+                    "id": 42
+                }
+            }
+        }
+        """
+
+        let decoded = try JSONDecoder().decode(
+            GraphQLResponse<UpdateRepositoryInfoEnvelope>.self,
+            from: Data(json.utf8)
+        )
+
+        #expect(decoded.data?.updateRepository.id == 42)
+    }
+
+    @Test
+    func gitCanonicalEntityAddsMissingTilde() {
+        #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice")
+        #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice")
+        #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "  alice  ") == "~alice")
+    }
+
+    @Test
+    func gitHeadReferenceUsesFullBranchRef() {
+        #expect(RepositorySettingsViewModel.gitHeadReference(from: "main") == "refs/heads/main")
+        #expect(RepositorySettingsViewModel.gitHeadReference(from: " refs/heads/dev ") == "refs/heads/dev")
+    }
+
+    @Test
+    @MainActor
+    func unchangedHeadIsOmittedFromSaveInput() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: "refs/heads/main"),
+            branches: [Reference(name: "refs/heads/main", target: nil)],
+            client: SRHTClient(token: "test-token")
+        )
+
+        #expect(viewModel.selectedHeadReferenceForSave() == nil)
+    }
+
+    @Test
+    @MainActor
+    func changedHeadUsesSelectedBranchReference() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: "refs/heads/main"),
+            branches: [
+                Reference(name: "refs/heads/main", target: nil),
+                Reference(name: "refs/heads/dev", target: nil)
+            ],
+            client: SRHTClient(token: "test-token")
+        )
+        viewModel.editedHead = "dev"
+
+        #expect(viewModel.selectedHeadReferenceForSave() == "refs/heads/dev")
+    }
+
+    @Test
+    @MainActor
+    func bareRepositoryOmitsHeadWhenNoBranchExists() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: nil),
+            branches: [],
+            client: SRHTClient(token: "test-token")
+        )
+
+        #expect(viewModel.selectedHeadReferenceForSave() == nil)
+        viewModel.editedHead = "main"
+        #expect(viewModel.selectedHeadReferenceForSave() == nil)
+    }
+
+    @MainActor
+    private func makeRepository(headName: String?) -> RepositorySummary {
+        RepositorySummary(
+            id: 1,
+            rid: "rid-1",
+            service: .git,
+            name: "repo",
+            description: "desc",
+            visibility: .public,
+            updated: .now,
+            owner: Entity(canonicalName: "~owner"),
+            head: headName.map { Reference(name: $0, target: nil) }
+        )
+    }
+}