From e53dc666e2d1cd2fae4a5965306e166527357f69 Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Wed, 10 Jun 2026 20:35:31 +0800 Subject: [PATCH] Add per-repository branch deletion to workspace removal The removal sheet lists a checkbox for each worktree entry with a recorded branch; selected branches are deleted from their source repositories with git branch -D after the worktree is removed. The checkboxes activate only when file cleanup is enabled. --- docs/components/workspaces.md | 11 ++-- supacode/App/ContentView.swift | 3 + supacode/Domain/ProjectWorkspace.swift | 15 +++++ ...sitoriesFeature+RepositoryManagement.swift | 35 ++++++++++- .../Reducer/RepositoriesFeature.swift | 9 +++ .../RemoveWorkspaceConfirmationView.swift | 24 ++++++++ supacodeTests/ProjectWorkspaceTests.swift | 44 ++++++++++++++ supacodeTests/RepositoriesFeatureTests.swift | 60 +++++++++++++++++++ 8 files changed, 195 insertions(+), 6 deletions(-) diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 1c2f068c..f63fa8d8 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -92,10 +92,13 @@ Branch behavior is explicit: it only removes the entry from Prowl and leaves everything on disk. Tick **Also delete the workspace folder and its worktrees** to additionally unregister the worktrees that were created for this workspace from their source repositories -(`git worktree remove --force`) and delete the workspace folder. Linked -repositories stay untouched — only the symlinks inside the workspace folder are -removed. Cleanup is best-effort: a broken source repository is logged and -skipped instead of blocking the deletion. +(`git worktree remove --force`) and delete the workspace folder. Worktree +entries with a recorded branch additionally offer a per-repository **Delete +branch** checkbox that removes the branch from the source repository +(`git branch -D`) after the worktree is gone. Linked repositories stay +untouched — only the symlinks inside the workspace folder are removed. Cleanup +is best-effort: a broken source repository is logged and skipped instead of +blocking the deletion. ## Metadata diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 33c98d63..8bab252a 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -115,6 +115,9 @@ struct ContentView: View { onDeleteFilesChanged: { repositoriesStore.send(.repositoryManagement(.removeWorkspaceDeleteFilesChanged($0))) }, + onDeleteBranchChanged: { + repositoriesStore.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged($0, $1))) + }, onCancel: { repositoriesStore.send(.repositoryManagement(.removeWorkspacePromptDismissed)) }, diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 5a19b71b..8f00f7bf 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -563,6 +563,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { static func cleanup( _ workspace: ProjectWorkspace, rootURL: URL, + deleteBranchEntryIDs: Set = [], fileManager: FileManager = .default, gitRunner: ProjectWorkspaceGitRunner ) async { @@ -588,6 +589,20 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } catch { log.warning("Workspace cleanup could not unregister worktree at \(entryPath): \(error)") } + // Branch deletion must follow the worktree removal — git refuses to + // delete a branch that still has a checkout. + if deleteBranchEntryIDs.contains(entry.id), let branchName = entry.branchName { + do { + try await gitRunner.run( + ProjectWorkspaceGitCommand( + arguments: ["-C", sourceLocation, "branch", "-D", branchName], + currentDirectoryURL: nil + ) + ) + } catch { + log.warning("Workspace cleanup could not delete branch \(branchName): \(error)") + } + } } do { try fileManager.removeItem(at: rootURL) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index 4c41963f..0a2b744c 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -164,10 +164,25 @@ extension RepositoriesFeature { case .requestRemoveRepository(let repositoryID): if let repository = state.repositories[id: repositoryID], repository.isWorkspace { + let branchOptions = (repository.workspace?.repositories ?? []).compactMap { + entry -> RemoveWorkspaceConfirmation.BranchOption? in + guard entry.sourceKind != .remote, + let branchName = entry.branchName, + entry.sourceLocation != nil + else { + return nil + } + return RemoveWorkspaceConfirmation.BranchOption( + id: entry.id, + repositoryName: entry.name, + branchName: branchName + ) + } state.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( repositoryID: repositoryID, workspaceTitle: repository.name, - rootPath: repository.rootURL.path(percentEncoded: false) + rootPath: repository.rootURL.path(percentEncoded: false), + branchOptions: branchOptions ) return .none } @@ -178,6 +193,16 @@ extension RepositoriesFeature { state.removeWorkspaceConfirmation?.deleteFiles = deleteFiles return .none + case .removeWorkspaceDeleteBranchChanged(let entryID, let isSelected): + guard var confirmation = state.removeWorkspaceConfirmation, + let index = confirmation.branchOptions.firstIndex(where: { $0.id == entryID }) + else { + return .none + } + confirmation.branchOptions[index].isSelected = isSelected + state.removeWorkspaceConfirmation = confirmation + return .none + case .removeWorkspacePromptDismissed: state.removeWorkspaceConfirmation = nil return .none @@ -205,9 +230,15 @@ extension RepositoriesFeature { ) } let rootURL = repository.rootURL + let deleteBranchEntryIDs = Set(confirmation.branchOptions.filter(\.isSelected).map(\.id)) let gitRunner = Self.workspaceGitRunner(shellClient: shellClient) return .run { send in - await ProjectWorkspace.cleanup(workspace, rootURL: rootURL, gitRunner: gitRunner) + await ProjectWorkspace.cleanup( + workspace, + rootURL: rootURL, + deleteBranchEntryIDs: deleteBranchEntryIDs, + gitRunner: gitRunner + ) await send( .repositoryManagement( .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 02183eae..00f8f0f6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -70,10 +70,18 @@ struct ForceDeleteBranchRequest: Equatable { } struct RemoveWorkspaceConfirmation: Equatable { + struct BranchOption: Equatable, Identifiable { + let id: String + let repositoryName: String + let branchName: String + var isSelected = false + } + let repositoryID: Repository.ID let workspaceTitle: String let rootPath: String var deleteFiles = false + var branchOptions: [BranchOption] = [] } @Reducer @@ -217,6 +225,7 @@ struct RepositoriesFeature { case requestRemoveRepository(Repository.ID) case removeFailedRepository(Repository.ID) case removeWorkspaceDeleteFilesChanged(Bool) + case removeWorkspaceDeleteBranchChanged(String, Bool) case removeWorkspacePromptDismissed case removeWorkspacePromptConfirmed case repositoryRemoved(Repository.ID, selectionWasRemoved: Bool) diff --git a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift index 80f6c539..ac2a64e4 100644 --- a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift +++ b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct RemoveWorkspaceConfirmationView: View { let confirmation: RemoveWorkspaceConfirmation let onDeleteFilesChanged: (Bool) -> Void + let onDeleteBranchChanged: (String, Bool) -> Void let onCancel: () -> Void let onRemove: () -> Void @@ -34,6 +35,29 @@ struct RemoveWorkspaceConfirmationView: View { + "then delete the workspace folder." ) + if !confirmation.branchOptions.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(confirmation.branchOptions) { option in + Toggle( + isOn: Binding( + get: { option.isSelected }, + set: { onDeleteBranchChanged(option.id, $0) } + ) + ) { + HStack(spacing: 4) { + Text("Delete branch") + Text(option.branchName) + .font(.body.monospaced()) + Text("in \(option.repositoryName)") + } + } + .help("Delete the branch from the source repository with git branch -D after removing the worktree.") + } + } + .padding(.leading, 20) + .disabled(!confirmation.deleteFiles) + } + Text("Linked repositories stay untouched; only the symlinks inside the workspace folder are removed.") .font(.footnote) .foregroundStyle(.secondary) diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 76691871..332bc025 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -731,6 +731,50 @@ struct ProjectWorkspaceTests { #expect(FileManager.default.fileExists(atPath: linkedSourceURL.path(percentEncoded: false))) } + @Test func cleanupDeletesSelectedBranchesAfterWorktreeRemoval() async throws { + let rootURL = try makeTemporaryWorkspaceRoot() + let bareURL = try makeTemporaryWorkspaceRoot() + defer { + try? FileManager.default.removeItem(at: rootURL) + try? FileManager.default.removeItem(at: bareURL) + } + try FileManager.default.createDirectory( + at: rootURL.appending(path: "api"), + withIntermediateDirectories: true + ) + + let workspace = ProjectWorkspace( + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: bareURL.path(percentEncoded: false), + branchName: "chore/x" + ) + ] + ) + let commands = LockIsolated<[ProjectWorkspaceGitCommand]>([]) + let apiPath = rootURL.appending(path: "api").standardizedFileURL.path(percentEncoded: false) + await ProjectWorkspace.cleanup( + workspace, + rootURL: rootURL, + deleteBranchEntryIDs: ["api"], + gitRunner: ProjectWorkspaceGitRunner { command in + commands.withValue { $0.append(command) } + } + ) + + let barePath = bareURL.path(percentEncoded: false) + #expect( + commands.value.map(\.arguments) == [ + ["-C", barePath, "worktree", "remove", "--force", apiPath], + ["-C", barePath, "branch", "-D", "chore/x"], + ]) + #expect(!FileManager.default.fileExists(atPath: rootURL.path(percentEncoded: false))) + } + @Test func listRuntimeContextsReportWorkspaceKind() { let rootURL = URL(fileURLWithPath: "/tmp/workspace") let repository = Repository( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 48a9bd48..3d010394 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -1134,6 +1134,66 @@ struct RepositoriesFeatureTests { } } + @Test func requestRemoveWorkspaceBuildsBranchOptions() async { + let workspaceID = "/tmp/ws" + let workspace = ProjectWorkspace( + title: "WS", + repositories: [ + ProjectWorkspace.RepositoryEntry( + id: "api", + name: "API", + path: "api", + sourceKind: .bareRepository, + sourceLocation: "/tmp/api.git", + branchName: "chore/x" + ), + ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + sourceLocation: "/tmp/app" + ), + ProjectWorkspace.RepositoryEntry( + id: "web", + name: "Web", + path: "web", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/web.git", + branchName: "feature/web" + ), + ] + ) + let repository = makeRepository( + id: workspaceID, + name: "WS", + kind: .plain, + worktrees: [], + workspace: workspace + ) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.repositoryManagement(.requestRemoveRepository(workspaceID))) { + $0.removeWorkspaceConfirmation = RemoveWorkspaceConfirmation( + repositoryID: workspaceID, + workspaceTitle: "WS", + rootPath: workspaceID, + branchOptions: [ + RemoveWorkspaceConfirmation.BranchOption( + id: "api", + repositoryName: "API", + branchName: "chore/x" + ) + ] + ) + } + await store.send(.repositoryManagement(.removeWorkspaceDeleteBranchChanged("api", true))) { + $0.removeWorkspaceConfirmation?.branchOptions[0].isSelected = true + } + } + @Test func removeWorkspaceConfirmedRemovesEntry() async { let workspaceID = "/tmp/ws" let repository = makeRepository( -- 2.51.2