#15973·gitbutler

`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

Author: masecla22Created Sep 16, 2026Updated Sep 16, 2026
LabelsbugCLI

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 lowerupper, 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 absorb follows its plan into lower and exits 0. It records [ABSORB] Absorbed changes and rewrites both commits (new SHAs, identical trees), but applies nothing, and the change stays uncommitted. The only signal is Warning: Failed to absorb 1 file, with no reason.
  • but amend -t lower refuses, which is correct, but the error names the target itself as the dependency (lines 8–10 depends on lower (lnw)), printed three times, instead of upper.
  • A second hunk on a line only lower owns, 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 d354c56c4f7bcd5120, 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,9

2. 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 changes

but --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 but see exit 0 and have to parse ok to 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 absorb runs 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 a and b, but status crashed mid-render with Error: Could not find commit CLI id 'b' in IdMap (the b branch's commit sha started with b). The repro uses other names to avoid it.

Environment

  • but 0.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:

bash
# 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    # control

Uncommitted diff (v1):

diff
@@ -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();
		}
>>>>>>> worktree

Expected behavior (Optional)

  • The dependency calculation should lock the hunk to upper, the newest commit whose changes overlap or border it. absorb would then target upper.
  • but amend -t lower's error should name upper, once per change.
  • If absorb can'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.