Skip to content

Commit 6325be2

Browse files
committed
feat: add integration detection for squash-merged branches
Improve branch cleanup detection with four ordered checks to identify when branch content is already in the target branch. This enables accurate removal of squash-merged branches even after target advances. **New integration checks (in cost order):** - Same commit: Branch HEAD is literally the same commit as target - No added changes: Three-dot diff shows no file changes beyond merge-base - Tree contents match: Tree SHA equals target's tree SHA - Merge simulation: Merging branch into target adds nothing (--full only) **Status symbols updated:** - `·` for same commit (cheapest check) - `⊂` for content integrated via different history (all other cases) Previously used `≡` (matches main) and `_` (no commits) which were ambiguous. The new symbols better represent the checked conditions. **Implementation:** - Add `IsAncestorTask`, `HasFileChangesTask`, `WouldMergeAddTask` to task DAG - Add `is_ancestor`, `has_file_changes`, `would_merge_add` fields to `ListItem` - Create `IntegrationReason` enum for type-safe reason tracking - Update `is_potentially_removable()` logic with ordered checks - Refine `BranchOpState` variants to map to integration reasons
1 parent efc8c5f commit 6325be2

File tree

61 files changed

+587
-238
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+587
-238
lines changed

docs/content/claude-code.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The plugin adds status indicators to `wt list`:
2020
@ <b>main</b> <span class=d>^</span> <b>./repo</b> <span class=d>b834638e</span> <span class=d>1d</span> <span class=d>Initial commit</span>
2121
+ feature-api <span class=d>↑</span> 🤖 <span class=g>↑1</span> ./repo.feature-api <span class=d>9606cd0f</span> <span class=d>1d</span> <span class=d>Add REST API endpoints</span>
2222
+ review-ui <span class=c>?</span> <span class=d>↑</span> 💬 <span class=g>↑1</span> ./repo.review-ui <span class=d>afd3b353</span> <span class=d>1d</span> <span class=d>Add dashboard component</span>
23-
+ <span class=d>wip-docs</span> <span class=c>?</span><span class=d>_</span> <span class=d>./repo.wip-docs</span> <span class=d>b834638e</span> <span class=d>1d</span> <span class=d>Initial commit</span>
23+
+ <span class=d>wip-docs</span> <span class=c>?</span><span class=d>·</span> <span class=d>./repo.wip-docs</span> <span class=d>b834638e</span> <span class=d>1d</span> <span class=d>Initial commit</span>
2424

2525
⚪ <span class=d>Showing 4 worktrees, 2 ahead</span>
2626
{% end %}

docs/content/list.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ Include branches that don't have worktrees:
5656
@ <b>feature-api</b> <span class=c>+</span> <span class=d>↕</span><span class=d>⇡</span> <span class=g>+54</span> <span class=r>-5</span> <span class=g>↑4</span> <span class=d><span class=r>↓1</span></span> <span class=g>+234</span> <span class=r>-24</span> <b>./repo.feature-api</b> <span class=g>⇡3</span> <span class=d><span style='color:var(--blue,#00a)'>●</span></span> <span class=d>28d38c20</span> <span class=d>30m</span>
5757
^ main <span class=d>^</span><span class=d>⇅</span> ./repo <span class=g>⇡1</span> <span class=d><span class=r>⇣1</span></span> <span class=g>●</span> <span class=d>2e6b7a8f</span> <span class=d>4d</span>
5858
+ fix-auth <span class=d>↕</span><span class=d>|</span> <span class=g>↑2</span> <span class=d><span class=r>↓1</span></span> <span class=g>+25</span> <span class=r>-11</span> ./repo.fix-auth <span class=d>|</span> <span class=g>●</span> <span class=d>1d697d5b</span> <span class=d>5h</span>
59-
exp <span class=d>↕</span> <span class=d>/</span> <span class=g>↑2</span> <span class=d><span class=r>↓1</span></span> <span class=g>+137</span> <span class=d>32936618</span> <span class=d>2d</span>
60-
wip <span class=d>↕</span> <span class=d>/</span> <span class=g>↑1</span> <span class=d><span class=r>↓1</span></span> <span class=g>+33</span> <span class=d>6844b101</span> <span class=d>3d</span>
59+
<span class=d>exp</span> <span class=d>⊂</span><span class=d>↕</span> <span class=d>/</span> <span class=g>↑2</span> <span class=d><span class=r>↓1</span></span> <span class=g>+137</span> <span class=d>32936618</span> <span class=d>2d</span>
60+
<span class=d>wip</span> <span class=d>⊂</span><span class=d>↕</span> <span class=d>/</span> <span class=g>↑1</span> <span class=d><span class=r>↓1</span></span> <span class=g>+33</span> <span class=d>6844b101</span> <span class=d>3d</span>
6161

