`but absorb` silently fails and rewrites the stack when a hunk borders a line changed higher in the stack; `but amend` blames the target itself
Version
0.22.3 (CLI but and app)
Operating System
Linux
Distribution Method
deb (Linux)
Describe the issue
This issue (and reproducer) was generated using AI. The issue happened to me while working on a repository and I have asked a model to diagnose it. I am not 100% sure the diagnosis is correct, but the attached reproducer shows the behaviour on a fresh throwaway repository.
I have included a reproducer (repro.sh + the output.txt it produced), so you can try out my issue:
Summary
In a stack lower → upper, an uncommitted hunk sits directly after a line that upper changed. GitButler's hunk dependency calculation locks the hunk to lower, the commit that created the file, and ignores upper. Replaying the hunk onto lower conflicts, and then:
but absorbfollows its plan intolowerand exits 0. It records[ABSORB] Absorbed changesand rewrites both commits (new SHAs, identical trees), but applies nothing, and the change stays uncommitted. The only signal isWarning: Failed to absorb 1 file, with no reason.but amend -t lowerrefuses, which is correct, but the error names the target itself as the dependency (lines 8–10 depends on lower (lnw)), printed three times, instead ofupper.- A second hunk on a line only
lowerowns, which merges cleanly, is refused or dropped along with the conflicting one.
Amending into upper works.
We hit this in a real 6-branch stack. Amending from the GitButler app behaved like absorb above: five [AMEND] Amended commit operations each rewrote 31–52 commits with zero tree changes and showed no error.
We hit it again in the same stack in a slightly different shape: the uncommitted change edited a line created by a lower commit, directly below a line that a higher branch had renamed. The dry run promised the lower commit, the real absorb printed only Failed to absorb 1 file, and each attempt rewrote the whole pushed stack (top commit d354c56c4 → f7bcd5120, identical tree).
Actual
1. Dependency calculation ignores upper
$ but absorb --dry-run
Absorbed to commit: lnw lower: add provider
(files locked to commit due to hunk range overlap)
provider.ts @5,6 +5,92. but amend -t lower refuses, but blames lower
Error: Cannot amend: 1 change could not be applied:
provider.ts
lines 8–10 depends on lower (lnw)
lines 8–10 depends on lower (lnw)
lines 8–10 depends on lower (lnw)In v2 the clean line-4 change is refused as well:
line 4 depends on lower (mvo)
line 4 depends on lower (mvo)
line 4 depends on lower (mvo)
lines 8–10 depends on lower (mvo)
lines 8–10 depends on lower (mvo)
lines 8–10 depends on lower (mvo)Nothing changes, which is correct for a refusal.
3. but absorb "succeeds" as a no-op rewrite
$ but absorb
Absorbed to commit: lnw lower: add provider
(files locked to commit due to hunk range overlap)
provider.ts @5,6 +5,9
Warning: Failed to absorb 1 file
Hint: you can run `but undo` to undo these changes
(exit 0)
lower sha: 3fa3b217… -> bf6a3fbb…
upper sha: b37413fe… -> 19774f41…
lower tree: d00a0c18… -> d00a0c18… (unchanged)
upper tree: fb3f0cfe… -> fb3f0cfe… (unchanged)
workspace tree change: none
still uncommitted: 1 file changed, 3 insertions(+)
$ but oplog list
26446c2 2026-09-15 10:34:09 [ABSORB] Absorbed changesbut --json absorb knows the absorb failed, but reports no reason and still exits 0 (and rewrites the stack again):
$ but --json absorb
{
"ok": false,
"rejected": 1,
"plan": { ... same plan as --dry-run, no rejection reason ... }
}
(exit 0)In v2 the clean line-4 change is not absorbed either; the whole file stays uncommitted.
4. Control: but amend -t upper works
Amended xny
upper tree: fb3f0cfe… -> af7bc16e…
still uncommitted: (nothing)Impact
- No real signal. Users see success, or at most a one-line warning, and retry.
- SHA churn. Every retry rewrites the stack: pushed branches need a force-push and open PRs churn, even though nothing changed.
- App affected too. In the app the same situation looked exactly like a successful amend.
- JSON consumers can't trust the exit code. Scripts and agents driving
butsee exit 0 and have to parseokto notice the failure, and still get no reason.
Notes
Related: #15347 (the app's lock tooltip names only one of the branches a hunk depends on). Same theme: the dependency information shown to the user is incomplete.
Reproducing the rewrite: commit hashes include the committer time at one-second resolution. If
absorbruns in the same second the commits were created, the rewritten commits hash identically and the rewrite goes unnoticed. The script sleeps 2s before each mutation.Possibly unrelated: with branches named
aandb,but statuscrashed mid-render withError: Could not find commit CLI id 'b' in IdMap(thebbranch's commit sha started withb). The repro uses other names to avoid it.
Environment
but0.22.3- git 2.53.0
- Linux (Ubuntu 26.04, deb package)
How to reproduce (Optional)
Reproducer: absorb-noop-reproducer.zip (attached below). Run ./repro.sh; it creates throwaway repos with a local bare remote next to the script. Note that but setup registers them in GitButler's project list.
repro.sh (attached) builds throwaway repos with a local bare remote as target. The core of it:
# branch lower: provider.ts contains
# async #setHtml(view) {
but commit -b lower -m "lower: add provider"
# branch upper, stacked on lower: that line becomes
# private async setHtml(view) {
but branch new upper --above lower
but commit -b upper -m "upper: rename private"
# uncommitted: insert right after the renamed line
# if (!this.ready) { view.html = loading(); }
# (v2 also changes `const icon = "upload"`, a line only `lower` owns)
but absorb --dry-run
but amend -t lower
but absorb
but amend -t upper # controlUncommitted diff (v1):
@@ -7,0 +8,3 @@ export class Provider {
+ if (!this.ready) {
+ view.html = loading();
+ }git blame at the workspace commit: line 7 (private async setHtml(view) {) belongs to upper; line 4 belongs to lower.
A 3-way merge of the change onto lower's version of the file conflicts on upper's line:
<<<<<<< lower
async #setHtml(view) {
||||||| workspace
private async setHtml(view) {
=======
private async setHtml(view) {
if (!this.ready) {
view.html = loading();
}
>>>>>>> worktreeExpected behavior (Optional)
- The dependency calculation should lock the hunk to
upper, the newest commit whose changes overlap or border it.absorbwould then targetupper. but amend -t lower's error should nameupper, once per change.- If
absorbcan't apply a change, it should exit non-zero with a reason. It shouldn't rewrite commits whose trees don't change, or record a successful operation. - Hunks that apply cleanly (v2's line 4) should either be absorbed, or the refusal should make clear they were held back only because they share a file with a conflicting hunk.
Relevant log output (Optional)
See output.txt in the attached zip.
Source: gitbutlerapp/gitbutler