Skip to content
Merged
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
35 changes: 29 additions & 6 deletions GraphcodeKit/Sources/Domain/WorktreeHygiene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ public enum WorktreeLoopBinding: Equatable, Sendable {
/// The three groups of the sweeper, ordered by what removing the worktree would lose.
public enum WorktreeTier: Equatable, Sendable {
/// Landed, clean, pushed and unbound — or a stale admin file with no directory.
/// Preselected; the only tier automatic removal may ever touch.
/// Never the default branch, whatever its readings say. Preselected; the only tier
/// automatic removal may ever touch.
case safeToRemove
/// Unmerged commits, uncommitted files, unpushed work, or a stopped loop still bound.
/// Never preselected: a human looks first.
Expand Down Expand Up @@ -103,13 +104,27 @@ public struct WorktreeAssessment: Identifiable, Equatable, Sendable {
self.resolvedAt = resolvedAt
}

/// Whether this worktree holds the repository's default branch. Every landed
/// reading is trivially true of it — `git cherry` of `main` against a base of
/// `main` is empty — so without this rule the trunk checks every box of the safe
/// tier whenever it lives in a linked worktree.
public var isDefaultBranch: Bool {
!ref.branch.isEmpty && ref.branch == facts.defaultBranch
}

public var tier: WorktreeTier {
// A running loop occupies the worktree, whatever branch it is on.
if binding.isRunning { return .inUse }
// The default branch itself is never a cleanup candidate, however clean it
// reads: removal deletes the branch too, and the offer would be deleting the
// trunk the whole repository hangs from. Before the prunable rule — "stale
// admin file · nothing on disk to lose" would still `branch -D` it.
if isDefaultBranch { return .lookBeforeRemoving }
// A prunable entry with unlanded commits is not "nothing to lose" — the directory
// is gone but removing it deletes the branch, and the branch is all that's left.
if facts.prunable {
return facts.landed ? .safeToRemove : .lookBeforeRemoving
}
if binding.isRunning { return .inUse }
// Locked means a human action stands between here and removal (`git worktree
// unlock`) — that is "look before removing" by definition, however clean it is.
if facts.locked { return .lookBeforeRemoving }
Expand All @@ -126,9 +141,12 @@ public struct WorktreeAssessment: Identifiable, Equatable, Sendable {
/// Whether the sweeper can act on this row at all. A running loop locks its
/// worktree, and so does `git worktree lock` — git refuses a locked removal even
/// with `--force`, so offering the checkbox would be offering a silent failure.
/// Everything else is the human's to remove, including a dirty tree, which asks for
/// confirmation first.
public var isRemovable: Bool { !binding.isRunning && !facts.locked }
/// The default branch is excluded the same way: the removal path deletes the
/// branch, and hygiene does not offer the trunk as garbage. Everything else is
/// the human's to remove, including a dirty tree, which asks for confirmation first.
public var isRemovable: Bool {
!binding.isRunning && !facts.locked && !isDefaultBranch
}

/// Whether removing this worktree discards files nothing else has a copy of — the
/// case the sweeper confirms before forcing.
Expand All @@ -147,13 +165,18 @@ public struct WorktreeAssessment: Identifiable, Equatable, Sendable {
/// with "1 file uncommitted" on a merged PR is what makes the sweeper look like it
/// never noticed the merge.
public var summary: String {
// "merged into main" on main itself is nonsense — the row says why it is here
// instead.
if binding.isRunning { return "a loop is running in it" }
if isDefaultBranch {
return "the default branch · never offered for removal"
}
if facts.prunable {
guard !facts.landed else { return "stale admin file · nothing on disk to lose" }
let unit = facts.commitsNotLanded == 1 ? "commit" : "commits"
return "directory gone · \(facts.commitsNotLanded) \(unit) not in "
+ "\(facts.defaultBranch) — only the branch remains"
}
if binding.isRunning { return "a loop is running in it" }
if tier == .safeToRemove {
return "\(mergedPhrase) · clean tree · no loop bound"
}
Expand Down
31 changes: 31 additions & 0 deletions graphcode/Tests/WorktreeHygieneTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ struct WorktreeHygieneTests {
unlanded.summary == "directory gone · 3 commits not in main — only the branch remains")
}

@Test
func theDefaultBranchIsNeverACleanupCandidate() {
// `main` checked out in a linked worktree reads as landed against itself, clean
// and pushed — every signal the safe tier asks for. But the removal path deletes
// the branch too, so the offer would be deleting the trunk.
let main = WorktreeAssessment(ref: ref("main"), facts: facts(), binding: .none)

#expect(main.tier == .lookBeforeRemoving)
#expect(!main.isRemovable)
#expect(main.summary == "the default branch · never offered for removal")
}

@Test
func theDefaultBranchIsNotSafeEvenWhenPrunable() {
// The stale-admin-file rule would read main as "nothing on disk to lose", and
// the removal would still `branch -D` the trunk.
let pruned = WorktreeAssessment(
ref: ref("main"), facts: facts(prunable: true, size: nil), binding: .none)

#expect(pruned.tier == .lookBeforeRemoving)
#expect(!pruned.isRemovable)
}

@Test
func aDefaultBranchWithARunningLoopStillReadsInUse() {
let main = WorktreeAssessment(
ref: ref("main"), facts: facts(), binding: .running(loopTitle: "Release"))

#expect(main.tier == .inUse)
}

@Test
func aLockedWorktreeIsNotRemovable() {
// git refuses a locked removal even with --force; a checkbox would offer a
Expand Down
Loading