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