krz/hutch

an ios client for sourcehut

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

54d037548261c1546555a89a6a803b542a769880

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T03:30:22Z

feat: add repo settings management

Implements: https://todo.sr.ht/~ccleberg/hutch/42
Implements: https://todo.sr.ht/~ccleberg/hutch/43
Implements: https://todo.sr.ht/~ccleberg/hutch/44
 Hutch.xcodeproj/project.pbxproj                    |  16 +-
 Hutch/Models/RepositorySummary.swift               |  34 +++
 Hutch/Views/Lookup/UserProfileView.swift           |   4 +-
 Hutch/Views/Lookup/UserProfileViewModel.swift      |  11 +
 Hutch/Views/Lookup/UserRepositoriesView.swift      |   4 +-
 .../Views/Repositories/RepositoryDetailView.swift  |  37 +--
 Hutch/Views/Repositories/RepositoryListView.swift  |  15 +-
 .../Repositories/RepositoryListViewModel.swift     |  11 +
 Hutch/Views/Repositories/RepositoryRowView.swift   |   4 +-
 .../Repositories/RepositorySettingsView.swift      | 226 +++++++++------
 .../Repositories/RepositorySettingsViewModel.swift | 320 +++++++++++++--------
 HutchTests/RepositorySettingsViewModelTests.swift  |  26 ++
 12 files changed, 473 insertions(+), 235 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 0a9a9b6..3a7f7cd 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 44;
+				CURRENT_PROJECT_VERSION = 45;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.1;
+				MARKETING_VERSION = 2.19.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 44;
+				CURRENT_PROJECT_VERSION = 45;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.19.1;
+				MARKETING_VERSION = 2.19.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 44;
+				CURRENT_PROJECT_VERSION = 45;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.1;
+				MARKETING_VERSION = 2.19.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 44;
+				CURRENT_PROJECT_VERSION = 45;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.19.1;
+				MARKETING_VERSION = 2.19.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift
index f4ecc6e..3821aa2 100644
--- a/Hutch/Models/RepositorySummary.swift
+++ b/Hutch/Models/RepositorySummary.swift
@@ -55,3 +55,37 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
         self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
     }
 }
