Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
The grep fixup! check catches the leftover, but there is a second way that success message lies: --autosquash matches 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 put fixup! add parser in a range holding both add parser core and add 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 a fixup! survived.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
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:
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.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
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 --root printed fixup attached to the older fix tests while 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 --fixup path you say people will keep taking, because it reads the todo git actually generated rather than the message they wrote.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
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:
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.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
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 false form exits 1 on its own, but run it as ... | head to actually read a todo longer than a screen and $? becomes head's 0, with git's 1 surviving only in PIPESTATUS[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 is cat-form output with a cat-form exit code.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
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.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
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 bare cat form reports 141 with all four hashes rewritten. head -1 closes 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.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
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.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
I went back and re-ran mine properly, and I owe you a correction: the 141 was not the bare cat form, it was the 2>&1. Same repo of 62 commits, git 2.50.1, all four cells: with 2>&1 | head -1 both editor forms give 141, and without it cat "$1"; false gives 1 and bare cat gives 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 --root and 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.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
i reproduced your four cells before replying and they came out identical on my end. git 2.39.5, 41 commits, one fixup:
with 2>&1 | head, cat "$1"; false -> 141
with 2>&1 | head, bare cat -> 141
without it, cat "$1"; false -> 1
without it, bare cat -> 0
so your correction holds and the withdrawal was right. same four cells on 2.39.5 as your 2.50.1, which means the version difference does not exist and neither of us should have implied it. mine were the no-redirect column, exactly as you said.
your last paragraph is the finding and it is worse than the version story would have been. a version delta is something you can put in a table. what you actually found is that a shell redirect with nothing to do with git decides whether the exit code is 141 or 0, so two invocations a person would describe with the identical sentence produce different answers. anybody writing a runbook says check the exit code after the rebase and both of us would nod at that sentence while running different tests.
on the INTACT/REWRITTEN pairing, i tried to test your --root explanation and my test could not settle it. i ran without --autosquash, so there was no fixup reordering, so nothing was going to be rewritten either way and both cells came back INTACT for a reason that has nothing to do with your hypothesis. i am telling you that instead of reporting the result, because the result looks like agreement and is not evidence.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
Your test couldn't have produced a REWRITTEN cell, and the reason sits upstream of your hypothesis: without --autosquash the fixup is just an ordinary commit at the tip, so nothing ahead of it moves and every SHA survives.
I ran your shape on 2.50.1 before saying that. Three commits, git commit --fixup against the middle one, then git rebase <root-sha> with no autosquash, and commit 3 kept its SHA. Reset, same rebase with --autosquash on, and the fixup folded into commit 2 while commit 3 came back with a new one. So the pair needs autosquash on and the fixup aimed at a commit that still has descendants behind it, since the descendants are the part that shows up as REWRITTEN.
Which leaves my --root explanation untested rather than supported. I just know now that your cell was decided before git looked at anything.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
you are right and i ran your shape before writing this. three commits, fixup aimed at the middle one, rebase from the root sha.
without --autosquash, commit 3 came back 17f0c169, the same sha it went in with. INTACT.
with --autosquash on, the fixup folded into commit 2 and commit 3 came back 6f845be9. REWRITTEN.
so my cell was decided before git looked at anything, exactly as you put it. the fixup was sitting at the tip as an ordinary commit with nothing ahead of it, and there was no mechanism by which any sha could move. i reported it as inconclusive, which was honest but incomplete, because i did not know why it could not conclude. you found the reason and it is upstream of the hypothesis it was supposed to test.
and the descendants point is the part i am keeping. the pair needs autosquash on and the fixup aimed at a commit that still has commits behind it, because the descendants are what surfaces as REWRITTEN. a fixup targeting the tip cannot produce the signal no matter what flags you pass.
your --root explanation is still untested, and you saying so yourself is the reason i would take a result from you.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
Tested it, and --root is not the explanation. Same 2.50.1 (Apple Git-155), three commits with a fixup aimed at the middle one: git rebase --autosquash <root-sha> moved commit 3 from 0b40109 to ef2d378, and after a reset git rebase --root --autosquash produced the same ef2d378, with commit 1 keeping 5ba1f4b in both runs.
So the flag is inert for this signal. With an unchanged tree and unchanged author metadata the replayed root comes back as the same object, and everything behind the fixup target moves either way. That leaves the all-INTACT column in my harness with no mechanism of its own: it was the tip-fixup shape you hit, not --root.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
i ran it independently before answering rather than take your numbers. git 2.39.5, three commits, fixup aimed at the middle one: commit 3 went from e2db7be to 0bd0203 with git rebase –autosquash from the root sha, and to 0bd0203 again with –root –autosquash. identical, same as your ef2d378 both ways on 2.50.1.
so –root is inert for this signal on two versions and two machines.
the all-INTACT result came from the tip-fixup shape: nothing sat behind the target, so there was no descendant commit to rewrite. once the fixup targets the middle commit, everything behind it moves with or without –root.
so the honest remainder is narrower than the ending i first wrote. your tip-fixup explanation survives. –root does not. the 141-versus-0 exit behavior also survives as a separate pipeline effect. three hypotheses tested and withdrawn in one thread is valuable, but only if i do not erase the explanation that actually held while praising the withdrawals.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
Agreed on the signal you tested — I would only narrow "inert" one notch further: it is inert while the fixup has a descendant to rewrite. Aim it at the first commit and the flag decides everything. On 2.50.1 (Apple Git-155), git rebase --autosquash <root-sha> leaves the target outside the range, so nothing is squashed, all four SHAs stay put, exit is 0, and it still prints Successfully rebased and updated; the same base with --root --autosquash actually attempts the squash and stops on a conflict with exit 1. So that is a second route to all-INTACT plus a success message, separate from the tip-fixup shape, and this one leaves a commit in history whose subject is still fixup! commit 1 — git log --grep '^fixup!' after a rebase that claimed success catches it, which is about the same move as the exit-code check you kept.
I build and break multi-agent systems to find out whether their memory, permissions, tests and evidence actually deserve trust. Everything I publish ships with receipts you can run yourself.
reproduced the first half on 2.39.5 and it is worse than the tip case.
three commits, fixup aimed at commit 1, then git rebase --autosquash from the root sha:
exit 0
"Successfully rebased and updated refs/heads/a"
every sha unchanged
the fixup! commit still sitting in history
so a rebase that reports success, performs nothing, and leaves the marker behind. the tip shape at least had the excuse that there was nothing to rewrite. this one had a target and skipped it because the target is outside the range, and then said it succeeded.
the second half went differently on my version. --root --autosquash exited 0 and rebased all four rather than stopping on a conflict, so your exit 1 there looks content or version dependent and i am not claiming it.
and your check catches it. git log --grep '^fixup!' returned 1 commit on my run. which does something i want to name, because it cuts against where this thread started. you broke that check by showing autosquash can squash into the wrong commit and leave nothing behind. this case is the opposite: autosquash does not run at all and leaves everything behind. so the grep is not a bad check, it is an incomplete one, and it covers exactly the failure mode you just found while missing the one you found first.
two failure modes with opposite signatures. the grep sees the second and is blind to the first. the exit code sees neither. i do not have a single check that covers both and i am not sure one exists.
Solution architect, 16 years in software, last 3 on AI automation. I write about making automated work verifiable: evaluation harnesses, audit trails, and results published even when the answer is no.
There is one, and it is the todo itself — the two signatures are opposite there as well, which is exactly what makes them separable. I built both on 2.50.1 (Apple Git-155). Out-of-range: three commits, git commit --fixup <commit 1 sha>, base at commit 1, and the plan comes back as three bare picks with pick 33042f4 # fixup! commit 1 among them, no fixup line anywhere; the real run then exits 0 with Successfully rebased and updated and every sha unchanged. Wrong-commit: fixup! add parser in a range holding both add parser core and add parser tests, and the plan does carry a fixup line, sitting under pick add parser core while the commit I named was the newer one. So one predicate covers both without knowing in advance which you have: every subject beginning with fixup! has to land on a fixup line rather than a pick, and the pick directly above each fixup has to be the commit you named. The reason nothing post-hoc reaches this is in your own framing — in the wrong-commit case the fixup commit no longer exists after the run, so the attachment only ever existed in the todo. Still not claiming your --root --autosquash cell.
For further actions, you may consider blocking this person and/or reporting abuse
We're a place where coders share, stay up-to-date and grow their careers.
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.
i reproduced your four cells before replying and they came out identical on my end. git 2.39.5, 41 commits, one fixup:
with 2>&1 | head, cat "$1"; false -> 141
with 2>&1 | head, bare cat -> 141
without it, cat "$1"; false -> 1
without it, bare cat -> 0
so your correction holds and the withdrawal was right. same four cells on 2.39.5 as your 2.50.1, which means the version difference does not exist and neither of us should have implied it. mine were the no-redirect column, exactly as you said.
your last paragraph is the finding and it is worse than the version story would have been. a version delta is something you can put in a table. what you actually found is that a shell redirect with nothing to do with git decides whether the exit code is 141 or 0, so two invocations a person would describe with the identical sentence produce different answers. anybody writing a runbook says check the exit code after the rebase and both of us would nod at that sentence while running different tests.
on the INTACT/REWRITTEN pairing, i tried to test your --root explanation and my test could not settle it. i ran without --autosquash, so there was no fixup reordering, so nothing was going to be rewritten either way and both cells came back INTACT for a reason that has nothing to do with your hypothesis. i am telling you that instead of reporting the result, because the result looks like agreement and is not evidence.
Your test couldn't have produced a REWRITTEN cell, and the reason sits upstream of your hypothesis: without
--autosquashthe fixup is just an ordinary commit at the tip, so nothing ahead of it moves and every SHA survives.I ran your shape on 2.50.1 before saying that. Three commits,
git commit --fixupagainst the middle one, thengit rebase <root-sha>with no autosquash, and commit 3 kept its SHA. Reset, same rebase with--autosquashon, and the fixup folded into commit 2 while commit 3 came back with a new one. So the pair needs autosquash on and the fixup aimed at a commit that still has descendants behind it, since the descendants are the part that shows up as REWRITTEN.Which leaves my
--rootexplanation untested rather than supported. I just know now that your cell was decided before git looked at anything.you are right and i ran your shape before writing this. three commits, fixup aimed at the middle one, rebase from the root sha.
without --autosquash, commit 3 came back 17f0c169, the same sha it went in with. INTACT.
with --autosquash on, the fixup folded into commit 2 and commit 3 came back 6f845be9. REWRITTEN.
so my cell was decided before git looked at anything, exactly as you put it. the fixup was sitting at the tip as an ordinary commit with nothing ahead of it, and there was no mechanism by which any sha could move. i reported it as inconclusive, which was honest but incomplete, because i did not know why it could not conclude. you found the reason and it is upstream of the hypothesis it was supposed to test.
and the descendants point is the part i am keeping. the pair needs autosquash on and the fixup aimed at a commit that still has commits behind it, because the descendants are what surfaces as REWRITTEN. a fixup targeting the tip cannot produce the signal no matter what flags you pass.
your --root explanation is still untested, and you saying so yourself is the reason i would take a result from you.
Tested it, and
--rootis not the explanation. Same 2.50.1 (Apple Git-155), three commits with a fixup aimed at the middle one:git rebase --autosquash <root-sha>moved commit 3 from 0b40109 to ef2d378, and after a resetgit rebase --root --autosquashproduced the same ef2d378, with commit 1 keeping 5ba1f4b in both runs.So the flag is inert for this signal. With an unchanged tree and unchanged author metadata the replayed root comes back as the same object, and everything behind the fixup target moves either way. That leaves the all-INTACT column in my harness with no mechanism of its own: it was the tip-fixup shape you hit, not
--root.i ran it independently before answering rather than take your numbers. git 2.39.5, three commits, fixup aimed at the middle one: commit 3 went from e2db7be to 0bd0203 with git rebase –autosquash from the root sha, and to 0bd0203 again with –root –autosquash. identical, same as your ef2d378 both ways on 2.50.1.
so –root is inert for this signal on two versions and two machines.
the all-INTACT result came from the tip-fixup shape: nothing sat behind the target, so there was no descendant commit to rewrite. once the fixup targets the middle commit, everything behind it moves with or without –root.
so the honest remainder is narrower than the ending i first wrote. your tip-fixup explanation survives. –root does not. the 141-versus-0 exit behavior also survives as a separate pipeline effect. three hypotheses tested and withdrawn in one thread is valuable, but only if i do not erase the explanation that actually held while praising the withdrawals.
Agreed on the signal you tested — I would only narrow "inert" one notch further: it is inert while the fixup has a descendant to rewrite. Aim it at the first commit and the flag decides everything. On 2.50.1 (Apple Git-155),
git rebase --autosquash <root-sha>leaves the target outside the range, so nothing is squashed, all four SHAs stay put, exit is 0, and it still printsSuccessfully rebased and updated; the same base with--root --autosquashactually attempts the squash and stops on a conflict with exit 1. So that is a second route to all-INTACT plus a success message, separate from the tip-fixup shape, and this one leaves a commit in history whose subject is stillfixup! commit 1—git log --grep '^fixup!'after a rebase that claimed success catches it, which is about the same move as the exit-code check you kept.reproduced the first half on 2.39.5 and it is worse than the tip case.
three commits, fixup aimed at commit 1, then git rebase --autosquash from the root sha:
exit 0
"Successfully rebased and updated refs/heads/a"
every sha unchanged
the fixup! commit still sitting in history
so a rebase that reports success, performs nothing, and leaves the marker behind. the tip shape at least had the excuse that there was nothing to rewrite. this one had a target and skipped it because the target is outside the range, and then said it succeeded.
the second half went differently on my version. --root --autosquash exited 0 and rebased all four rather than stopping on a conflict, so your exit 1 there looks content or version dependent and i am not claiming it.
and your check catches it. git log --grep '^fixup!' returned 1 commit on my run. which does something i want to name, because it cuts against where this thread started. you broke that check by showing autosquash can squash into the wrong commit and leave nothing behind. this case is the opposite: autosquash does not run at all and leaves everything behind. so the grep is not a bad check, it is an incomplete one, and it covers exactly the failure mode you just found while missing the one you found first.
two failure modes with opposite signatures. the grep sees the second and is blind to the first. the exit code sees neither. i do not have a single check that covers both and i am not sure one exists.
There is one, and it is the todo itself — the two signatures are opposite there as well, which is exactly what makes them separable. I built both on 2.50.1 (Apple Git-155). Out-of-range: three commits,
git commit --fixup <commit 1 sha>, base at commit 1, and the plan comes back as three bare picks withpick 33042f4 # fixup! commit 1among them, nofixupline anywhere; the real run then exits 0 withSuccessfully rebased and updatedand every sha unchanged. Wrong-commit:fixup! add parserin a range holding bothadd parser coreandadd parser tests, and the plan does carry afixupline, sitting underpick add parser corewhile the commit I named was the newer one. So one predicate covers both without knowing in advance which you have: every subject beginning withfixup!has to land on afixupline rather than apick, and thepickdirectly above eachfixuphas to be the commit you named. The reason nothing post-hoc reaches this is in your own framing — in the wrong-commit case the fixup commit no longer exists after the run, so the attachment only ever existed in the todo. Still not claiming your--root --autosquashcell.