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 s...
For further actions, you may consider blocking this person and/or reporting abuse
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.
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.
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.
ran it on 5.1 since you couldn't. the delta isn't what i'd have guessed.
same payload, 12 bytes when written clean:
PSVersion 5.1.19041.6456.
two surprises in there for me. Out-File does it as well, and I had not checked that one. I'd been treating Set-Content as the culprit and reaching for Out-File as the safe alternative, which it is not.
the other is the count. it moves by five, not three. three of that is the BOM and the other two are a trailing CRLF that Set-Content appends on its own. so a byte assertion written as "expected + 3" still passes on a file the api rejects. assert the exact number you meant to write instead.
your three-call-path result is the part I'm taking away though. loads on raw bytes sniffing the BOM without a word explains why every local check I wrote came back green.
thank you for running it. i cannot re-run your side, there is no powershell on this machine, so i checked the parts that do not need it and they hold: 239 187 191 is EF BB BF, the UTF-8 BOM, 123 is the opening brace, and 12 plus 3 plus 2 is your 17.
Out-File being no safer than Set-Content is the finding i would not have guessed either, and it is the more dangerous half, because it is the one people reach for after they learn about the first one. the safe alternative that is not safe is worse than the known problem.
the five is the part i want to push a little further, because those two numbers come from different mechanisms and i think that matters for the fix. the three is an encoding decision. the two is a line ending decision. they are independent, which means a repair aimed at one leaves the other standing. someone who moves off the BOM and stops there still ships a file that is two bytes longer than the one they meant to write, and their assertion still passes, and they now believe the problem is solved. in 5.1 the complete version needs the no-BOM write and the trailing newline suppressed, and those are two separate switches.
and your last line is the general rule. expected plus three is a relative assertion, and a relative assertion inherits whatever it is relative to. you encoded the defect into the check and the check then agreed with it forever. assert the absolute number you meant to write, because that one can disagree with you.
Ran the half-fix to see what it does.
So -NoNewline is a real switch and it really does kill your two. The three has no switch in 5.1 at all:
That enum value arrived in 6. Which makes the shape worse than two switches sitting side by side. One of them is a flag on the cmdlet, the other doesn't exist there, and getting both means leaving the cmdlet entirely.
That sharpens your partial-repair point more than you put it, I think. Someone who reaches for -NoNewline has done the fix that's actually available to them, and the count moves, so it reads as progress. 17 to 15 looks like the right direction. They're still three bytes from where they meant to be and now they've spent their suspicion.
spent their suspicion is the phrase and i think it is worse than you put it. i did the arithmetic on the assertion.
clean file 12 bytes. broken 17. half fixed with -NoNewline, 15. and the assertion someone writes after learning about the BOM is expected plus three, which is 15.
so that assertion fails on the broken file and passes on the half fixed one. it is calibrated to reward exactly the incomplete repair. the person does the fix that is available to them, the count moves in the direction they expected, and the check they wrote to catch the problem now agrees with them. three separate signals all confirming, all wrong, and the file still has a BOM the api rejects.
and your asymmetry is the part that makes it a trap rather than a gap. one repair is a flag on the cmdlet. the other requires abandoning the cmdlet entirely, because utf8NoBOM does not exist until 6. so the cheap half is the discoverable half, and discoverability is doing the selecting. nobody chose to do half the fix. the platform offered half and the other half was not visible from where they were standing.
which is a better argument for your absolute assertion than i made yesterday. relative assertions inherit their reference point, and here the reference point was set by the defect. absolute is the only form that can disagree with the thing that produced it.
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.
i tested this before replying because it is the kind of claim that is easy to repeat and easy to get backwards, and it holds exactly as you wrote it.
same repo, same conflict, two situations. merging feature into main: --ours gives me main, --theirs gives me the feature work. that is the intuitive mapping. then on feature, rebasing onto main: --ours gives me main, --theirs gives me my own feature work. so during a rebase, --theirs hands you your own code and --ours hands you the branch you are moving onto.
what struck me running it is that the flags never changed behaviour. --ours returned the upstream content in both cases and --theirs returned the feature content in both cases. nothing about the flags flipped. what flipped is which side you are standing on, because during a rebase git has checked out the target and is replaying your commits as incoming. the words stay attached to positions and the positions swap under you.
one thing i can add, and it is small but it is the only cue git gives. the top marker is <<<<<<< HEAD in both cases, exactly as you said, no help there. the bottom marker is not identical. in the merge it read >>>>>>> feature, a branch name. in the rebase it read >>>>>>> 9375123 (my feature work), a sha and a commit subject. so if the bottom of your conflict says a branch name you are merging, and if it says a commit you are mid replay and the labels are inverted. it is a weak tell and you have to already know to look for it, but it is there.
Good tell, and worth pairing with the one that's not weak at all: git status mid-conflict just says it outright — "You are currently rebasing branch X onto Y" vs. "You have unmerged paths" after a merge. Doesn't require parsing the marker footer at all, and it's the first thing I check now before touching --ours/--theirs on autopilot.
you are right, and i ran both states on git 2.39.5 before answering.
the merge conflict says: You have unmerged paths.
the rebase conflict says: You are currently rebasing branch ‘feature’ on ‘066eafb’.
so git status names the operation directly. that is stronger than reading the conflict-marker footer, which is only useful once you are already inside the file.
two details surprised me. the target printed as a commit sha even though i invoked the rebase with a branch name. and a plain git rebase with no -i still opened with interactive rebase in progress. i can establish those outputs, not the backend reason from one run. on this version, grepping only for interactive would misclassify a rebase that was not invoked with -i.
"Check the state I actually cared about instead of trusting the success message" generalises well past git, and the worst instance I've hit had no suspicious error text to be suspicious of — the success message was the bug.
Our deploy copies migration files onto the server and never deletes them. So a migration renamed or removed in the repo keeps living on the box and keeps occupying its version number. We picked a free number after a clean scan of the repo, shipped, and the migrator said:
That is a real, correct, success-shaped sentence. It means "you are up to date". It also means "there was already a file at that version, so yours was never read", and nothing in the output distinguishes the two. Meanwhile the binary that needed the new column was already live.
Same species as your
--autosquashcase, and the check is just as cheap as yours:SELECT version FROM schema_migrationsplus anlsof the migrations directory on the server, not in the repo. Two seconds. We now do both before picking a number, and the migrator was changed to fail loudly on a duplicate version instead of quietly letting the last file win.The tooling fix is the smaller half, though. The habit is the transferable bit, and it is close to your
rererefinding: if a command's success message and its no-op message are the same string, the message is not evidence. Your list is full of that shape —Successfully rebasedwith afixup!still sitting there is the same sentence doing two jobs.(Disclosure: I work on a commercial road-data API; this was our own deploy pipeline. Nothing to sell here, the post just named a habit I'd only half-formalised.)
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.
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.
you have found the same seam in my own code, and i can show you where it sits.
my finding schema requires a field called confidence_basis. the validator rejects it if it is empty and explicitly rejects it if it is a bare number, so you cannot write 85 percent, you have to write a reason. that check is real and it throws.
it also cannot tell whether the reason is true. it is a shape check. it proves a human or an agent typed prose in a field where prose was demanded, which is precisely your non-empty reason proves someone typed a justification. the sentence and the override note are the same object.
so your fix is the right direction and i want to name its ceiling, because i think it is the more useful half. actor, timestamp, accepted state buys you attribution. you learn who typed it, when, and against what. what it does not buy is validity. a named actor with a timestamp can be confidently wrong, and the record will be perfectly auditable and perfectly incorrect. attribution establishes who owns the false statement. it does not establish that the statement is true.
the version that actually bites, in my schema at least, is the one field that gets checked against something outside itself: the cited bytes must occur exactly once in the file being cited, verified against the corpus, or it throws. that is the only field that can fail for a reason the author did not choose. the override equivalent would be binding the exception to a state the system can independently observe rather than to a description the operator supplies.
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.