+
+extension RepositorySummary {
+    var defaultBranchName: String? {
+        head?.name.replacingOccurrences(of: "refs/heads/", with: "")
+    }
+
+    func updating(
+        name: String? = nil,
+        description: String? = nil,
+        visibility: Visibility? = nil,
+        updated: Date? = nil,
+        head: Reference? = nil
+    ) -> RepositorySummary {
+        RepositorySummary(
+            id: id,
+            rid: rid,
+            service: service,
+            name: name ?? self.name,
+            description: description ?? self.description,
+            visibility: visibility ?? self.visibility,
+            updated: updated ?? self.updated,
+            owner: owner,
+            head: head ?? self.head
+        )
+    }
+
+    static func displayBranchName(for reference: Reference) -> String {
+        displayBranchName(for: reference.name)
+    }
+
+    static func displayBranchName(for referenceName: String) -> String {
+        referenceName.replacingOccurrences(of: "refs/heads/", with: "")
+    }
+}
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift
index b694738..068e05f 100644
--- a/Hutch/Views/Lookup/UserProfileView.swift
+++ b/Hutch/Views/Lookup/UserProfileView.swift
@@ -119,7 +119,9 @@ struct UserProfileView: View {
                     } else {
                         ForEach(viewModel.repositories.prefix(4)) { repo in
                             NavigationLink {
-                                RepositoryDetailView(repository: repo)
+                                RepositoryDetailView(repository: repo) { updatedRepository in
+                                    viewModel.updateRepository(updatedRepository)
+                                }
                             } label: {
                                 RepositoryRowView(repository: repo, buildStatus: .none)
                             }
diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift
index 56c2852..b88d36a 100644
--- a/Hutch/Views/Lookup/UserProfileViewModel.swift
+++ b/Hutch/Views/Lookup/UserProfileViewModel.swift
@@ -61,6 +61,17 @@ final class UserProfileViewModel {
         }
     }
 
+    func updateRepository(_ repository: RepositorySummary) {
+        guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return }
+        repositories[index] = repository
+        repositories.sort { lhs, rhs in
+            if lhs.updated == rhs.updated {
+                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+            }
+            return lhs.updated > rhs.updated
+        }
+    }
+
     func loadTrackers() async {
         isLoadingTrackers = true
         trackersError = nil
diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift
index 52d3c15..7aeea10 100644
--- a/Hutch/Views/Lookup/UserRepositoriesView.swift
+++ b/Hutch/Views/Lookup/UserRepositoriesView.swift
@@ -7,7 +7,9 @@ struct UserRepositoriesView: View {
         List {
             ForEach(viewModel.repositories) { repo in
                 NavigationLink {
-                    RepositoryDetailView(repository: repo)
+                    RepositoryDetailView(repository: repo) { updatedRepository in
+                        viewModel.updateRepository(updatedRepository)
+                    }
                 } label: {
                     RepositoryRowView(repository: repo, buildStatus: .none)
                 }
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index b19e5b0..0031e47 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -1,7 +1,7 @@
 import SwiftUI
 
 struct RepositoryDetailView: View {
-    var repository: RepositorySummary
+    let onRepositoryUpdated: ((RepositorySummary) -> Void)?
     var onDeleted: (() -> Void)?
 
     @Environment(AppState.self) private var appState
@@ -10,22 +10,26 @@ struct RepositoryDetailView: View {
     @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
     @State private var showSettings = false
     @State private var showACLs = false
-    @State private var displayName: String
+    @State private var currentRepository: RepositorySummary
 
     private var canManageRepository: Bool {
         guard let currentUser = appState.currentUser else { return false }
-        return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
+        return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName)
     }
 
-    init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
-        self.repository = repository
+    init(
+        repository: RepositorySummary,
+        onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
+        onDeleted: (() -> Void)? = nil
+    ) {
+        self.onRepositoryUpdated = onRepositoryUpdated
         self.onDeleted = onDeleted
-        self._displayName = State(initialValue: repository.name)
+        self._currentRepository = State(initialValue: repository)
     }
 
     var body: some View {
-        if repository.service == .hg {
-            HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
+        if currentRepository.service == .hg {
+            HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted)
         } else {
             Group {
                 if let viewModel {
@@ -34,11 +38,11 @@ struct RepositoryDetailView: View {
                     SRHTLoadingStateView(message: "Loading repository…")
                 }
             }
-            .navigationTitle(displayName)
+            .navigationTitle(currentRepository.name)
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItemGroup(placement: .topBarTrailing) {
-                    SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+                    SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
                         Image(systemName: "square.and.arrow.up")
                     }
 
@@ -59,11 +63,12 @@ struct RepositoryDetailView: View {
             }
             .sheet(isPresented: $showSettings) {
                 RepositorySettingsView(
-                    repository: repository,
+                    repository: currentRepository,
                     branches: viewModel?.branches ?? [],
                     client: appState.client,
-                    onRenamed: { newName in
-                        displayName = newName
+                    onUpdated: { updatedRepository in
+                        currentRepository = updatedRepository
+                        onRepositoryUpdated?(updatedRepository)
                     },
                     onDeleted: {
                         dismiss()
@@ -74,7 +79,7 @@ struct RepositoryDetailView: View {
             .sheet(isPresented: $showACLs) {
                 NavigationStack {
                     RepositoryACLView(
-                        repository: repository,
+                        repository: currentRepository,
                         client: appState.client,
                         showsDoneButton: true
                     )
@@ -83,7 +88,7 @@ struct RepositoryDetailView: View {
             .task {
                 if viewModel == nil {
                     viewModel = RepositoryDetailViewModel(
-                        repository: repository,
+                        repository: currentRepository,
                         client: appState.client
                     )
                 }
@@ -110,7 +115,7 @@ struct RepositoryDetailView: View {
                 ReadmeView(viewModel: viewModel)
             case .tree:
                 FileTreeView(
-                    repository: repository,
+                    repository: currentRepository,
                     client: appState.client
                 )
             case .log:
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
index fb090b2..d176e4b 100644
--- a/Hutch/Views/Repositories/RepositoryListView.swift
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -45,7 +45,13 @@ struct RepositoryListView: View {
             }
         )) {
             if let createdRepository {
-                RepositoryDetailView(repository: createdRepository) {
+                RepositoryDetailView(
+                    repository: createdRepository,
+                    onRepositoryUpdated: { updatedRepository in
+                        self.createdRepository = updatedRepository
+                        viewModel?.updateRepository(updatedRepository)
+                    }
+                ) {
                     viewModel?.removeRepository(id: createdRepository.id)
                 }
             }
@@ -139,7 +145,12 @@ struct RepositoryListView: View {
             }
         }
         .navigationDestination(for: RepositorySummary.self) { repo in
-            RepositoryDetailView(repository: repo) {
+            RepositoryDetailView(
+                repository: repo,
+                onRepositoryUpdated: { updatedRepository in
+                    viewModel.updateRepository(updatedRepository)
+                }
+            ) {
                 viewModel.removeRepository(id: repo.id)
             }
         }
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index fd5bafe..7a65441 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -208,6 +208,17 @@ final class RepositoryListViewModel {
         repositories.removeAll { $0.id == id }
     }
 
+    func updateRepository(_ repository: RepositorySummary) {
+        if let index = repositories.firstIndex(where: { $0.id == repository.id }) {
+            repositories[index] = repository
+            repositories.sort(by: repositorySortOrder)
+        }
+
+        let updatedRepositories = repositoriesForSearchIndex
+            .filter { $0.id != repository.id } + [repository]
+        updateSearchIndex(with: updatedRepositories)
+    }
+
     func createRepository(
         service: RepositoryCreationService,
         name: String,
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index f791494..814ea76 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -41,8 +41,8 @@ struct RepositoryRowView: View {
             }
 
             HStack(spacing: 12) {
-                if let head = repository.head {
-                    Label(head.name, systemImage: "arrow.triangle.branch")
+                if let defaultBranchName = repository.defaultBranchName {
+                    Label(defaultBranchName, systemImage: "arrow.triangle.branch")
                         .font(.caption)
                         .foregroundStyle(.secondary)
                 }
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
index e6beedf..4736619 100644
--- a/Hutch/Views/Repositories/RepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -4,14 +4,13 @@ struct RepositorySettingsView: View {
     let repository: RepositorySummary
     let branches: [ReferenceDetail]
     let client: SRHTClient
-    let onRenamed: (String) -> Void
+    let onUpdated: (RepositorySummary) -> Void
     let onDeleted: () -> Void
 
     @Environment(\.dismiss) private var dismiss
     @State private var viewModel: RepositorySettingsViewModel?
+    @State private var showVisibilityConfirmation = false
     @State private var showDeleteConfirmation = false
-    @State private var showRenameConfirmation = false
-    @State private var saveResultAlert: SaveResultAlert?
 
     var body: some View {
         NavigationStack {
@@ -32,12 +31,11 @@ struct RepositorySettingsView: View {
         }
         .task {
             if viewModel == nil {
-                let vm = RepositorySettingsViewModel(
+                viewModel = RepositorySettingsViewModel(
                     repository: repository,
                     branches: branches,
                     client: client
                 )
-                viewModel = vm
             }
         }
     }
@@ -47,37 +45,36 @@ struct RepositorySettingsView: View {
         @Bindable var vm = viewModel
 
         Form {
-            infoSection(viewModel)
-            renameSection(viewModel)
-            accessSection()
+            currentConfigurationSection(viewModel)
+            metadataSection(viewModel)
+            defaultBranchSection(viewModel)
+            visibilitySection(viewModel)
             deleteSection(viewModel)
         }
         .srhtErrorBanner(error: $vm.error)
         .alert(
-            "Rename repository to \(viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines))?",
-            isPresented: $showRenameConfirmation
+            visibilityConfirmationTitle(for: viewModel),
+            isPresented: $showVisibilityConfirmation
         ) {
             Button("Cancel", role: .cancel) {
-                // Alert dismissal is implicit; no additional action required.
+                viewModel.editedVisibility = viewModel.repository.visibility
             }
-            Button("Rename", role: .destructive) {
+            Button("Apply", role: .destructive) {
                 Task {
-                    await viewModel.rename()
-                    if let newName = viewModel.updatedName {
-                        onRenamed(newName)
-                        dismiss()
+                    if let updatedRepository = await viewModel.updateVisibility() {
+                        onUpdated(updatedRepository)
                     }
                 }
             }
         } message: {
-            Text("This will change the repository URL. Existing clones will be redirected but links may break.")
+            Text(visibilityConfirmationMessage(for: viewModel))
         }
         .alert(
-            "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
+            "Permanently delete \(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)?",
             isPresented: $showDeleteConfirmation
         ) {
             Button("Cancel", role: .cancel) {
-                // Alert dismissal is implicit; no additional action required.
+                // Alert dismissal is implicit.
             }
             Button("Delete", role: .destructive) {
                 Task {
@@ -91,115 +88,156 @@ struct RepositorySettingsView: View {
         } message: {
             Text("This cannot be undone.")
         }
-        .alert(item: $saveResultAlert) { alert in
-            Alert(
-                title: Text(alert.title),
-                message: Text(alert.message),
-                dismissButton: .default(Text("OK"))
-            )
-        }
     }
 
-    // MARK: - Info Section
-
     @ViewBuilder
-    private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
-        Section("Info") {
-            LabeledContent("Name") {
-                Text(repository.name)
+    private func currentConfigurationSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+        Section("Current Configuration") {
+            LabeledContent("Repository") {
+                Text("\(viewModel.repository.owner.canonicalName)/\(viewModel.repository.name)")
                     .font(.body.monospaced())
             }
 
-            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
-                .lineLimit(3...6)
+            LabeledContent("Default Branch") {
+                Text(viewModel.currentDefaultBranchName)
+                    .font(.body.monospaced())
+            }
 
-            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
-                Text("Public").tag(Visibility.public)
-                Text("Unlisted").tag(Visibility.unlisted)
-                Text("Private").tag(Visibility.private)
+            LabeledContent("Visibility") {
+                Text(repositoryVisibilityLabel(viewModel.repository.visibility))
             }
+        }
+    }
 
-            if !viewModel.branches.isEmpty {
-                Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
-                    ForEach(viewModel.branches, id: \.name) { branch in
-                        let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
-                        Text(name).tag(name)
-                    }
-                }
+    @ViewBuilder
+    private func metadataSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+        Section {
+            TextField("Repository name", text: Bindable(viewModel).editedName)
+                .autocorrectionDisabled()
+                .textInputAutocapitalization(.never)
+
+            TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
+                .lineLimit(2...4)
+
+            if let metadataValidationMessage = viewModel.metadataValidationMessage {
+                Text(metadataValidationMessage)
+                    .font(.caption)
+                    .foregroundStyle(.red)
+            } else if viewModel.normalizedEditedName != viewModel.repository.name {
+                Text("Changing the repository name updates the repository URL.")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            } else {
+                Text("Name and description stay pending until you save this section.")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
             }
 
             Button {
                 Task {
-                    let didSave = await viewModel.saveInfo()
-                    saveResultAlert = SaveResultAlert(
-                        title: didSave ? "Settings Updated" : "Couldn't Update Settings",
-                        message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
-                    )
+                    if let updatedRepository = await viewModel.saveMetadata() {
+                        onUpdated(updatedRepository)
+                    }
                 }
             } label: {
-                if viewModel.isSavingInfo {
+                if viewModel.isSavingMetadata {
                     ProgressView()
                         .frame(maxWidth: .infinity)
                 } else {
-                    Text("Save Changes")
+                    Text("Save Details")
                         .frame(maxWidth: .infinity)
                 }
             }
-            .disabled(viewModel.isSavingInfo)
+            .disabled(
+                viewModel.isMutating ||
+                !viewModel.isMetadataDirty ||
+                viewModel.metadataValidationMessage != nil
+            )
+        } header: {
+            Text("Repository Details")
         }
     }
 
-    // MARK: - Rename Section
-
     @ViewBuilder
-    private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+    private func defaultBranchSection(_ viewModel: RepositorySettingsViewModel) -> some View {
         Section {
-            TextField("New repository name", text: Bindable(viewModel).editedName)
-                .autocorrectionDisabled()
-                .textInputAutocapitalization(.never)
+            LabeledContent("Current") {
+                Text(viewModel.currentDefaultBranchName)
+                    .font(.body.monospaced())
+            }
 
-            Text("This will change the repository URL. Existing clones will be redirected but links may break.")
-                .font(.caption)
-                .foregroundStyle(.secondary)
+            if viewModel.branches.isEmpty {
+                Text("This repository doesn't have any branches yet.")
+                    .foregroundStyle(.secondary)
+            } else {
+                Picker("Branch", selection: Bindable(viewModel).editedHead) {
+                    ForEach(viewModel.availableBranchNames, id: \.self) { branch in
+                        Text(branch)
+                            .font(.body.monospaced())
+                            .tag(branch)
+                    }
+                }
 
-            Button {
-                showRenameConfirmation = true
-            } label: {
-                if viewModel.isRenaming {
-                    ProgressView()
-                        .frame(maxWidth: .infinity)
-                } else {
-                    Text("Rename Repository")
-                        .frame(maxWidth: .infinity)
+                Text("Changes stay pending until you set the new default branch.")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+
+                Button {
+                    Task {
+                        if let updatedRepository = await viewModel.saveDefaultBranch() {
+                            onUpdated(updatedRepository)
+                        }
+                    }
+                } label: {
+                    if viewModel.isSavingDefaultBranch {
+                        ProgressView()
+                            .frame(maxWidth: .infinity)
+                    } else {
+                        Text("Set Default Branch")
+                            .frame(maxWidth: .infinity)
+                    }
                 }
+                .disabled(
+                    viewModel.isMutating ||
+                    !viewModel.isDefaultBranchDirty ||
+                    viewModel.defaultBranchValidationMessage != nil
+                )
             }
-            .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
         } header: {
-            Text("Rename")
+            Text("Default Branch")
         }
     }
 
-    // MARK: - Access Section
-
     @ViewBuilder
-    private func accessSection() -> some View {
+    private func visibilitySection(_ viewModel: RepositorySettingsViewModel) -> some View {
         Section {
-            NavigationLink {
-                RepositoryACLView(repository: repository, client: client, showsDoneButton: false)
-            } label: {
-                Label("Manage Access", systemImage: "person.2")
+            Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
+                Text("Public").tag(Visibility.public)
+                Text("Unlisted").tag(Visibility.unlisted)
+                Text("Private").tag(Visibility.private)
             }
 
-            Text("Review and update repository access without leaving settings.")
+            Text("Visibility changes apply immediately after you confirm them.")
                 .font(.caption)
                 .foregroundStyle(.secondary)
+
+            Button {
+                showVisibilityConfirmation = true
+            } label: {
+                if viewModel.isUpdatingVisibility {
+                    ProgressView()
+                        .frame(maxWidth: .infinity)
+                } else {
+                    Text("Apply Visibility Change")
+                        .frame(maxWidth: .infinity)
+                }
+            }
+            .disabled(viewModel.isMutating || !viewModel.isVisibilityDirty)
         } header: {
-            Text("Access")
+            Text("Sensitive Settings")
         }
     }
 
-    // MARK: - Delete Section
-
     @ViewBuilder
     private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
         Section {
@@ -214,14 +252,24 @@ struct RepositorySettingsView: View {
                         .frame(maxWidth: .infinity)
                 }
             }
-            .disabled(viewModel.isDeleting)
+            .disabled(viewModel.isMutating)
+        } header: {
+            Text("Danger Zone")
         }
     }
 
-    private struct SaveResultAlert: Identifiable {
-        let title: String
-        let message: String
+    private func visibilityConfirmationTitle(for viewModel: RepositorySettingsViewModel) -> String {
+        "Change visibility to \(repositoryVisibilityLabel(viewModel.editedVisibility))?"
+    }
 
-        var id: String { "\(title)-\(message)" }
+    private func visibilityConfirmationMessage(for viewModel: RepositorySettingsViewModel) -> String {
+        switch viewModel.editedVisibility {
+        case .public:
+            "Anyone will be able to find and view this repository."
+        case .unlisted:
+            "People with the link can view this repository, but it won't appear in public listings."
+        case .private:
+            "Only people with explicit access will be able to view this repository."
+        }
     }
 }
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index 30fe040..8bd80a1 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -1,72 +1,119 @@
 import Foundation
 
-// MARK: - Response types
-
-private struct UpdateRepoResponse: Decodable, Sendable {
-    let updateRepository: UpdatedRepo
-}
-
-private struct UpdateRepoInfoResponse: Decodable, Sendable {
-    let updateRepository: UpdatedRepoInfo
+private struct UpdateRepositoryResponse: Decodable, Sendable {
+    let updateRepository: UpdatedRepositoryPayload
 }
 
-private struct UpdatedRepo: Decodable, Sendable {
-    let id: Int?
-    let rid: String?
+private struct UpdatedRepositoryPayload: Decodable, Sendable {
+    let id: Int
+    let rid: String
     let name: String
     let description: String?
-    let visibility: Visibility?
-}
+    let visibility: Visibility
+    let updated: Date
+    let head: Reference?
 
-private struct UpdatedRepoInfo: Decodable, Sendable {
-    let id: Int
+    enum CodingKeys: String, CodingKey {
+        case id, rid, name, description, visibility, updated
+        case head = "HEAD"
+    }
+
+    func repositorySummary(using owner: Entity, service: SRHTService) -> RepositorySummary {
+        RepositorySummary(
+            id: id,
+            rid: rid,
+            service: service,
+            name: name,
+            description: description,
+            visibility: visibility,
+            updated: updated,
+            owner: owner,
+            head: head
+        )
+    }
 }
 
-private struct DeleteRepoResponse: Decodable, Sendable {
-    let deleteRepository: DeletedRepo
+private struct DeleteRepositoryResponse: Decodable, Sendable {
+    let deleteRepository: DeletedRepositoryPayload
 }
 
-private struct DeletedRepo: Decodable, Sendable {
+private struct DeletedRepositoryPayload: Decodable, Sendable {
     let id: Int
 }
 
-// MARK: - View Model
-
 @Observable
 @MainActor
 final class RepositorySettingsViewModel {
-
     let repositoryId: Int
     let repositoryRid: String
     let service: SRHTService
-    private let client: SRHTClient
 
-    // MARK: - Info fields
+    private let client: SRHTClient
+    private(set) var repository: RepositorySummary
 
+    var editedName: String
     var editedDescription: String
     var editedVisibility: Visibility
     var editedHead: String
-    private let originalEditedHead: String
-    var isSavingInfo = false
 
-    // MARK: - Rename fields
+    private(set) var branches: [ReferenceDetail]
+    var isSavingMetadata = false
+    var isSavingDefaultBranch = false
+    var isUpdatingVisibility = false
+    var isDeleting = false
+    var error: String?
+    var didDelete = false
 
-    var editedName: String
-    var isRenaming = false
+    var isMutating: Bool {
+        isSavingMetadata || isSavingDefaultBranch || isUpdatingVisibility || isDeleting
+    }
 
-    // MARK: - Delete state
+    var currentDefaultBranchName: String {
+        repository.defaultBranchName ?? "Not set"
+    }
 
-    var isDeleting = false
+    var availableBranchNames: [String] {
+        branches.map { RepositorySummary.displayBranchName(for: $0.name) }
+    }
 
-    // MARK: - Branches (for HEAD picker)
+    var normalizedEditedName: String {
+        editedName.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
 
-    var branches: [ReferenceDetail]
+    var normalizedEditedDescription: String {
+        editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
 
-    // MARK: - Results
+    var metadataValidationMessage: String? {
+        Self.metadataValidationMessage(for: normalizedEditedName)
+    }
 
-    var error: String?
-    var updatedName: String?
-    var didDelete = false
+    var defaultBranchValidationMessage: String? {
+        guard !branches.isEmpty else {
+            return "This repository doesn't have any branches yet."
+        }
+        let normalizedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !normalizedHead.isEmpty else {
+            return "Select a default branch."
+        }
+        guard availableBranchNames.contains(normalizedHead) else {
+            return "Select one of the available branches."
+        }
+        return nil
+    }
+
+    var isMetadataDirty: Bool {
+        normalizedEditedName != repository.name ||
+        normalizedEditedDescription != (repository.description ?? "")
+    }
+
+    var isDefaultBranchDirty: Bool {
+        editedHead.trimmingCharacters(in: .whitespacesAndNewlines) != (repository.defaultBranchName ?? "")
+    }
+
+    var isVisibilityDirty: Bool {
+        editedVisibility != repository.visibility
+    }
 
     init(
         repository: RepositorySummary,
@@ -77,118 +124,103 @@ final class RepositorySettingsViewModel {
         self.repositoryRid = repository.rid
         self.service = repository.service
         self.client = client
+        self.repository = repository
+        self.branches = branches
+        self.editedName = repository.name
         self.editedDescription = repository.description ?? ""
         self.editedVisibility = repository.visibility
-        self.editedName = repository.name
-        self.branches = branches
-
-        // Extract branch name from HEAD reference
-        let initialEditedHead: String
-        if let head = repository.head?.name {
-            initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
-        } else {
-            initialEditedHead = "main"
-        }
-        self.editedHead = initialEditedHead
-        self.originalEditedHead = initialEditedHead
+        self.editedHead = repository.defaultBranchName ?? ""
     }
 
-    // MARK: - Update Repository Info
-
-    private static let updateRepoMutation = """
+    private static let updateRepositoryMutation = """
     mutation updateRepository($id: Int!, $input: RepoInput!) {
         updateRepository(id: $id, input: $input) {
-            id rid name description visibility
+            id
+            rid
+            name
+            description
+            visibility
+            updated
+            HEAD { name target }
         }
     }
     """
 
-    private static let updateRepoInfoMutation = """
-    mutation updateRepository($id: Int!, $input: RepoInput!) {
-        updateRepository(id: $id, input: $input) {
-            id
-        }
+    private static let deleteRepositoryMutation = """
+    mutation deleteRepository($id: Int!) {
+        deleteRepository(id: $id) { id }
     }
     """
 
-    func saveInfo() async -> Bool {
-        isSavingInfo = true
-        defer { isSavingInfo = false }
+    func saveMetadata() async -> RepositorySummary? {
+        guard !isMutating else { return nil }
+        if let metadataValidationMessage {
+            error = metadataValidationMessage
+            return nil
+        }
+        guard isMetadataDirty else { return repository }
+
+        isSavingMetadata = true
+        defer { isSavingMetadata = false }
         error = nil
 
+        let input: [String: any Sendable] = [
+            "name": normalizedEditedName,
+            "description": normalizedEditedDescription
+        ]
+
         do {
-            var input: [String: any Sendable] = [
-                "description": editedDescription,
-                "visibility": editedVisibility.rawValue
-            ]
-            if let headReference = selectedHeadReferenceForSave() {
-                input["HEAD"] = headReference
-            }
-            _ = try await client.execute(
-                service: service,
-                query: Self.updateRepoInfoMutation,
-                variables: ["id": repositoryId, "input": input],
-                responseType: UpdateRepoInfoResponse.self
-            )
-            return true
+            return try await updateRepository(with: input)
         } catch {
-            self.error = error.userFacingMessage
-            return false
+            self.error = "Couldn't update repository details. \(error.userFacingMessage)"
+            return nil
         }
     }
 
-    // MARK: - Rename
+    func saveDefaultBranch() async -> RepositorySummary? {
+        guard !isMutating else { return nil }
+        if let defaultBranchValidationMessage {
+            error = defaultBranchValidationMessage
+            return nil
+        }
+        guard isDefaultBranchDirty else { return repository }
+        guard let headReference = selectedHeadReferenceForSave() else {
+            error = "Select one of the available branches."
+            return nil
+        }
 
-    func rename() async {
-        isRenaming = true
-        defer { isRenaming = false }
+        isSavingDefaultBranch = true
+        defer { isSavingDefaultBranch = false }
         error = nil
 
         do {
-            let input: [String: any Sendable] = [
-                "name": editedName
-            ]
-            let result = try await client.execute(
-                service: service,
-                query: Self.updateRepoMutation,
-                variables: ["id": repositoryId, "input": input],
-                responseType: UpdateRepoResponse.self
-            )
-            updatedName = result.updateRepository.name
+            return try await updateRepository(with: ["HEAD": headReference])
         } catch {
-            self.error = error.userFacingMessage
+            self.error = "Couldn't update the default branch. \(error.userFacingMessage)"
+            return nil
         }
     }
 
-    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 updateVisibility() async -> RepositorySummary? {
+        guard !isMutating else { return nil }
+        guard isVisibilityDirty else { return repository }
 
-    func selectedHeadReferenceForSave() -> String? {
-        let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard normalizedEditedHead != originalEditedHead else {
+        isUpdatingVisibility = true
+        defer { isUpdatingVisibility = false }
+        error = nil
+
+        do {
+            return try await updateRepository(with: ["visibility": editedVisibility.rawValue])
+        } catch {
+            self.error = "Couldn't update visibility. \(error.userFacingMessage)"
+            editedVisibility = repository.visibility
             return nil
         }
-
-        return branches.first {
-            $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
-        }?.name
     }
 
-    // MARK: - Delete Repository
-
-    private static let deleteRepoMutation = """
-    mutation deleteRepository($id: Int!) {
-        deleteRepository(id: $id) { id }
-    }
-    """
-
     func deleteRepository() async {
+        guard !isMutating else { return }
+
         isDeleting = true
         defer { isDeleting = false }
         error = nil
@@ -196,13 +228,69 @@ final class RepositorySettingsViewModel {
         do {
             _ = try await client.execute(
                 service: service,
-                query: Self.deleteRepoMutation,
+                query: Self.deleteRepositoryMutation,
                 variables: ["id": repositoryId],
-                responseType: DeleteRepoResponse.self
+                responseType: DeleteRepositoryResponse.self
             )
             didDelete = true
         } catch {
             self.error = error.userFacingMessage
         }
     }
+
+    private func updateRepository(with input: [String: any Sendable]) async throws -> RepositorySummary {
+        let result = try await client.execute(
+            service: service,
+            query: Self.updateRepositoryMutation,
+            variables: ["id": repositoryId, "input": input],
+            responseType: UpdateRepositoryResponse.self
+        )
+        let updatedRepository = result.updateRepository.repositorySummary(
+            using: repository.owner,
+            service: service
+        )
+        apply(updatedRepository)
+        return updatedRepository
+    }
+
+    private func apply(_ updatedRepository: RepositorySummary) {
+        repository = updatedRepository
+        editedName = updatedRepository.name
+        editedDescription = updatedRepository.description ?? ""
+        editedVisibility = updatedRepository.visibility
+        editedHead = updatedRepository.defaultBranchName ?? ""
+    }
+
+    static func metadataValidationMessage(for name: String) -> String? {
+        guard !name.isEmpty else {
+            return "Enter a repository name."
+        }
+        guard !name.contains("/") else {
+            return "Repository names can't contain '/'."
+        }
+        guard name.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else {
+            return "Repository names can't contain spaces."
+        }
+        return nil
+    }
+
+    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 != (repository.defaultBranchName ?? "") else {
+            return nil
+        }
+
+        return branches.first {
+            RepositorySummary.displayBranchName(for: $0.name) == normalizedEditedHead
+        }?.name
+    }
 }
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
index 5b06539..3b76da1 100644
--- a/HutchTests/RepositorySettingsViewModelTests.swift
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -89,6 +89,32 @@ struct RepositorySettingsViewModelTests {
         #expect(viewModel.selectedHeadReferenceForSave() == nil)
     }
 
+    @Test
+    @MainActor
+    func metadataValidationRejectsEmptyOrInvalidNames() {
+        #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "") == "Enter a repository name.")
+        #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo/name") == "Repository names can't contain '/'.")
+        #expect(RepositorySettingsViewModel.metadataValidationMessage(for: "repo name") == "Repository names can't contain spaces.")
+    }
+
+    @Test
+    @MainActor
+    func metadataDirtyStateTracksNormalizedInputs() {
+        let viewModel = RepositorySettingsViewModel(
+            repository: makeRepository(headName: "refs/heads/main"),
+            branches: [ReferenceDetail(name: "refs/heads/main", target: nil, date: nil)],
+            client: SRHTClient(token: "test-token")
+        )
+
+        #expect(viewModel.isMetadataDirty == false)
+        viewModel.editedDescription = "  updated  "
+        #expect(viewModel.isMetadataDirty)
+
+        viewModel.editedDescription = "desc"
+        viewModel.editedName = "  repo  "
+        #expect(viewModel.isMetadataDirty == false)
+    }
+
     @MainActor
     private func makeRepository(headName: String?) -> RepositorySummary {
         RepositorySummary(