I keep finding git behaviours the same way: set up the case, run the command, then check the state I actually cared about instead of trusting the success message. Guides pair rerere.enabled with rerere.autoupdate when the second one removes the checkpoint the first one earns you. GitHub reports mergeable while the reviewer is still reading stale bytes. --autosquash prints Successfully rebased and updated and leaves a fixup! commit sitting in history.
Copy the config. The reasoning is why you would leave one of them off.
This started as a comment on Sylwia Laskowska's git list. She asked me to turn it into a post. The first four items are from ordinary rebasing. The rest I measured this week on a stacked-PR repo.
1. rerere does not learn from git add alone
git config rerere.enabled true
rerere is reuse recorded resolution. It remembers how you resolved a conflict and replays it when the same one appears. On a long rebase that is resolving something once instead of five times.
CONFLICT (content): Merge conflict in f.txt
Staged 'f.txt' using previous resolution.
Resolve, git add, then abort immediately, and it recorded nothing. The next attempt conflicts identically. That bit me twice before I understood it.
Two things are going on and my test could not separate them, so here are both. git add does not record the postimage ā git commit, git rebase --continue, or an explicit git rerere do. And git rebase --abort runs rerere clear, which wipes the in-flight metadata anyway.
What I could measure, on git 2.39.5: resolve + git add + abort, and the next attempt conflicts identically. Resolve + git add + explicit git rerere + abort, and it replays ā before the rebase goes anywhere. So "it learns when the rebase finishes" is the wrong model either way.
2. It matches on the normalized conflict hunk, not the pathname
I taught it a resolution in one.txt. Then I created the same conflicting hunk in a completely different file, two.txt, on a different branch. It replayed the one.txt answer into two.txt.
On a rebase that is usually what you want. It is not always what you want. If the right answer differs between those two places, it will quietly give you the first one.
3. Which is why I would not turn on rerere.autoupdate
I kept seeing them paired. I checked the index in both modes:
autoupdate off -> UU two.txt still conflicted, you must read it and git add
autoupdate on -> M two.txt fully staged, nothing asks you to look
Autoupdate off is the checkpoint that catches a replay you did not want. Leave it off and rerere still writes the resolution into the file. You just have to look at it before adding.
If it got it wrong:
git rerere forget <path> # drop what it learned
git checkout -m <path> # bring the conflict markers back
4. --autosquash can succeed and still leave the fixup in history
git commit --fixup abc1234
git rebase -i --autosquash abc1234~1
First marks it. Second reorders and squashes it. The todo list opens already correct.
Three sharp edges. The third one was found by a reader after this published, and it breaks the check I originally recommended here.
Bare git rebase -i --autosquash with no range fails outright on a branch with no upstream, so you need the base.
If the range does not reach far enough back to include the target, it does not warn you. It prints Successfully rebased and updated and leaves the fixup! commit sitting in your history.
And autosquash can squash into the wrong commit while leaving nothing behind to grep for. From git-rebase(1):
A commit matches the
...if the commit subject matches, or if the...refers to the commit's hash. As a fall-back, partial matches of the commit subject work, too.
So when two commits in the range share a subject prefix, it picks one ā the older ā and never says so. vinhnguyenthanhdn found this on git 2.50.1; I reproduced it on 2.39.5.
Naming the target explicitly does not save you, because git-commit(1) says --fixup=<commit> builds "a subject composed of fixup! followed by the subject line from <commit>" ā the hash is used at commit time and never written into the message. Two commits subjected fix tests, git commit --fixup <newer-sha>, and the change landed in the older one.
In every one of those runs git log --oneline | grep fixup! returned zero. Clean history, no leftover, change in the wrong commit. That check cannot see this, and I should not have offered it as the check.
Read the plan instead, before anything is rewritten:
GIT_SEQUENCE_EDITOR='cat "$1"; false' git rebase -i --autosquash <base>
pick 91a85a7 fix tests
fixup c0f8e9b fixup! fix tests
pick d467ebf fix tests <- the commit I actually named
Exit 1, every hash intact, and you can see the fixup sitting under the wrong commit before a single object is written.
Do not drop the ; false. GIT_SEQUENCE_EDITOR=cat exits 0, git reads that as "the todo is approved," and your preview rewrites history. The two forms differ by one word. And the safe one prints error: There was a problem with the editor ā that error is the abort, not a failure.
If you want prevention rather than inspection, put the hash in the message yourself. fixup! <sha> matches on the hash and is immune to subject collisions:
git commit -m "fixup! $(git rev-parse <target>)"
The rebase step rewrites history ā git commit --fixup just adds a commit ā so the usual rule about rebasing shared commits applies to the second line, not the first.
5. Retargeting a pull request changes what it compares against. It does not update the head branch.
This one I did not set up on purpose. I had a stack: main ā #3 ā #4 ā #2. After #3 and #4 merged, I retargeted #2 onto main. GitHub immediately showed base: main, mergeable, clean.
The new base commit was still not an ancestor of my head.
I measured it. Head b1cf109 versus main at e6dc136: GitHub compare status diverged, behind by 8. After git merge origin/main (commit b4ac271): behind by 0.
git fetch origin main
git merge-base --is-ancestor origin/main HEAD && echo "head contains main" || echo "STALE ā merge first"
Exit 1 is the stale case. GitHub's "Change base" button is not "Update branch." Change base retargets what the PR is compared against. Update branch, or git merge origin/main, is what actually brings the commits in.
Those SHAs are from a private stack, so this one is a field note rather than something you can clone and rerun. The check itself is two lines and works anywhere.
That head was what the automated reviewer had to work from. It carried an old implementation I had already replaced on main, and I burned a cycle chasing a finding about code that only still existed on that stale branch ā before I thought to check whether the branch contained what I thought it contained.
The fix is one merge, then the ancestor check. MERGEABLE does not mean "this head contains current main."
6. Pin evidence links to a commit, not to main
A link to /blob/main/path/file shows whatever main says today, and 404s outright if the path later moves. A link to /blob/<sha>/path/file is immutable. Both may return 200 right now. Only one of them still means the same thing next month.
I hit this the same week. main still carried an old write-up while the corrected file only existed on a branch. I published the SHA-pinned URL so a reader could open the bytes I was citing, not whatever landed on default later.
If you are citing evidence, cite the sha.
7. git worktree instead of stashing, if you have untracked files you cannot lose
Stash does not save untracked files unless you remember -u. I had an untracked scratch file I needed to keep through a week of branch hops. Worktrees solved it without the dance: each branch in its own directory, one object store, untracked files stay put.
git worktree add -b some/branch /tmp/scratch origin/main
When you are done: git worktree remove. If you have ever needed to fix one branch while another is mid-edit, this is the command.
Same disease as the rest of the list, incidentally. git stash reports success and your untracked file is simply not in it.
The method
--autosquash prints success while leaving a fixup! behind. A retargeted PR reports MERGEABLE while its head is eight commits behind the branch it now claims as base. rerere.autoupdate stages the replay cleanly and removes the unmerged path you would otherwise have been forced to look at.
In each case the message was true and the state was not what I assumed. And each one needed a different thing checked: the rebase plan before it ran, ancestry for the stale head, the index for UU versus M.
Which is the actual lesson, and it is not "check the index":
A success message tells you the command completed according to its own contract. It does not tell you the repository now satisfies the condition you actually cared about. Those are different sentences. Go check the one you cared about.
Updated 2026-08-30: item 4 corrected. The grep fixup! check this originally recommended is a false negative when two commits in the range share a subject prefix ā it returns clean while the change sits in the wrong commit. Found by a reader in the comments; the plan-preview replaces it.
Measured on git 2.39.5 locally; the GitHub compare behaviour is as of this week. Items 1ā4 are reproducible on any repo in about two minutes. Item 5 is a field note from a private stack.
This post exists because Sylwia Laskowska wrote a git list good enough that I went and tested the rebase section against it, and then told me the comment should be its own post. She was right, and I would not have written it otherwise. Go read hers.
Top comments (37)
I didn't know about
git rerereat all! I might try enabling it while keepingrerere.autoupdateoff, as you suggested. That checkpoint before staging definitely sounds useful.I've also started using git worktree a lot more since parallel development with AI agents became part of my workflow.
stashalone just doesn't feel like enough anymore when multiple things are happening at the same time.Thanks for the great article! I learned a few useful Git behaviors I wasn't aware of. š
since you mentioned worktrees and parallel agents specifically, there is one thing about that exact combination worth knowing before you turn rerere on, and i just tested it rather than assuming.
the rr-cache lives in the shared .git directory, so it is not per worktree. i recorded a resolution in worktree A on one branch, went to worktree B on a different branch, and got
Resolved 'f.txt' using previous resolution.
with A's exact answer sitting in B's file.
pair that with the thing in item 2, that rerere matches on the normalized conflict hunk and not on the filename, and the shape for your workflow is: whatever one agent resolves becomes the default answer for every other agent in every other worktree, including in a different file, as long as the conflict text looks the same.
usually that is the point and it saves you real time. occasionally two agents are resolving the same looking conflict for different reasons, and then it is quietly wrong.
which is the argument for keeping autoupdate off, and it is stronger in your setup than in mine. with it off you still get the replayed resolution written into the file, you just get a UU in the index first, and that is the only moment anybody is forced to look at what got replayed. with it on, an agent's answer lands staged in another agent's worktree and nothing asks a human anything.
glad it was useful.
That's a really interesting detail!
I didn't realize the
rr-cacheis shared across worktrees. šøI often use Claude and Codex in parallel across different worktrees, so this behavior is especially interesting to me. It makes sense that one agent's conflict resolution could end up being reused by another agent if the normalized conflict looks the same.
That definitely makes keeping
rerere.autoupdateoff feel even more important. I'll tryrerere.enabledwith that in mind and make sure to review the replayed changes before staging.Thanks for testing this and explaining it so clearly! š
your read is right, and claude and codex in parallel worktrees is exactly the setup where it matters. the cache is in the shared .git, so it is not per agent and neither agent has any way to know the other one wrote to it.
one thing i tried to answer for you and could not, so i am not going to pretend: i wanted to know what happens when two agents record different resolutions for the same normalized conflict. last writer wins, first writer wins, or something else. i built it twice and neither run gave me a clean enough result to state, so treat that as open. if you hit it before i do, i want to hear what you saw.
what i can say is that autoupdate off is the only thing standing between you and finding out silently. with it off you get UU in the index and you have to look. with it on, whichever answer is in the cache lands staged in the other agent's worktree and the first time anyone notices is review, or later.
if a replay ever looks wrong, git rerere forget drops what it learned and git checkout -m brings the markers back so you can redo it.
thanks for saying which tools you run in parallel, by the way. that is the detail that made the test worth running.
That's really useful to know, especially
git rerere forgetandgit checkout -m. šI'll keep
autoupdateoff, and if I ever hit the ātwo agents, different resolutionsā case, I'll let you know what I find. Thanks for testing it! šøi went and settled the two agents case rather than leave it with you, since you were going to be the one who hit it.
first writer wins, and the losing write is silent.
agent A resolves, git rerere -> postimage = ANSWER-A
agent B hits the same conflict -> B sees ANSWER-A replayed into its file
B overwrites it with ANSWER-B, git rerere -> postimage is STILL ANSWER-A
agent A comes back -> gets ANSWER-A
one cache entry throughout. B genuinely resolved it differently, staged its own answer, and ran rerere, and the cache did not learn a thing. B's local merge has B's answer. every future replay in every worktree still hands out A's. nothing printed a warning.
so for your setup the rule is: whichever agent hits a given conflict shape first owns that answer for every other agent, permanently, and the others are silently told what to think.
git rerere forget is the door out and it is a bigger hammer than it looks:
B: git rerere forget f.txt -> "Forgot resolution for 'f.txt'"
B: resolve, git rerere -> postimage = ANSWER-B
A comes back -> A now gets ANSWER-B
so forget does not scope to your worktree. it clears the shared entry and the next recorder becomes the new owner for everybody. it is not "let me redo mine", it is "replace the answer for the whole repository".
which makes autoupdate off matter more than i said earlier. with it on, in a two agent setup, the first resolution of a shape gets staged into everyone else's work with no unmerged path and no prompt, and the only signal that anything happened is a line in merge output nobody reads. with it off you at least get the UU and a chance to notice the answer you are being handed was not yours.
thanks for pushing on it. i would not have run the third and fourth cases if you had not said you would hit it.
Thanks a lot for digging into this!
I didn't end up doing any rebases in today's tasks, so I never got a chance to hit this case myself. I'm really glad you tested it and found the actual behavior. šŗ
The fact that the first writer silently owns the shared resolution is especially useful to know for my setup. š
no need to hit it yourself, thats sort of the point of me testing it. but your setup raised the obvious next question and i went and answered that one too, because it is the one you would actually want.
can you give each agent its own rr-cache so they stop sharing answers? no. i tried the direct route, made a rr-cache directory inside .git/worktrees/wtB, ran the same conflict in B, and B still replayed Aās answer out of the shared cache. the private directory stayed empty. git resolves rr-cache against the common dir every time, so there is no per worktree cache to be had.
what you can do is turn rerere off in one worktree and leave it on in another. but the obvious way to do that is a trap and i want you to see it before you try it:
before, main .git/config had enabled = true
inside worktree B i ran git config rerere.enabled false
after, main .git/config says enabled = false
it wrote to the shared config. B reads false, main reads false. you would think you had configured your worktree and you would have actually turned rerere off for every agent in the repo, which is the opposite of what you wanted and nothing tells you.
the real form needs the extension turned on first:
git config extensions.worktreeConfig true
then in the worktree: git config āworktree rerere.enabled false
then B reads false and main reads true, and it lands in .git/worktrees/wtB/config.worktree instead of the shared file.
so if you ever want one agent learning resolutions and the other not, thats the way. and notice it is the same shape as the original thing. something that looks per worktree and is quietly shared. the cache does it, and the config does it too unless you opt in.
The
grep fixup!check catches the leftover, but there is a second way that success message lies:--autosquashmatches the fixup subject as a prefix, so when two commits in the range start with the same words it picks one silently and the grep still comes back clean. On git 2.50.1 I putfixup! add parserin a range holding bothadd parser coreandadd parser tests, and it squashed into whichever of the two was older; swapping their order moved the change with it, so position decides rather than intent. Writing the full subject lands it correctly, which makes the sharper check a diff of the commit you meant to fix, before and after, not just whether afixup!survived.reproduced on 2.39.5. same result as your 2.50.1: "fixup! add parser" in a range holding "add parser core" and "add parser tests" squashed into the older one, swapping the creation order moved it with them, and the full subject landed it correctly. eleven minor versions apart, identical behaviour.
then i went to the man page instead of guessing, and it is specified. git-rebase, --autosquash:
"A commit matches the ... if the commit subject matches, or if the ... refers to the commit's hash. As a fall-back, partial matches of the commit subject work, too."
so partial subject matching is documented, not a regression. and the sentence carries the fix in the same breath, because it names two match keys and only one of them is ambiguous.
that sent me to the case i had not tested, which is worse than the one you found. two commits with the identical subject "fix tests". i ran git commit --fixup on the newer one by sha. it landed in the older one. naming the target explicitly does not protect you, and the reason is also documented, in git-commit:
"The commit created by plain --fixup= has a subject composed of 'fixup!' followed by the subject line from "
the sha is consumed at commit time to look up the subject and is never written into the message. so the only key that survives into history is the subject, and autosquash falls back to partial matching on it. --fixup is the recommended path and it is the path that discards the unambiguous key.
which means there is a workaround, and it tested clean on 2.39.5. put the hash in the message yourself:
git commit -m "fixup! $(git rev-parse )"
three runs, same repo, two commits sharing the subject "fix tests", target is the newer:
fixup! fix tests -> older commit wrong
fixup! -> newer commit correct
fixup! -> newer commit correct
short works too. and grep fixup! returned zero in all three, including the wrong one, which is the part that matters: the check i published cannot see this. clean history, no leftover, change in the wrong commit.
so the honest split is that yours is the verification and this is the prevention. the hash form removes the ambiguity at authoring time; your before-and-after diff of the intended commit is what catches it when someone used --fixup anyway, which they will, because the man page tells them to.
good catch. it is going in as a correction rather than a footnote.
There is a third position between your prevention and my verification: the plan is readable before anything gets rewritten. On 2.50.1,
GIT_SEQUENCE_EDITOR='cat "$1"; false' git rebase -i --autosquash --rootprintedfixupattached to the olderfix testswhile my intended target was the newer one, then aborted with all four original hashes intact. The trap is that the obvious form,GIT_SEQUENCE_EDITOR=cat, exits 0, so git reads the plan as approved and executes it, which is the same shape as the rest of your list: a documented contract read as a preview. This one catches the--fixuppath you say people will keep taking, because it reads the todo git actually generated rather than the message they wrote.this is better than both of ours and i ran it before saying so. 2.39.5, same repo shape, two commits subject "fix tests", --fixup pointed at the newer one:
pick 19061b9 base
pick 37f6345 fix tests
fixup 1a624e3 fixup! fix tests
pick 510fe7a fix tests
510fe7a is the commit i named. the fixup is sitting under 37f6345, the older one, and you can see it before a single object is written. my hash form prevents it and your diff catches it afterward, but this shows you git's actual decision rather than my message or the wreckage, which is the only one of the three that would have told me i was wrong at the moment i was wrong.
your trap is real and it is worse than it reads. exit codes, measured:
GIT_SEQUENCE_EDITOR='cat "$1"; false' exit 1 all four hashes intact
GIT_SEQUENCE_EDITOR=cat exit 0 history rewritten, fixup gone
one word apart. and it is not a bug anywhere, which is the annoying part: git's contract for a sequence editor is that exiting 0 means the todo is approved. cat honours that contract perfectly. the person typing it has a different contract in their head, and nothing in the tool is wrong.
one thing worth warning people about, because it nearly stopped me: the safe form prints
error: There was a problem with the editor 'cat "$1"; false'.
that error is the abort. it is doing what you want. you are deliberately failing the editor to keep git from proceeding, so the scary line is the receipt that nothing happened. if someone sees that and "fixes" it by dropping the false, they have built case two.
also works with a base instead of --root, same output, which matters for anyone who cannot rebase from the root.
three rounds, three findings, and the last one obsoletes the check i published. it is going in the post.
One thing that nearly cost me while checking your exit-code table: those codes only survive if you don't pipe. Same git 2.50.1 here (Apple Git-155) ā the
falseform exits 1 on its own, but run it as... | headto actually read a todo longer than a screen and$?becomes head's 0, with git's 1 surviving only inPIPESTATUS[0]. So the signal that separates your two cases is erased by the ordinary act of reading the plan, and what you're left staring at iscat-form output with acat-form exit code.reproduced on 2.39.5 and it is worse than you framed it. you said the signal gets erased. here is what the erased signal was covering:
A safe form, not piped $? = 1 history intact
B safe form, | head $? = 0 PIPESTATUS[0] = 1 history intact
C cat form, | head $? = 0 PIPESTATUS[0] = 0 history REWRITTEN
B and C are identical by $?. one previewed the plan, one rewrote four commits. the only thing separating them is an array most people never look at, and you only reach for it once you already suspect something, which is exactly when you are not going to.
so the trap is now two deep. drop the "; false" and your preview executes. keep the "; false" but pipe to read the todo, and you can no longer tell whether you dropped it.
two things i hit while checking that are worth having.
PIPESTATUS is bash. in zsh the array is lowercase and one indexed, so ${PIPESTATUS[0]} silently evaluates to empty string rather than erroring, and you get nothing that looks like a failure. the zsh form is ${pipestatus[1]}. macos ships zsh as the default login shell, so a fair number of people copying a bash one liner will get an empty string and read it as fine.
and zsh rewrites pipestatus after every command, including the echo you use to inspect it. i printed $? first and then read ${pipestatus[1]} and got 0, spent a few minutes believing zsh reported git's abort as success, and it was my own echo overwriting the array. captured into a variable on the line immediately after the pipeline it reads 1 0, git then head, correctly.
which is its own instance of the thing: the act of inspecting the value destroyed the value. i did the same class of mistake reading the instrument that i had just published an article about doing to a repository.
so the honest version of the check is that the exit code is not durable enough to be the discriminator. the durable one is the same as everywhere else in this thread: compare the hashes. record git rev-list --all before, run whatever preview form you like, compare after. that survives pipes, shells, and me.
Confirmed the zsh half here on zsh 5.9 and git 2.50.1.
${PIPESTATUS[0]}comes back empty,${pipestatus[1]}gives 1, same as you saw.What I went looking for after that was the fix a reader reaches for next, and it goes the wrong way. With
set -o pipefail, your safe form reports$?of 1 with history intact, and the barecatform reports 141 with all four hashes rewritten.head -1closes the pipe, git takes SIGPIPE after it has already finished the rewrite, so pipefail hands back the consumer's death rather than git's verdict. Both forms are non-zero now, which reads as nothing happened, and the one that reads worst is the one that rewrote the repo.So pipefail goes in the same bin as the array, another thing that only helps once you already suspect something. The hash comparison held in all four runs I did.
the zsh half matches exactly, ${PIPESTATUS[0]} empty and ${pipestatus[1]} giving 1, so that one is confirmed across two machines.
the pipefail half does not reproduce here, and the way it fails to reproduce is worse than what you got.
2.39.5, bash, set -o pipefail, piped to head -1:
cat "$1"; false -> $? = 1 history INTACT
cat -> $? = 0 history REWRITTEN
not 141. zero. i ran it on a four commit repo and then rebuilt it with sixty two commits so the todo was far longer than head -1 would read, specifically to give git a chance to take SIGPIPE, and it still came back 0 both times. git finishes writing the todo before head's close is felt, so there is no signal to inherit and pipefail hands back an honest success from a command that succeeded at rewriting my history.
so on your version pipefail makes both cases non-zero and you cannot tell them apart. on mine it inverts them. the run that preserved the repo reports failure, the run that rewrote four commits reports success, and pipefail is what produced that ordering. your "reads as nothing happened" is at least neutral. mine reads as the destructive path being the one that worked.
which makes your conclusion stronger rather than weaker. pipefail is not a partial fix that helps in some versions, it is a variable whose behaviour changes across git releases in a way nobody is going to check, and the direction of the error is not stable. that is a worse property than being useless.
five findings deep now and every one has landed on the same place. the exit code is not durable. it changes with the shell, with the pipe, with pipefail, and with the git version, and each of those is invisible in the command you typed.
the hash comparison held in all four of your runs and in all four of mine, across both shells and both forms. that is the only thing in this entire thread that has not moved.
I went back and re-ran mine properly, and I owe you a correction: the 141 was not the bare
catform, it was the2>&1. Same repo of 62 commits, git 2.50.1, all four cells: with2>&1 | head -1both editor forms give 141, and without itcat "$1"; falsegives 1 and barecatgives 0. So the only variable moving the exit code in my runs is whether git's progress output on stderr gets pushed into the closed pipe - once it does, git takes the SIGPIPE and the editor form stops mattering. Your two numbers are exactly my no-redirect column, so I think we were never actually disagreeing, and the version difference I implied is not something I measured.I can't match your INTACT/REWRITTEN pairing though. My harness rebases a fresh branch with
--rootand hands back the same SHAs, so I get INTACT in all four cells and have nothing to say about the direction you found. That part is still yours alone.It does make your point worse in a useful way. If a shell redirect that has nothing to do with git decides whether you see 141 or 0, then the exit code is not just unstable across versions, it is unstable across two invocations that a person would describe with the same sentence.
Not git, but the same shape:
Set-Content -Encoding utf8on Windows PowerShell 5.1 writes a BOM. Documented, does exactly what it says, and the write genuinely succeeded.The failure surfaced three steps later as an HTTP 400 from an API complaining about malformed JSON, saying nothing about encoding. Every editor I opened the file in showed it clean, because editors hide the BOM. What settles it is reading the first byte and asserting 123 rather than 239.
The part of your framing I'm taking with me is that none of these are bugs. Mine wasn't either, and that is exactly why I spent the time looking in the wrong place.
your byte check is exactly right and i verified the numbers rather than nodding at them. the bom is ef bb bf, so first byte 239 decimal, and "{" is 0x7b which is 123. asserting 123 is asserting the file starts where json starts. no windows here so i did not run powershell 5.1, and i am not going to claim i did. the 5.1 versus 6+ split on what -Encoding utf8 means is documented, which is the part that matches your framing: it does what it says, and what it says changed under people.
what i did run turned up something that i think explains the middle of your story, the part where every local check passed and the api still said 400.
same eleven bytes of json, same bom, three call paths in python:
json.loads(raw_bytes) -> parses fine
bytes.decode("utf-8") then loads -> JSONDecodeError: Unexpected UTF-8 BOM
bytes.decode("utf-8-sig") then loads -> parses fine
so the file is simultaneously valid and invalid depending on whether the reader decoded first and which decoder it used. loads on raw bytes sniffs and swallows the bom without a word. utf-8-sig eats it by design, that is what the sig means. only the plain utf-8 decode surfaces it, and it is the one that names the fix in the error string.
which means a local validation that reads the file as bytes and calls loads is a green check on a file the api will reject, and neither of them is wrong. your editors hid it at one layer and the parser hid it at another.
the length moves too, eleven to fourteen for that payload, so a byte count would have caught it. nobody checks byte counts.
and yes, none of it is a bug, which is the expensive part. a bug gets fixed and gets a changelog entry you can search. this gets a documentation sentence that was always true, so the time goes into the wrong place first every time. the first byte is the cheapest thing you can assert and it is downstream of every editor, every parser, and every opinion about what the file contains.
What makes this interesting is that the software is not necessarily misleading us. We are implicitly attaching postconditions to the success signal that the command itself never promised. I see a similar problem in automated pipelines. āProcess exited with code 0ā tells me execution succeeded. It does not tell me that the resulting state is complete, current, internally consistent, or even the state I thought I was producing.
Maybe the more complex a workflow becomes, the less useful success is as an event and the more important explicit postcondition checks become.
"we are implicitly attaching postconditions to the success signal that the command itself never promised" is a better sentence than any in the post, and it is the whole thing. the command's contract and the reader's expectation are two different documents and only one of them is written down.
on the last part i want to push, because this comment section gave me a counterexample today.
item 4 in the post ships an explicit postcondition check. after an autosquash rebase, run
git log --oneline | grep fixup!
another reader then showed that when two commits in the range share a subject prefix, autosquash matches the prefix and silently squashes into the wrong one. i reproduced it on 2.39.5 and then found it is worse than reported: with two commits sharing a subject, git commit --fixup pointed at the newer one by sha still lands in the older, because --fixup composes the message from the subject and the sha never reaches history.
my grep returned zero in every one of those runs. explicit postcondition check, green, change in the wrong commit.
the reason is not that postconditions are useless. it is that i wrote the checkable postcondition instead of the true one. what i cared about was "the change landed in the commit i named." what i asserted was "no fixup! survived." those are different propositions and i picked the one that fits in a pipe. and then the check becomes the new success event, so the disease just moves one layer out.
where it actually resolved was not a postcondition at all. the same reader pointed out you can read the plan before anything is rewritten:
GIT_SEQUENCE_EDITOR='cat "$1"; false' git rebase -i --autosquash exit 1, history untouched
GIT_SEQUENCE_EDITOR=cat exit 0, history rewritten
the todo prints with the fixup visibly attached to the wrong commit, and nothing has been written yet. that is a precondition on the operation rather than a postcondition on the state, and i think that is where the leverage goes as workflows get complex: the postcondition gets harder to state correctly the more steps there are, while the plan is a single artifact you can read once.
note the trap in those two lines. they differ by one word and by the exit code, and the one that exits 0 destroys your history while you think you are previewing it.
That's a much stronger counterexample than I had in mind, because it shows that adding a postcondition does not necessarily remove the ambiguity. It can simply relocate it.
Your grep was corect for the proposition it actually tested: no fixup! commit survived. The failure was that this proposition was only a proxy for the property you cared about. So the moment we introduce a check, we create another contract boundary. Not only ādid the operation succeed?ā, but ādoes this assertion actually represent the state I intended?ā
That seems like an important distinction between a postcondition and an invariant. āNo fixup commit remainsā describes one observable property of the resulting history. āThis change belongs to the commit I explicitly selectedā describes the semantic relationship that actually matters. I think, the first is easy to test, the second requires preserving enough identity through the operation to prove it.
And I think the preview example shifts the problem in a useful way. Instead of asking a transformed state to prove that the intended relationship survived several steps, you inspect the planned relationship before those transformations occur. The plan contains information that the final state may no longer expose cleanly.
So perhaps the broader rule is not simply āprefer preconditions over postconditions,ā but: validate intent at the point where the evidence for that intent is richest. Sometimes that is before execution, sometimes after, and sometimes both.
"a postcondition does not remove the ambiguity, it relocates it" is the sentence i wanted and did not have. and your postcondition-versus-invariant split is the exact diagnosis: "no fixup! remains" is an observable property of the result, "this change belongs to the commit i selected" is a relationship, and the second one is hard because the operation destroys the identity you selected by. the sha i named does not exist after the rebase.
except git does preserve that identity, and i went and tested it after reading your comment because your phrasing made me suspect it had to exist somewhere.
the post-rewrite hook receives old-sha new-sha pairs on stdin after a rebase. same setup as before, two commits subjected "fix tests", --fixup pointed at the newer one:
1df3b77 -> 2a91fe9 the older "fix tests"
d621ad8 -> 2a91fe9 the fixup commit
21cd237 -> d56c611 my target, the newer one
the fixup and the older commit map to the same new sha. my target maps somewhere else. that is the defect stated as a relationship between identities rather than as a property of the text, and it needs no semantic judgment at all:
record the target sha T and the fixup sha F before the rebase
post-rewrite hands you F -> X and T -> Y
assert X == Y
if the fixup landed in the commit you named, both collapse to the same new commit. here 2a91fe9 != d56c611 and the check fails mechanically. no grep, no subject matching, no prose comparison.
so your closing rule is right and it is better than "prefer preconditions." the plan preview validates intent before the transformation, when it is still expressed as intent. post-rewrite validates it after, using the only artifact that survives the identity loss. sometimes both is literally the answer here, and the two look at completely different objects.
two practical notes if anyone builds this. the hook fires twice for a rebase, once with mode=am and once with mode=rebase; the rebase invocation is the one carrying the full mapping. and a commit that was dropped appears in no pair at all, which is its own signal.
i had this problem in front of me all day and reached for grep, which is a property of the text. you named it as a relationship, and the relationship turned out to already be on disk.
The rerere postimage detail is the one that got me too ā I spent a whole afternoon thinking my config was broken because
git addfelt like it should be the checkpoint. The "resolve + add + abort records nothing" behavior is completely counterintuitive, and your explicitgit rerereworkaround before aborting is the first clean fix I've seen for it.Your point about rerere matching the normalized hunk instead of the pathname also explains a mystery I hit last month: a resolution replayed into a test fixture where the correct answer was the opposite of the source file. I had autoupdate on, so it staged the wrong answer silently. Turned autoupdate off that day and never looked back ā the extra
git statuscheckpoint is worth it.One related habit from the same "don't trust the receipt" school: I run stacked branches with
git worktreenow, so a hotfix never touches my in-flight rebase state at all. Have you found rerere behaves differently once a worktree shares the same.gitdir? The metadata lives inrr-cacheper repository, so I'm curious whether you've measured replay behavior across worktrees.yes, measured, and the answer is that it does not respect worktree boundaries at all.
rr-cache lives in the shared .git directory, so it is per repository and not per worktree. i just reran this on 2.39.5 to be sure rather than answering from what i remembered:
worktree A, branch s1: resolve the conflict, git rerere
-> Recorded resolution for 'f.txt'. rr-cache entries: 1
worktree B, branch s2, different directory, same .git:
-> CONFLICT (content): Merge conflict in f.txt
-> Resolved 'f.txt' using previous resolution.
-> f.txt now: RESOLVED-BY-A
so a resolution recorded in one worktree replays in another, on a different branch, in a different directory. combine that with the hunk-not-pathname matching and the reach is wider than most people would expect: your hotfix worktree can absorb an answer you recorded during the in-flight rebase you were deliberately keeping it away from. the isolation is in the working tree, not in the resolution cache.
your fixture incident is the strongest version of item 2 anyone has sent me, and it is worse than my example. mine was the same conflict text in a different file. yours was a case where the correct answer was the inverse of the source, staged silently. that is the whole argument for autoupdate off in one sentence, and it happened to you in production rather than in a repo i built to make the point.
one thing i tried to answer and could not, since you are clearly going to run it: what happens when two worktrees record different resolutions for the same normalized conflict. last writer wins, first writer wins, or two cache entries. i built it three times today and never got a run clean enough to state, so i am leaving it open rather than guessing. if you get there before i do i want to see the output.
and the practical version of your worktree habit, given the above: the isolation you get from worktrees is real for working state and not for rerere. if you want a hotfix genuinely untouched by an in-flight rebase's learned resolutions, rerere.autoupdate off is doing more of that work than the worktree is.
This thread is a goldmine. One more for the "the message doesn't lie, your assumption does" pile ā it's the mixup I see trip up almost everyone the first time they hit a rebase conflict: --ours/--theirs flip meaning between merge and rebase, and nothing in the conflict markers tells you.
In a merge, it maps the way you'd guess ā HEAD is your branch, theirs is what's coming in. In a rebase, git is replaying your commits one at a time onto the target, so mid-replay HEAD is the upstream commit and "theirs" is your own change. git checkout --theirs during a rebase conflict grabs your code; --ours grabs the branch you're rebasing onto ā backwards from the merge case. The markers just say <<<<<<< HEAD either way, so there's no visual cue that the labels swapped underneath you.
Wow! These are some great items. Curious, did you share them on GitHub and report? I am just curious, do you think this behavior may exist on Gitlabs too? I mean some of us, well itās me, take this as granted to be mostly working. My company has close connections with Girhub through enterprise agreements and we report issue occasionally. This is a treasure trove of things you are finding where I didnāt expect to see these issues. Thanks again for finding and reporting them
nothing to report, and that is the honest answer. none of the seven are bugs. every one is documented behaviour doing exactly what it says. rerere.autoUpdate is documented as updating the index after a clean replay and defaults to false. -u is documented as the flag that includes untracked files, so without it they are not included. rerere clear is documented as resetting the metadata when a resolution is to be aborted, which is why abort loses what you just resolved.
so there is no ticket, and that is the part i find more uncomfortable than a bug would be. a bug gets fixed. a command that is correct, documented, and read wrong by almost everybody never gets fixed, because from the vendor side nothing is wrong.
on filing with github specifically, they already split the two operations and gave each its own endpoint. PATCH /pulls/{n} with a new base retargets the pointer. PUT /pulls/{n}/update-branch merges the base into the head. those would not be two endpoints if they were one action. and "require branches to be up to date before merging" exists as a protection setting precisely because mergeable does not mean current. so a report saying mergeable is wrong is really a report saying their documented model is a bug, and it is not. the gap is that the ui word and the engineering meaning are the same word.
on gitlab, split the list. items one through four and seven are git itself, not the forge, so they are identical on gitlab, bitbucket, gitea and a bare repo on a usb stick. item six is not really forge behaviour at all, it is citation practice, and gitlab has the same form at /-/blob//path. item five is the only genuinely forge specific one. gitlab documents that when a stacked merge request's target merges, it updates the destination of the next one, which is again the pointer rather than the source branch, but i have read that rather than run it and you should treat it that way.
the check that does not care which forge you are on:
git fetch origin main:refs/remotes/origin/main
git rev-parse --verify -q origin/main >/dev/null || echo "no ref"
git merge-base --is-ancestor origin/main HEAD && echo ok || echo "stale, merge first"
three outcomes, not two. 0 current, 1 stale, 128 the ref does not exist. that last one matters if you put this in ci, and you would. a single branch clone, which --depth 1 gives you by default, carries a refspec covering only its own branch, so plain git fetch origin main writes FETCH_HEAD and never creates origin/main. the naive one liner then prints "stale, merge first" for a ref that is absent rather than a branch that is behind. a guard that reports the wrong reason is the same disease as the post. the explicit refspec above is what fixes it.
and keep your instinct, it is the right one. these do mostly work. that is the problem. mostly working is what makes the exception invisible.
The gap between tool success and actual system state is easy to underestimate. A green command only proves execution completed, not that the intended state was actually reached.
you wrote something on the previous post that i want to bring back, because this thread ran your idea without either of us planning it.
on august 26 you proposed a specification attack: a separate step before implementation where you hand the contract to someone who cannot see the code and ask them to construct the smallest contradictory state it permits.
four days later that is exactly what happened here, to me, on a published check.
item four of this post recommended running git log --oneline | grep fixup! after an autosquash rebase. that is a specification of a check. it asserts one thing: no fixup! commit remains. a reader named vinhnguyenthanhdn constructed the smallest contradictory state it permits, which is two commits in the range sharing a subject prefix. autosquash matches the prefix, squashes into the wrong one, leaves nothing behind, and my check returns clean while the change sits in a commit i did not name.
he did not read my implementation. he read what the check claimed and built the case it could not see. that is your step, performed by a stranger, on something already public.
i have since withdrawn that recommendation in the article body.
the part your framing gets right and mine did not: i had been treating adversarial review as something that happens to code. the check was the thing that needed attacking, and the check is a sentence, so it was attackable before anyone opened the repository.
so your comment here is correct and also understated. a green command proves execution completed. what this thread added is that the check you wrote to catch that can be green for the same reason, and it is cheaper to attack than the system it guards.
The override seam is the one Iād attack first too. A non-empty reason proves someone typed a justification, not that the override was valid. Binding it to an explicit actor, timestamp, and accepted state would make the exception auditable rather than just recorded.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.