Squash-merged stack is never detected as integrated, and the conflicted residue permanently blocks re-detection
Version
but CLI 0.22.0 (analysis done against the release/0.22.0 tag; the decisive files are byte-identical on master @ 5ed3782, 2026-08-03)
Operating System
macOS (Apple Silicon)
Summary
Follow-up to #14298 with a source-level analysis. I'm filing it separately because it covers a second, so-far-unreported defect: after the first failed integration of a squash-merged stack, the workspace enters a state that can never be detected as integrated again — there is no automatic recovery path. Fixing squash detection alone (e.g. reviving #14323) would not heal workspaces that are already in this state.
Related: #14298 (squash → conflict on integrate), #15127 ((no changes) residue never cleaned up), #8457 (same symptom on the old backend), #14323 (closed-unmerged fix attempt for the detection half).
How to reproduce
- Create a stack with 2 commits A and B that touch overlapping lines; push; open a PR.
- Squash-merge the PR on GitHub (the target branch gains one new commit whose content is A+B). The merge itself is clean — GitHub reports no conflicts, CI is green.
but pull.
Observed symptoms
1. The lane turns into a zombie instead of disappearing.
After the pull, the merged stack is still applied. Both commits were rebased onto the new base (which already contains their squashed sum), so both now show (no changes); the first one is additionally stored {conflicted}. Sanitized but status right after the pull (healthy unrelated lanes omitted, names redacted):
┊╭┄ ab [dep-bump-branch] CI: ✅ #1234: fix: bump dependencies to clear CVEs
┊◐ cd fix: adjust install step so the bump takes effect (no changes)
┊◐ ef fix: bump dependencies to clear CVEs (no changes) {conflicted}
├╯
┊
┴ 1a2b3c4 (common base) 2026-08-04 fix: bump dependencies to clear CVEs (#1234)
Hint: ◐ means rewritten locally vs upstream.Three things worth pointing out in that snapshot:
- The common base is the squash commit of this very branch — the content is demonstrably already downstream of us, yet the lane above it survives.
- GitButler itself renders
CI: ✅ #1234(the merged PR) on the exact lane it flags{conflicted}. The forge association is clearly known; it just doesn't feed the integration decision. - Both commits are now marked
◐(rewritten locally vs upstream), because the rebase rewrote them — so after the failed pull the local copies drift even further from the pushed/merged ones, making any SHA- or patch-id-style matching even less likely to ever succeed.
2. A conflict is manufactured where none exists.
The PR merged on GitHub with zero conflicts. The {conflicted} state exists only inside the local workspace, produced by GitButler replaying the stale intermediate commit onto a base that already contains the final result. From the user's perspective the timeline is: "PR merged clean → ran but pull → now I have a conflict."
3. It never heals, no matter how many times you pull.
Subsequent but pull runs report Everything is up to date and never re-examine the lane (mechanism below). The zombie survives target updates indefinitely. We squash-merge every multi-commit PR as a matter of policy, so these lanes accumulate week after week — same pain as described in #15127.
4. Even manual cleanup is bumpy.
but unapply does clear it, but the obvious follow-up fails:
$ but unapply dep-bump-branch
Unapplied stack with branches 'dep-bump-branch' from workspace
$ but branch delete dep-bump-branch
Error: Could not find branch: 'dep-bump-branch'
Hint: Run `but status` for applicable targets.(but branch delete apparently only resolves applied stacks.) And the unapplied entry still lists as local ✓ dep-bump-branch ↑2 — two commits allegedly ahead — even though every byte of them is merged. So the only real exit is unapply and then ignoring the leftover entry.
Root cause (paths at release/0.22.0)
1. The integrator discards the N:1 squash detection that already exists.
The expensive preflight in head_info performs a test-squash-merge trial: it compares the cumulative base→tip changeset of a stack against upstream commit changesets and marks everything below the boundary integrated on a match (crates/but-workspace/src/changeset.rs:169-212). The N:1 relation is even documented on LocalCommitRelation::Integrated (crates/but-workspace/src/ref_info.rs:186-191).
But the actual integration (workspace_integrate_upstream → collect_stacks) never consumes that result. It re-detects from scratch with compute_similarity_by_commit_ids, which matches workspace commits one-by-one against single upstream commits (crates/but-workspace/src/upstream_integration.rs:536-541, crates/but-core/src/changeset.rs:99-140). N commits can never match one squash commit, so the branch is not pruned. This is the mechanism behind #14298 — exactly what #14323's aggregate matcher addressed before it was closed.
2. The replay then stores a conflicted commit.
The un-pruned commits are cherry-picked onto a target that already contains their sum. A conflicting replay is stored as a synthetic conflicted commit and kept — there is no drop/skip branch for picks that become empty or conflicted (crates/but-rebase/src/graph_rebase/cherry_pick.rs:188-217, crates/but-rebase/src/graph_rebase/rebase.rs:80-92). Status renders (no changes) (the visible diff uses the .auto-resolution tree) plus the conflict flag — which is exactly the (no changes) {conflicted} combination in the snapshot above.
3. From then on, detection is permanently poisoned.
- With
behind == 0, integration only runs at all if a cleanup candidate exists (crates/but/src/command/legacy/pull/mod.rs:326-341), and aConflictedstack is not a candidate (crates/but/src/command/legacy/upstream.rs:85-97). That is why every later pull printsEverything is up to datewhile the zombie sits there. - Worse, the N:1 trial itself can no longer match: the squash-trial boundary feeds the commit's raw
tree_idinto changeset identification (crates/but-workspace/src/changeset.rs:182-199), and for a conflicted commit that raw tree is the synthetic tree including the conflict metadata subtrees —.auto-resolutionis only unwrapped for commit ids, not tree ids (crates/but-core/src/changeset.rs:492-503). The cumulative changeset therefore never equals the upstream squash changeset again.
Net effect: one failed integration turns a fully-merged branch into a permanent zombie lane.
Expected behavior
The squash-merged stack is recognized as integrated (ideally before any rebase is attempted), the branch is pruned, and a workspace that already contains conflicted residue can still be recognized and cleaned up.
Possible directions
Happy to attempt a PR for whichever direction you'd prefer:
- Wire the preflight N:1
Integratedrelation intocollect_stacks(essentially reviving #14323's aggregate matcher), and/or - Unwrap
.auto-resolutionwhen a conflicted commit's tree feeds changeset identification, so already-poisoned workspaces can still match, and/or - Treat "all commits
(no changes)after rebase" as a cleanup candidate sobehind == 0pulls can still prune.
Source: gitbutlerapp/gitbutler