Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions graphcode/Sources/Clients/GitClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 11 additions & 1 deletion graphcode/Sources/Clients/RemoteGitClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
)
}

Expand Down
47 changes: 47 additions & 0 deletions graphcode/Sources/Features/Worktrees/WorktreeSweepFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
var failure: String?

var id: String { projectPath }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

}
}
}
Expand Down
15 changes: 14 additions & 1 deletion graphcode/Sources/Features/Worktrees/WorktreeSweepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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"
Expand Down
80 changes: 78 additions & 2 deletions graphcode/Tests/WorktreeSweepFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ 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(
id: branch, repositoryPath: "/repo", worktreePath: "/repo-\(branch)",
branch: branch),
facts: WorktreeGitFacts(
defaultBranch: "main", commitsNotLanded: 0, dirtyFileCount: dirty, pushed: true,
sizeBytes: size))
locked: locked, sizeBytes: size))
}

@Test
Expand Down Expand Up @@ -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()"
}
}
}