diff --git a/graphcode/Sources/Clients/GitClient.swift b/graphcode/Sources/Clients/GitClient.swift index db36699..eb96840 100644 --- a/graphcode/Sources/Clients/GitClient.swift +++ b/graphcode/Sources/Clients/GitClient.swift @@ -28,6 +28,10 @@ struct GitClient: Sendable { /// the default's safety. var removeWorktreeAndBranch: @Sendable (_ worktree: WorktreeRef, _ prunable: Bool, _ force: Bool) async throws -> Void + /// Clears a `git worktree lock` — the one state git refuses to remove even with + /// `--force`, so unlocking is what stands between a locked row and the sweeper. + var unlockWorktree: + @Sendable (_ repositoryPath: String, _ worktreePath: String) async throws -> Void /// Streams a `git clone --progress` into `destination`: progress lines while it runs, /// `.finished` on success, a thrown `GitClientError` on failure. Streaming is what lets /// the form show a live percentage instead of a spinner over a multi-minute network @@ -145,6 +149,10 @@ extension GitClient: DependencyKey { appendRemovedBranchRecord(branch: worktree.branch, tip: tip, path: worktree.worktreePath) } }, + unlockWorktree: { repositoryPath, worktreePath in + _ = try await run( + "git", ["-C", repositoryPath, "worktree", "unlock", "--", worktreePath]) + }, clone: { url, destination, branch, depth in runClone(url: url, destination: destination, branch: branch, depth: depth) } diff --git a/graphcode/Sources/Clients/RemoteGitClient.swift b/graphcode/Sources/Clients/RemoteGitClient.swift index 177d5d2..28ef8d7 100644 --- a/graphcode/Sources/Clients/RemoteGitClient.swift +++ b/graphcode/Sources/Clients/RemoteGitClient.swift @@ -24,6 +24,9 @@ struct RemoteGitClient: Sendable { @Sendable ( _ location: RemoteProjectLocation, _ worktree: WorktreeRef, _ prunable: Bool, _ force: Bool ) async throws -> Void + /// Clears a `git worktree lock` over SSH. + var unlockWorktree: + @Sendable (_ location: RemoteProjectLocation, _ worktreePath: String) async throws -> Void } extension RemoteGitClient: DependencyKey { @@ -94,13 +97,20 @@ extension RemoteGitClient: DependencyKey { appendRemovedBranchRecord( branch: worktree.branch, tip: tip, path: worktree.worktreePath, host: location.host) } + }, + unlockWorktree: { location, worktreePath in + let quoted = RemoteProjectLocation.shellQuoted + _ = try await runSSH( + location, + "git -C \(quoted(location.remotePath)) worktree unlock \(quoted(worktreePath))") } ) static let testValue = RemoteGitClient( inspectWorktrees: { _ in [] }, worktreeSizeBytes: { _, _ in nil }, - removeWorktreeAndBranch: { _, _, _, _ in } + removeWorktreeAndBranch: { _, _, _, _ in }, + unlockWorktree: { _, _ in } ) } diff --git a/graphcode/Sources/Features/Worktrees/WorktreeSweepFeature.swift b/graphcode/Sources/Features/Worktrees/WorktreeSweepFeature.swift index 41e0c66..686d881 100644 --- a/graphcode/Sources/Features/Worktrees/WorktreeSweepFeature.swift +++ b/graphcode/Sources/Features/Worktrees/WorktreeSweepFeature.swift @@ -24,6 +24,9 @@ struct WorktreeSweepFeature { /// Up while the "this discards uncommitted files" confirmation is showing — the /// gate between selecting a dirty worktree and actually forcing it away. var isConfirmingRemoval = false + /// Worktree paths whose `git worktree unlock` is in flight — their button is + /// disabled so a double click can't race two unlocks. + var unlocking: Set = [] var failure: String? var id: String { projectPath } @@ -64,6 +67,11 @@ struct WorktreeSweepFeature { /// The dirty-selection confirmation's two exits. case removeConfirmed case removeCancelled + /// A locked row's Unlock button — `git worktree unlock`, run here in the child: + /// unlike a removal, the sheet stays open to show the row become removable. + case unlockTapped(String) + case unlockSucceeded(id: String) + case unlockFailed(id: String, String) } @Dependency(\.gitClient) var gitClient @@ -172,6 +180,45 @@ struct WorktreeSweepFeature { state.isConfirmingRemoval = false return .none + case .unlockTapped(let id): + guard let assessment = state.assessments?.first(where: { $0.id == id }), + assessment.facts.locked, !state.unlocking.contains(id) + else { return .none } + state.unlocking.insert(id) + let repositoryPath = state.projectPath + let worktreePath = assessment.ref.worktreePath + return .run { [gitClient, remoteGitClient] send in + do { + if let location = RemoteProjectLocation.parse(projectPath: repositoryPath) { + try await remoteGitClient.unlockWorktree(location, worktreePath) + } else { + try await gitClient.unlockWorktree(repositoryPath, worktreePath) + } + await send(.unlockSucceeded(id: id)) + } catch { + await send(.unlockFailed(id: id, String(describing: error))) + } + } + + case .unlockSucceeded(let id): + state.unlocking.remove(id) + guard var assessments = state.assessments, + let index = assessments.firstIndex(where: { $0.id == id }) + else { return .none } + assessments[index].facts.locked = false + state.assessments = assessments + // The unlock was this row's own button — selecting it is what makes "unlock, + // then delete" one gesture short of done. Remove still asks about dirty files. + if assessments[index].isRemovable { + state.selection.insert(id) + } + return .none + + case .unlockFailed(let id, let message): + state.unlocking.remove(id) + state.failure = message + return .none + } } } diff --git a/graphcode/Sources/Features/Worktrees/WorktreeSweepView.swift b/graphcode/Sources/Features/Worktrees/WorktreeSweepView.swift index 4da64f7..4fc6192 100644 --- a/graphcode/Sources/Features/Worktrees/WorktreeSweepView.swift +++ b/graphcode/Sources/Features/Worktrees/WorktreeSweepView.swift @@ -170,6 +170,9 @@ struct WorktreeSweepView: View { } .lineLimit(1) Spacer(minLength: 8) + if assessment.facts.locked { + unlockButton(assessment) + } if tier == .lookBeforeRemoving && !assessment.facts.prunable { Button("Reveal") { NSWorkspace.shared.activateFileViewerSelecting([ @@ -197,11 +200,21 @@ struct WorktreeSweepView: View { .help(rowHelp(assessment)) } + private func unlockButton(_ assessment: WorktreeAssessment) -> some View { + Button(store.unlocking.contains(assessment.id) ? "Unlocking…" : "Unlock") { + store.send(.unlockTapped(assessment.id)) + } + .buttonStyle(.plain) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Color(red: 0.424, green: 0.714, blue: 1.0)) + .disabled(store.unlocking.contains(assessment.id)) + } + private func rowHelp(_ assessment: WorktreeAssessment) -> String { if assessment.tier == .inUse { return "A loop is running in it" } if assessment.facts.locked { return "Locked (git worktree lock) — git refuses removal even when forced; " - + "unlock it first" + + "Unlock clears the lock and selects the row for removal" } if assessment.removalDiscardsFiles { return "Has uncommitted files — removing it discards them, and asks first" diff --git a/graphcode/Tests/WorktreeSweepFeatureTests.swift b/graphcode/Tests/WorktreeSweepFeatureTests.swift index 19060a8..f99b914 100644 --- a/graphcode/Tests/WorktreeSweepFeatureTests.swift +++ b/graphcode/Tests/WorktreeSweepFeatureTests.swift @@ -11,7 +11,7 @@ import Testing @Suite struct WorktreeSweepFeatureTests { private func inspection( - branch: String, dirty: Int = 0, size: Int64? = 100 + branch: String, dirty: Int = 0, size: Int64? = 100, locked: Bool = false ) -> WorktreeInspection { WorktreeInspection( ref: WorktreeRef( @@ -19,7 +19,7 @@ struct WorktreeSweepFeatureTests { branch: branch), facts: WorktreeGitFacts( defaultBranch: "main", commitsNotLanded: 0, dirtyFileCount: dirty, pushed: true, - sizeBytes: size)) + locked: locked, sizeBytes: size)) } @Test @@ -259,3 +259,79 @@ struct WorktreeSweepFeatureTests { } } } + +/// The locked rows' Unlock button: `git worktree unlock` run from the row, so a locked +/// worktree can be freed and then deleted without dropping to the terminal (issue #92). +@Suite +struct WorktreeSweepUnlockTests { + private func inspection(branch: String, locked: Bool) -> WorktreeInspection { + WorktreeInspection( + ref: WorktreeRef( + id: branch, repositoryPath: "/repo", worktreePath: "/repo-\(branch)", + branch: branch), + facts: WorktreeGitFacts( + defaultBranch: "main", commitsNotLanded: 0, dirtyFileCount: 0, pushed: true, + locked: locked, sizeBytes: 100)) + } + + @Test + func unlockMakesALockedRowRemovableAndSelectsIt() async { + // Locked rows can't be selected — git refuses their removal even when forced. The + // row's Unlock button clears the lock, and selecting the freed row is what makes + // "unlock, then delete" one click from done. + let locked = inspection(branch: "held", locked: true) + let unlocked = LockIsolated<[String]>([]) + let store = TestStore( + initialState: WorktreeSweepFeature.State( + projectPath: "/repo", projectName: "repo", nodes: []) + ) { + WorktreeSweepFeature() + } withDependencies: { + $0.gitClient.inspectWorktrees = { _ in [locked] } + $0.gitClient.unlockWorktree = { _, path in unlocked.withValue { $0.append(path) } } + } + + await store.send(.task) + await store.receive(\.assessmentsLoaded) { + $0.assessments = WorktreeSweepFeature.assessments([locked], nodes: []) + } + // Still locked: the click does nothing. + await store.send(.rowToggled(locked.ref.worktreePath)) + await store.send(.unlockTapped(locked.ref.worktreePath)) { + $0.unlocking = [locked.ref.worktreePath] + } + await store.receive(\.unlockSucceeded) { + $0.unlocking = [] + $0.assessments?[0].facts.locked = false + $0.selection = [locked.ref.worktreePath] + } + #expect(unlocked.value == [locked.ref.worktreePath]) + } + + @Test + func aFailedUnlockSurfacesAndLeavesTheRowLocked() async { + struct Stuck: Error {} + let locked = inspection(branch: "held", locked: true) + let store = TestStore( + initialState: WorktreeSweepFeature.State( + projectPath: "/repo", projectName: "repo", nodes: []) + ) { + WorktreeSweepFeature() + } withDependencies: { + $0.gitClient.inspectWorktrees = { _ in [locked] } + $0.gitClient.unlockWorktree = { _, _ in throw Stuck() } + } + + await store.send(.task) + await store.receive(\.assessmentsLoaded) { + $0.assessments = WorktreeSweepFeature.assessments([locked], nodes: []) + } + await store.send(.unlockTapped(locked.ref.worktreePath)) { + $0.unlocking = [locked.ref.worktreePath] + } + await store.receive(\.unlockFailed) { + $0.unlocking = [] + $0.failure = "Stuck()" + } + } +}