6262
⚪ <span class=d>Showing 3 worktrees, 2 branches, 1 with changes, 4 ahead, 1 column hidden</span>
6363
{% end %}
@@ -112,8 +112,8 @@ The Status column has multiple subcolumns. Within each, only the first matching
112112
| | `` | Rebase in progress |
113113
| | `` | Merge in progress |
114114
| | `` | Would conflict if merged to main |
115-
| | `` | Matches main (identical contents) |
116-
| | `_` | No commits ahead, clean working tree |
115+
| | `·` | Same commit |
116+
| | `` | Content integrated (`--full` detects additional cases) |
117117
| Main divergence | `^` | Is the main branch |
118118
| | `` | Ahead of main |
119119
| | `` | Behind main |
@@ -127,7 +127,7 @@ The Status column has multiple subcolumns. Within each, only the first matching
127127
| | `` | Prunable (directory missing) |
128128
| | `` | Locked worktree |
129129

130-
Rows are dimmed when the branch [content is already in main](@/remove.md#branch-cleanup) (`` matches main or `_` no commits ahead).
130+
Rows are dimmed when the branch [content is already in main](@/remove.md#branch-cleanup) (`·` same commit or `` content integrated).
131131

132132
## JSON output
133133

@@ -149,7 +149,7 @@ wt list --format=json | jq '.[] | select(.status.main_divergence == "Ahead")'
149149

150150
**Status fields:**
151151
- `working_tree`: `{untracked, modified, staged, renamed, deleted}`
152-
- `branch_state`: `""` | `"Conflicts"` | `"MergeTreeConflicts"` | `"MatchesMain"` | `"NoCommits"`
152+
- `branch_state`: `""` | `"Conflicts"` | `"MergeTreeConflicts"` | `"TreesMatch"` | `"NoAddedChanges"` | `"MergeAddsNothing"`
153153
- `git_operation`: `""` | `"Rebase"` | `"Merge"`
154154
- `main_divergence`: `""` | `"Ahead"` | `"Behind"` | `"Diverged"`
155155
- `upstream_divergence`: `""` | `"Ahead"` | `"Behind"` | `"Diverged"`

docs/content/remove.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ wt remove -D experimental
4141

4242
Branches delete automatically when their content is already in the target branch (typically main). This works with squash-merge and rebase workflows where commit history differs but file changes match.
4343

44-
A branch is safe to delete when its content is already reflected in the target. Worktrunk checks three conditions:
44+
A branch is safe to delete when its content is already reflected in the target. Worktrunk checks four conditions (in order of cost):
4545

46-
1. **No added changes** — Three-dot diff (`main...branch`) shows no files. The branch has no file changes beyond the merge-base.
47-
2. **Tree contents match** — Branch tree SHA equals main tree SHA. Commit history differs but file contents are identical (e.g., after a revert or merge commit pulling in main).
48-
3. **Merge adds nothing** — Simulated merge (`git merge-tree`) produces the same tree as main. Handles squash-merged branches where main has since advanced.
46+
1. **Same commit** — Branch HEAD is literally the same commit as target.
47+
2. **No added changes** — Three-dot diff (`main...branch`) shows no files. The branch has no file changes beyond the merge-base (includes "branch is ancestor" case).
48+
3. **Tree contents match** — Branch tree SHA equals main tree SHA. Commit history differs but file contents are identical (e.g., after a revert or merge commit pulling in main).
49+
4. **Merge adds nothing** — Simulated merge (`git merge-tree`) produces the same tree as main. Handles squash-merged branches where main has since advanced.
4950

50-
In `wt list`, `_` indicates no commits ahead of main, and `` indicates tree contents match. Branches showing either are dimmed as safe to delete.
51+
In `wt list`, `·` indicates same commit, and `` indicates content is integrated. Branches showing either are dimmed as safe to delete.
5152

5253
Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of status.
5354

src/cli.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,8 +1462,8 @@ The Status column has multiple subcolumns. Within each, only the first matching
14621462
| | `↻` | Rebase in progress |
14631463
| | `⋈` | Merge in progress |
14641464
| | `⚔` | Would conflict if merged to main |
1465-
| | `` | Matches main (identical contents) |
1466-
| | `_` | No commits ahead, clean working tree |
1465+
| | `·` | Same commit |
1466+
| | `` | Content integrated (`--full` detects additional cases) |
14671467
| Main divergence | `^` | Is the main branch |
14681468
| | `↑` | Ahead of main |
14691469
| | `↓` | Behind main |
@@ -1477,7 +1477,7 @@ The Status column has multiple subcolumns. Within each, only the first matching
14771477
| | `⌫` | Prunable (directory missing) |
14781478
| | `⊠` | Locked worktree |
14791479
1480-
Rows are dimmed when the branch [content is already in main](@/remove.md#branch-cleanup) (`≡` matches main or `_` no commits ahead).
1480+
Rows are dimmed when the branch [content is already in main](@/remove.md#branch-cleanup) (`·` same commit or `⊂` content integrated).
14811481
14821482
## JSON output
14831483
@@ -1499,7 +1499,7 @@ wt list --format=json | jq '.[] | select(.status.main_divergence == "Ahead")'
14991499
15001500
**Status fields:**
15011501
- `working_tree`: `{untracked, modified, staged, renamed, deleted}`
1502-
- `branch_state`: `""` | `"Conflicts"` | `"MergeTreeConflicts"` | `"MatchesMain"` | `"NoCommits"`
1502+
- `branch_state`: `""` | `"Conflicts"` | `"MergeTreeConflicts"` | `"TreesMatch"` | `"NoAddedChanges"` | `"MergeAddsNothing"`
15031503
- `git_operation`: `""` | `"Rebase"` | `"Merge"`
15041504
- `main_divergence`: `""` | `"Ahead"` | `"Behind"` | `"Diverged"`
15051505
- `upstream_divergence`: `""` | `"Ahead"` | `"Behind"` | `"Diverged"`
@@ -1680,13 +1680,14 @@ wt remove -D experimental
16801680
16811681
Branches delete automatically when their content is already in the target branch (typically main). This works with squash-merge and rebase workflows where commit history differs but file changes match.
16821682
1683-
A branch is safe to delete when its content is already reflected in the target. Worktrunk checks three conditions:
1683+
A branch is safe to delete when its content is already reflected in the target. Worktrunk checks four conditions (in order of cost):
16841684
1685-
1. **No added changes** — Three-dot diff (`main...branch`) shows no files. The branch has no file changes beyond the merge-base.
1686-
2. **Tree contents match** — Branch tree SHA equals main tree SHA. Commit history differs but file contents are identical (e.g., after a revert or merge commit pulling in main).
1687-
3. **Merge adds nothing** — Simulated merge (`git merge-tree`) produces the same tree as main. Handles squash-merged branches where main has since advanced.
1685+
1. **Same commit** — Branch HEAD is literally the same commit as target.
1686+
2. **No added changes** — Three-dot diff (`main...branch`) shows no files. The branch has no file changes beyond the merge-base (includes "branch is ancestor" case).
1687+
3. **Tree contents match** — Branch tree SHA equals main tree SHA. Commit history differs but file contents are identical (e.g., after a revert or merge commit pulling in main).
1688+
4. **Merge adds nothing** — Simulated merge (`git merge-tree`) produces the same tree as main. Handles squash-merged branches where main has since advanced.
16881689
1689-
In `wt list`, `_` indicates no commits ahead of main, and `` indicates tree contents match. Branches showing either are dimmed as safe to delete.
1690+
In `wt list`, `·` indicates same commit, and `` indicates content is integrated. Branches showing either are dimmed as safe to delete.
16901691
16911692
Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of status.
16921693

src/commands/list/collect.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ pub(super) enum TaskResult {
7171
item_idx: usize,
7272
committed_trees_match: bool,
7373
},
74+
/// Whether branch has file changes beyond the merge-base (three-dot diff)
75+
HasFileChanges {
76+
item_idx: usize,
77+
has_file_changes: bool,
78+
},
79+
/// Whether merging branch into main would add changes (merge simulation)
80+
/// Only computed with --full flag
81+
WouldMergeAdd {
82+
item_idx: usize,
83+
would_merge_add: bool,
84+
},
85+
/// Whether branch HEAD is ancestor of main (same commit or already merged)
86+
IsAncestor { item_idx: usize, is_ancestor: bool },
7487
/// Line diff vs main branch
7588
BranchDiff {
7689
item_idx: usize,
@@ -119,6 +132,9 @@ impl TaskResult {
119132
TaskResult::CommitDetails { item_idx, .. }
120133
| TaskResult::AheadBehind { item_idx, .. }
121134
| TaskResult::CommittedTreesMatch { item_idx, .. }
135+
| TaskResult::HasFileChanges { item_idx, .. }
136+
| TaskResult::WouldMergeAdd { item_idx, .. }
137+
| TaskResult::IsAncestor { item_idx, .. }
122138
| TaskResult::BranchDiff { item_idx, .. }
123139
| TaskResult::WorkingTreeDiff { item_idx, .. }
124140
| TaskResult::MergeTreeConflicts { item_idx, .. }
@@ -301,6 +317,24 @@ fn drain_results(
301317
} => {
302318
items[item_idx].committed_trees_match = Some(committed_trees_match);
303319
}
320+
TaskResult::HasFileChanges {
321+
item_idx,
322+
has_file_changes,
323+
} => {
324+
items[item_idx].has_file_changes = Some(has_file_changes);
325+
}
326+
TaskResult::WouldMergeAdd {
327+
item_idx,
328+
would_merge_add,
329+
} => {
330+
items[item_idx].would_merge_add = Some(would_merge_add);
331+
}
332+
TaskResult::IsAncestor {
333+
item_idx,
334+
is_ancestor,
335+
} => {
336+
items[item_idx].is_ancestor = Some(is_ancestor);
337+
}
304338
TaskResult::BranchDiff {
305339
item_idx,
306340
branch_diff,
@@ -545,6 +579,9 @@ pub fn collect(
545579
counts: None,
546580
branch_diff: None,
547581
committed_trees_match: None,
582+
has_file_changes: None,
583+
would_merge_add: None,
584+
is_ancestor: None,
548585
upstream: None,
549586
pr_status: None,
550587
status_symbols: None,
@@ -944,6 +981,9 @@ pub fn build_worktree_item(
944981
counts: None,
945982
branch_diff: None,
946983
committed_trees_match: None,
984+
has_file_changes: None,
985+
would_merge_add: None,
986+
is_ancestor: None,
947987
upstream: None,
948988
pr_status: None,
949989
status_symbols: None,

src/commands/list/collect_progressive_impl.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,105 @@ impl Task for CommittedTreesMatchTask {
209209
}
210210
}
211211

212+
/// Task 3b: File changes check (does branch have file changes beyond merge-base?)
213+
///
214+
/// Uses three-dot diff (`main...branch`) to detect if the branch has any file
215+
/// changes relative to the merge-base with main. Returns false when the diff
216+
/// is empty, indicating the branch content is already integrated into main.
217+
///
218+
/// This catches branches where commits exist (ahead > 0) but those commits
219+
/// don't add any file changes - e.g., squash-merged branches, merge commits
220+
/// that pulled in main, or commits whose changes were reverted.
221+
pub struct HasFileChangesTask;
222+
223+
impl Task for HasFileChangesTask {
224+
const KIND: TaskKind = TaskKind::HasFileChanges;
225+
226+
fn compute(ctx: TaskContext) -> TaskResult {
227+
let has_file_changes = if let Some(base) = ctx.default_branch.as_deref() {
228+
let repo = Repository::at(&ctx.repo_path);
229+
if let Some(branch) = ctx.branch.as_deref() {
230+
// Check if three-dot diff is empty
231+
repo.has_added_changes(branch, base).unwrap_or(true)
232+
} else {
233+
// No branch name (detached HEAD) - assume has changes
234+
true
235+
}
236+
} else {
237+
// No default branch - assume has changes
238+
true
239+
};
240+
241+
TaskResult::HasFileChanges {
242+
item_idx: ctx.item_idx,
243+
has_file_changes,
244+
}
245+
}
246+
}
247+
248+
/// Task 3b: Merge simulation (--full only)
249+
///
250+
/// Checks if merging the branch into main would add any changes by simulating
251+
/// the merge with `git merge-tree --write-tree`. Returns false when the merge
252+
/// result equals main's tree, indicating the branch is already integrated.
253+
///
254+
/// This catches branches where main has advanced past the squash-merge point -
255+
/// the three-dot diff might show changes, but those changes are already in main
256+
/// via the squash merge.
257+
pub struct WouldMergeAddTask;
258+
259+
impl Task for WouldMergeAddTask {
260+
const KIND: TaskKind = TaskKind::WouldMergeAdd;
261+
262+
fn compute(ctx: TaskContext) -> TaskResult {
263+
let would_merge_add = if let Some(base) = ctx.default_branch.as_deref() {
264+
let repo = Repository::at(&ctx.repo_path);
265+
if let Some(branch) = ctx.branch.as_deref() {
266+
// Simulate merging branch into main
267+
repo.would_merge_add_to_target(branch, base).unwrap_or(true)
268+
} else {
269+
// No branch name (detached HEAD) - assume would add changes
270+
true
271+
}
272+
} else {
273+
// No default branch - assume would add changes
274+
true
275+
};
276+
277+
TaskResult::WouldMergeAdd {
278+
item_idx: ctx.item_idx,
279+
would_merge_add,
280+
}
281+
}
282+
}
283+
284+
/// Task 3c: Ancestor check (is branch HEAD an ancestor of main?)
285+
///
286+
/// This is the cheapest integration check - just runs `git merge-base --is-ancestor`.
287+
/// Returns true when the branch HEAD is the same commit as main, or is an ancestor
288+
/// of main (already merged via fast-forward or rebase).
289+
pub struct IsAncestorTask;
290+
291+
impl Task for IsAncestorTask {
292+
const KIND: TaskKind = TaskKind::IsAncestor;
293+
294+
fn compute(ctx: TaskContext) -> TaskResult {
295+
let is_ancestor = if let Some(base) = ctx.default_branch.as_deref() {
296+
let repo = Repository::at(&ctx.repo_path);
297+
// Check if branch HEAD is ancestor of main (or same commit)
298+
repo.is_ancestor(&ctx.commit_sha, base).unwrap_or(false)
299+
} else {
300+
// No default branch - assume not ancestor
301+
false
302+
};
303+
304+
TaskResult::IsAncestor {
305+
item_idx: ctx.item_idx,
306+
is_ancestor,
307+
}
308+
}
309+
}
310+
212311
/// Task 4: Branch diff stats vs default branch
213312
pub struct BranchDiffTask;
214313

@@ -461,6 +560,8 @@ pub fn collect_worktree_progressive(
461560
spawner.spawn::<CommitDetailsTask>(s, &ctx);
462561
spawner.spawn::<AheadBehindTask>(s, &ctx);
463562
spawner.spawn::<CommittedTreesMatchTask>(s, &ctx);
563+
spawner.spawn::<HasFileChangesTask>(s, &ctx);
564+
spawner.spawn::<IsAncestorTask>(s, &ctx);
464565
spawner.spawn::<WorkingTreeDiffTask>(s, &ctx);
465566
spawner.spawn::<GitOperationTask>(s, &ctx);
466567
spawner.spawn::<UserMarkerTask>(s, &ctx);
@@ -476,6 +577,9 @@ pub fn collect_worktree_progressive(
476577
if !skip.contains(&TaskKind::CiStatus) {
477578
spawner.spawn::<CiStatusTask>(s, &ctx);
478579
}
580+
if !skip.contains(&TaskKind::WouldMergeAdd) {
581+
spawner.spawn::<WouldMergeAddTask>(s, &ctx);
582+
}
479583
});
480584
}
481585

@@ -513,6 +617,8 @@ pub fn collect_branch_progressive(
513617
spawner.spawn::<CommitDetailsTask>(s, &ctx);
514618
spawner.spawn::<AheadBehindTask>(s, &ctx);
515619
spawner.spawn::<CommittedTreesMatchTask>(s, &ctx);
620+
spawner.spawn::<HasFileChangesTask>(s, &ctx);
621+
spawner.spawn::<IsAncestorTask>(s, &ctx);
516622
spawner.spawn::<UpstreamTask>(s, &ctx);
517623

518624
// Optional tasks (check skip set)
@@ -525,6 +631,9 @@ pub fn collect_branch_progressive(
525631
if !skip.contains(&TaskKind::CiStatus) {
526632
spawner.spawn::<CiStatusTask>(s, &ctx);
527633
}
634+
if !skip.contains(&TaskKind::WouldMergeAdd) {
635+
spawner.spawn::<WouldMergeAddTask>(s, &ctx);
636+
}
528637
});
529638
}
530639

0 commit comments

Comments
 (0)