{"thread":{"id":"65966","subject":"[PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","startedAt":"2026-07-10T04:13:16Z","lastAt":"2026-08-31T16:10:02Z","messageCount":7,"participants":["Farid Zakaria","Phillip Wood","Yuxuan Chen","phillip.wood123@gmail.com"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"547672","messageId":"20260709-fz-autosquash-empty-v1-1-84cb494c3613@gmail.com","threadId":"65966","inReplyTo":null,"subject":"[PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Farid Zakaria","fromEmail":"farid.m.zakaria@gmail.com","sentAt":"2026-07-10T04:13:11Z","receivedAt":"2026-07-10T04:13:16Z","isPatch":true,"body":"When \"git rebase --autosquash\" melds a \"fixup!\" or \"squash!\" commit into\nits target, the result can be a commit that no longer changes anything\nrelative to its parent, for example when the melded change reverts the\ntarget.  Rather than dropping or keeping this empty commit, the rebase\nstops with\n\n\tYou asked to amend the most recent commit, but doing so would\n\tmake it empty. ...\n\nand the \"--empty\" option has no effect on it.  This makes backing a\nchange out of a series awkward: reverting a commit as a \"fixup!\" and\nrunning \"git rebase --autosquash --empty=drop\" ought to remove both the\ncommit and its revert, but it halts instead.\n\nThe reason is that allow_empty() decides emptiness with\nis_index_unchanged(), which compares the index to HEAD.  A \"fixup!\" is\napplied by amending HEAD, so the commit it produces has HEAD's parent as\nits parent; it is empty when the index matches the tree of that parent,\nnot of HEAD.  A meld that cancels out its target is therefore never\nrecognized as having become empty, and falls through to \"git commit\n--amend\", which refuses to create an empty commit.\n\nTeach is_index_unchanged() to compare against the tree of HEAD's parent\nwhen amending, and teach allow_empty() to classify the result as \"became\nempty\" (and thus subject to --empty) unless the commit being melded into\nwas itself already empty, in which case it \"started empty\" and is\ngoverned by allow_empty as before.\n\nWhen --empty=drop applies, the emptied commit has already been created\nby the preceding \"pick\", so drop it by moving HEAD back to its parent.\nDo so before the rewritten-commit list is flushed, so that --update-refs\nand the other rewrite consumers map the dropped commit to its parent.\n\nSigned-off-by: Farid Zakaria <farid.m.zakaria@gmail.com>\n---\nAt Meta we maintain a fork of LLVM that we regularly rebase onto\nupstream.  A set of internal patches rides on top, and we keep each one\nas a single commit by folding follow-up changes into it with autosquash\n\"fixup!\" commits.  That works well for evolving a patch, but not for\nretiring one: to back an internal patch out today we delete it from the\nhistory by hand with an interactive rebase and then force-push, which is\neasy to get wrong on a shared branch.\n\nIt would be nicer to retire a patch the same way we amend one: commit a\nrevert of it as a \"fixup!\" and let autosquash fold the two together.\nThe net change is empty, so the commit should just drop out of the\nseries.  Today it does not -- the rebase stops instead.\n\nFor example, starting from a commit we want to retire:\n\n    $ git log --oneline\n    4d5e6f7 add feature patch\n    9a1b2c3 base\n\n    # revert the feature and mark the revert as a fixup of it\n    $ git revert --no-edit HEAD\n    $ git commit --amend -m \"fixup! add feature patch\"\n\n    $ git rebase -i --autosquash --empty=drop 9a1b2c3\n    Rebasing (2/2)\n    You asked to amend the most recent commit, but doing so would\n    make it empty. You can repeat your command with --allow-empty [...]\n    Could not apply 8e9f0a1... # fixup! add feature patch\n\nThe \"--empty=drop\" is ignored.  \"--empty\" only governs commits that are\npicked empty, whereas a \"fixup!\" is applied by amending, and the\nemptiness of an amended commit is measured against the wrong parent.  So\nthe rebase falls through to \"git commit --amend\", which refuses to\ncreate an empty commit, and halts.\n\nWith this patch the emptied commit is recognized and handled according\nto \"--empty\", the same as any other commit that becomes empty during a\nrebase:\n\n    $ git rebase -i --autosquash --empty=drop 9a1b2c3\n    Rebasing (2/2)\n    dropping 8e9f0a1... fixup! add feature patch -- resulting commit is empty\n    Successfully rebased and updated refs/heads/main.\n\n    $ git log --oneline\n    9a1b2c3 base\n\n\"--empty=keep\" retains it as an empty commit, and \"--empty=stop\" (the\ndefault under \"-i\") halts so the user can decide -- matching how these\noptions already behave for commits that become empty when picked.\n\nOne open question, for a possible follow-up.  A natural next step would\nbe a \"revert!\" autosquash directive (and a \"git commit --revert\" to\ncreate it), mirroring \"fixup!\"/\"squash!\", so\nthat retiring a patch would not require generating the reverse diff by\nhand.  I have deliberately left it out of this series, because its\nsemantics are not obvious: in particular, whether a \"revert!\" commit\nshould carry the reverse patch as its own content (and thus be an\nordinary fixup that this patch already drops), or be an empty marker\nthat instructs the rebase to revert the target commit during the meld.\nOpinions on whether such a directive is wanted, and which of those two\nshapes is preferred, would be welcome before I attempt it.\n---\nbase-commit: f60db8d575adb79761d363e026fb49bddf330c73\n---\n Documentation/git-rebase.adoc | 12 ++++++\n sequencer.c                   | 96 +++++++++++++++++++++++++++++++++++++++----\n t/t3415-rebase-autosquash.sh  | 64 +++++++++++++++++++++++++++++\n 3 files changed, 163 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\nindex f6c22d1598..7eb8bbe95f 100644\n--- a/Documentation/git-rebase.adoc\n+++ b/Documentation/git-rebase.adoc\n@@ -282,6 +282,11 @@ by `git log --cherry-mark ...`) are detected and dropped as a\n preliminary step (unless `--reapply-cherry-picks` or `--keep-base` is\n passed).\n +\n+A commit can also become empty as a result of `--autosquash`, when a\n+`fixup!` or `squash!` commit cancels out all of the changes of the\n+commit it is melded into.  Such a commit is treated the same way and is\n+dropped, kept, or stopped at according to this option.\n++\n See also INCOMPATIBLE OPTIONS below.\n \n --no-keep-empty::\n@@ -591,6 +596,13 @@ changed from `pick` to `squash`, `fixup` or `fixup -C`, respectively, and they\n are moved right after the commit they modify.  The `--interactive` option can\n be used to review and edit the todo list before proceeding.\n +\n+If melding a `fixup!` or `squash!` commit cancels out all of the changes of\n+the commit it is applied to, the result is an empty commit.  The handling of\n+these empty commits can be configured with the `--empty` option: the emptied\n+commit is dropped, kept, or stopped at.  This makes it possible to back a\n+change out of a series by committing a revert of it as a `fixup!` and letting\n+`--autosquash --empty=drop` remove both.\n++\n The recommended way to create commits with squash markers is by using the\n `--squash`, `--fixup`, `--fixup=amend:` or `--fixup=reword:` options of\n linkgit:git-commit[1], which take the target commit as an argument and\ndiff --git a/sequencer.c b/sequencer.c\nindex 0fe8fed6c3..435b100e3d 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -823,7 +823,7 @@ static struct object_id *get_cache_tree_oid(struct index_state *istate)\n \treturn &istate->cache_tree->oid;\n }\n \n-static int is_index_unchanged(struct repository *r)\n+static int is_index_unchanged(struct repository *r, int amend)\n {\n \tstruct object_id head_oid, *cache_tree_oid;\n \tconst struct object_id *head_tree_oid;\n@@ -856,7 +856,26 @@ static int is_index_unchanged(struct repository *r)\n \t\tif (repo_parse_commit(r, head_commit))\n \t\t\treturn -1;\n \n-\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n+\t\tif (amend) {\n+\t\t\t/*\n+\t\t\t * When amending (e.g. melding a \"fixup!\" or \"squash!\"),\n+\t\t\t * the commit we are about to create replaces HEAD, so\n+\t\t\t * its parent is HEAD's parent.  It is therefore empty\n+\t\t\t * when the index matches the tree of HEAD's parent\n+\t\t\t * rather than the tree of HEAD itself.\n+\t\t\t */\n+\t\t\tif (head_commit->parents) {\n+\t\t\t\tstruct commit *parent =\n+\t\t\t\t\thead_commit->parents->item;\n+\t\t\t\tif (repo_parse_commit(r, parent))\n+\t\t\t\t\treturn -1;\n+\t\t\t\thead_tree_oid = get_commit_tree_oid(parent);\n+\t\t\t} else {\n+\t\t\t\thead_tree_oid = the_hash_algo->empty_tree;\n+\t\t\t}\n+\t\t} else {\n+\t\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n+\t\t}\n \t}\n \n \tif (!(cache_tree_oid = get_cache_tree_oid(istate)))\n@@ -1786,7 +1805,7 @@ static int is_original_commit_empty(struct commit *commit)\n  */\n static int allow_empty(struct repository *r,\n \t\t       struct replay_opts *opts,\n-\t\t       struct commit *commit)\n+\t\t       struct commit *commit, int amend)\n {\n \tint index_unchanged, originally_empty;\n \n@@ -1798,13 +1817,33 @@ static int allow_empty(struct repository *r,\n \t * drop_redundant_commits determine whether the commit should be kept or\n \t * dropped. If neither is specified, halt.\n \t */\n-\tindex_unchanged = is_index_unchanged(r);\n+\tindex_unchanged = is_index_unchanged(r, amend);\n \tif (index_unchanged < 0)\n \t\treturn index_unchanged;\n \tif (!index_unchanged)\n \t\treturn 0; /* we do not have to say --allow-empty */\n \n-\toriginally_empty = is_original_commit_empty(commit);\n+\t/*\n+\t * When amending (melding a \"fixup!\"/\"squash!\"), the resulting commit\n+\t * replaces HEAD, so whether it \"started\" empty or \"became\" empty is\n+\t * decided by whether the commit being melded into was itself empty: if\n+\t * HEAD had content that the fixup cancelled out, the commit became empty\n+\t * and is subject to keep/drop_redundant; if HEAD was already empty, the\n+\t * commit started empty and is subject to allow_empty as usual.\n+\t */\n+\tif (amend) {\n+\t\tstruct object_id head_oid;\n+\t\tstruct commit *head_commit;\n+\n+\t\tif (repo_get_oid(r, \"HEAD\", &head_oid))\n+\t\t\treturn error(_(\"could not resolve HEAD commit\"));\n+\t\thead_commit = lookup_commit_reference(r, &head_oid);\n+\t\tif (!head_commit)\n+\t\t\treturn -1;\n+\t\toriginally_empty = is_original_commit_empty(head_commit);\n+\t} else {\n+\t\toriginally_empty = is_original_commit_empty(commit);\n+\t}\n \tif (originally_empty < 0)\n \t\treturn originally_empty;\n \tif (originally_empty)\n@@ -2260,6 +2299,30 @@ static const char *reflog_message(struct replay_opts *opts,\n \treturn buf.buf;\n }\n \n+/*\n+ * A \"fixup!\"/\"squash!\" that melds into HEAD may empty it out.  In that case,\n+ * with --empty=drop, we want to drop the commit entirely.  Since the commit\n+ * being amended has already been created (by the preceding \"pick\"), and the\n+ * index and worktree already match the tree of its parent, dropping it is a\n+ * matter of moving HEAD back to that parent.\n+ */\n+static int reset_head_to_parent(struct repository *r, struct replay_opts *opts,\n+\t\t\t\tstruct object_id *head)\n+{\n+\tstruct commit *head_commit = lookup_commit_reference(r, head);\n+\n+\tif (!head_commit || repo_parse_commit(r, head_commit))\n+\t\treturn error(_(\"could not parse HEAD commit\"));\n+\tif (!head_commit->parents)\n+\t\treturn error(_(\"cannot drop the root commit\"));\n+\n+\treturn refs_update_ref(get_main_ref_store(r),\n+\t\t\t       reflog_message(opts, \"fixup\",\n+\t\t\t\t\t      \"dropping emptied commit\"),\n+\t\t\t       \"HEAD\", &head_commit->parents->item->object.oid,\n+\t\t\t       head, 0, UPDATE_REFS_MSG_ON_ERR);\n+}\n+\n static int do_pick_commit(struct repository *r,\n \t\t\t  struct todo_item *item,\n \t\t\t  struct replay_opts *opts,\n@@ -2493,7 +2556,7 @@ static int do_pick_commit(struct repository *r,\n \t}\n \n \tdrop_commit = 0;\n-\tallow = allow_empty(r, opts, commit);\n+\tallow = allow_empty(r, opts, commit, flags & AMEND_MSG);\n \tif (allow < 0) {\n \t\tres = allow;\n \t\tgoto leave;\n@@ -2506,9 +2569,24 @@ static int do_pick_commit(struct repository *r,\n \t\tunlink(git_path_merge_msg(r));\n \t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n \t\t\t\tNULL, REF_NO_DEREF);\n-\t\tfprintf(stderr,\n-\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n-\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n+\t\tif (flags & AMEND_MSG) {\n+\t\t\t/*\n+\t\t\t * The \"fixup!\"/\"squash!\" emptied out the commit it was\n+\t\t\t * melded into; that commit was already created by the\n+\t\t\t * preceding \"pick\", so drop it by moving HEAD back to\n+\t\t\t * its parent.\n+\t\t\t */\n+\t\t\tres = reset_head_to_parent(r, opts, &head);\n+\t\t\tif (res)\n+\t\t\t\tgoto leave;\n+\t\t\tfprintf(stderr,\n+\t\t\t\t_(\"dropping %s %s -- resulting commit is empty\\n\"),\n+\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n+\t\t} else {\n+\t\t\tfprintf(stderr,\n+\t\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n+\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n+\t\t}\n \t} /* else allow == 0 and there's nothing special to do */\n \tif (!opts->no_commit && !drop_commit) {\n \t\tif (author || command == TODO_REVERT || (flags & AMEND_MSG))\ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 5033411a43..508dcc7527 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -510,4 +510,68 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \ttest_commit_message HEAD -m \"something\"\n '\n \n+test_expect_success 'fixup! that empties its target is dropped with --empty=drop' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addX fileX 1 &&\n+\ttest_commit --no-tag changeX fileX 2 &&\n+\ttest_commit --no-tag later fileW hello &&\n+\techo 1 >fileX &&\n+\tgit commit -m \"fixup! changeX\" fileX &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n+\n+\tgit log --format=%s >actual &&\n+\t! grep changeX actual &&\n+\tgrep addX actual &&\n+\tgrep later actual &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileX &&\n+\techo hello >expect &&\n+\ttest_cmp expect fileW\n+'\n+\n+test_expect_success 'fixup! that empties its target is kept with --empty=keep' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addY fileY 1 &&\n+\ttest_commit --no-tag changeY fileY 2 &&\n+\techo 1 >fileY &&\n+\tgit commit -m \"fixup! changeY\" fileY &&\n+\n+\tgit rebase -i --autosquash --empty=keep HEAD~3 &&\n+\n+\tgit log --format=%s >actual &&\n+\tgrep changeY actual &&\n+\t: \"the retained commit is empty\" &&\n+\tgit diff --exit-code HEAD~1 HEAD &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileY\n+'\n+\n+test_expect_success 'fixup! that empties its target stops with --empty=stop' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addZ fileZ 1 &&\n+\ttest_commit --no-tag changeZ fileZ 2 &&\n+\techo 1 >fileZ &&\n+\tgit commit -m \"fixup! changeZ\" fileZ &&\n+\n+\ttest_when_finished \"git rebase --abort\" &&\n+\ttest_must_fail git rebase -i --autosquash --empty=stop HEAD~3\n+'\n+\n+test_expect_success 'squash! that empties its target is dropped with --empty=drop' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addS fileS 1 &&\n+\ttest_commit --no-tag changeS fileS 2 &&\n+\techo 1 >fileS &&\n+\tgit commit -m \"squash! changeS\" fileS &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~3 &&\n+\n+\tgit log --format=%s >actual &&\n+\t! grep changeS actual &&\n+\tgrep addS actual &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileS\n+'\n+\n test_done\n\n\n\n"},{"id":"547734","messageId":"afb76b98-661a-4663-8e8b-fd00572db5ba@gmail.com","threadId":"65966","inReplyTo":"20260709-fz-autosquash-empty-v1-1-84cb494c3613@gmail.com","subject":"Re: [PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T13:28:09Z","receivedAt":"2026-07-10T13:28:13Z","isPatch":true,"body":"Hi Farid\n\nOn 10/07/2026 05:13, Farid Zakaria wrote:\n> When \"git rebase --autosquash\" melds a \"fixup!\" or \"squash!\" commit into\n> its target, the result can be a commit that no longer changes anything\n> relative to its parent, for example when the melded change reverts the\n> target.  Rather than dropping or keeping this empty commit, the rebase\n> stops with\n> \n> \tYou asked to amend the most recent commit, but doing so would\n> \tmake it empty. ...\n> \n> and the \"--empty\" option has no effect on it.  This makes backing a\n> change out of a series awkward: reverting a commit as a \"fixup!\" and\n> running \"git rebase --autosquash --empty=drop\" ought to remove both the\n> commit and its revert, but it halts instead.\n\nI agree this is a use case that we want to support\n\n> The reason is that allow_empty() decides emptiness with\n> is_index_unchanged(), which compares the index to HEAD.  A \"fixup!\" is\n> applied by amending HEAD, so the commit it produces has HEAD's parent as\n> its parent; it is empty when the index matches the tree of that parent,\n> not of HEAD.  A meld that cancels out its target is therefore never\n> recognized as having become empty, and falls through to \"git commit\n> --amend\", which refuses to create an empty commit.\n\nand with this diagnosis.\n\n> Teach is_index_unchanged() to compare against the tree of HEAD's parent\n> when amending, and teach allow_empty() to classify the result as \"became\n> empty\" (and thus subject to --empty) unless the commit being melded into\n> was itself already empty, in which case it \"started empty\" and is\n> governed by allow_empty as before.\n\nHowever, I think that rather than changing the current check which \nchanges the behavior of a fixup commit that becomes empty we should add \nan additional check to see if applying the fixup makes the target commit \nempty. With the patch here a fixup commit that becomes empty is only \nseen as empty if the commit being fixed up is empty in which case we \nalways accept the fixup, whereas the current behavior is always to \nrespect what --empty says. When I'm planning out a series of commits I \nsometimes create empty commits where the messages says what I'm \nintending to do and then I create fixups for them when I get round to \nwriting the code. If one of those fixups becomes empty I want to know \nabout it because it means I need to drop the empty commit that's being \nfixed up as well.\n\n> When --empty=drop applies, the emptied commit has already been created\n> by the preceding \"pick\", so drop it by moving HEAD back to its parent.\n> Do so before the rewritten-commit list is flushed, so that --update-refs\n> and the other rewrite consumers map the dropped commit to its parent.\n\nIf we're dropping the commit then we should not record it as rewritten \nso we need to remove the rewritten-pending file. Any labels and \nupdate-ref commands that come immediately after the dropped commit will \nsee HEAD pointing to the dropped commits rewritten parent.\n\n> Signed-off-by: Farid Zakaria <farid.m.zakaria@gmail.com>\n> ---\n> At Meta we maintain a fork of LLVM that we regularly rebase onto\n> upstream.  A set of internal patches rides on top, and we keep each one\n> as a single commit by folding follow-up changes into it with autosquash\n> \"fixup!\" commits.  That works well for evolving a patch, but not for\n> retiring one: to back an internal patch out today we delete it from the\n> history by hand with an interactive rebase and then force-push, which is\n> easy to get wrong on a shared branch.\n\nYou'll still need a forced push though because you're dropping the \ncommit. I think the change you're proposing to git would be useful but \nyou could automate your existing workflow by setting GIT_SEQUENCE_EDITOR \nto a script that drops the commit and it's fixups from the todo list.\n\n> One open question, for a possible follow-up.  A natural next step would\n> be a \"revert!\" autosquash directive (and a \"git commit --revert\" to\n> create it), mirroring \"fixup!\"/\"squash!\", so\n> that retiring a patch would not require generating the reverse diff by\n> hand.  I have deliberately left it out of this series, because its\n> semantics are not obvious: in particular, whether a \"revert!\" commit\n> should carry the reverse patch as its own content (and thus be an\n> ordinary fixup that this patch already drops), or be an empty marker\n> that instructs the rebase to revert the target commit during the meld.\n> Opinions on whether such a directive is wanted, and which of those two\n> shapes is preferred, would be welcome before I attempt it.\n\nI think having support for creating and squashing revert! (or possibly \ndrop!) commits is a good idea (I've a feeling there is some discussion \nabout that in the gitgitgadget issue tracker). Using an empty commit has \na marker has the advantage that applying it cannot create conflicts, so \nyou only have to deal with the conflicts caused by the commit being \ndropped, not the by fixup not applying cleanly.\n\nThanks\n\nPhillip\n\n> ---\n> base-commit: f60db8d575adb79761d363e026fb49bddf330c73\n> ---\n>   Documentation/git-rebase.adoc | 12 ++++++\n>   sequencer.c                   | 96 +++++++++++++++++++++++++++++++++++++++----\n>   t/t3415-rebase-autosquash.sh  | 64 +++++++++++++++++++++++++++++\n>   3 files changed, 163 insertions(+), 9 deletions(-)\n> \n> diff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\n> index f6c22d1598..7eb8bbe95f 100644\n> --- a/Documentation/git-rebase.adoc\n> +++ b/Documentation/git-rebase.adoc\n> @@ -282,6 +282,11 @@ by `git log --cherry-mark ...`) are detected and dropped as a\n>   preliminary step (unless `--reapply-cherry-picks` or `--keep-base` is\n>   passed).\n>   +\n> +A commit can also become empty as a result of `--autosquash`, when a\n> +`fixup!` or `squash!` commit cancels out all of the changes of the\n> +commit it is melded into.  Such a commit is treated the same way and is\n> +dropped, kept, or stopped at according to this option.\n> ++\n>   See also INCOMPATIBLE OPTIONS below.\n>   \n>   --no-keep-empty::\n> @@ -591,6 +596,13 @@ changed from `pick` to `squash`, `fixup` or `fixup -C`, respectively, and they\n>   are moved right after the commit they modify.  The `--interactive` option can\n>   be used to review and edit the todo list before proceeding.\n>   +\n> +If melding a `fixup!` or `squash!` commit cancels out all of the changes of\n> +the commit it is applied to, the result is an empty commit.  The handling of\n> +these empty commits can be configured with the `--empty` option: the emptied\n> +commit is dropped, kept, or stopped at.  This makes it possible to back a\n> +change out of a series by committing a revert of it as a `fixup!` and letting\n> +`--autosquash --empty=drop` remove both.\n> ++\n>   The recommended way to create commits with squash markers is by using the\n>   `--squash`, `--fixup`, `--fixup=amend:` or `--fixup=reword:` options of\n>   linkgit:git-commit[1], which take the target commit as an argument and\n> diff --git a/sequencer.c b/sequencer.c\n> index 0fe8fed6c3..435b100e3d 100644\n> --- a/sequencer.c\n> +++ b/sequencer.c\n> @@ -823,7 +823,7 @@ static struct object_id *get_cache_tree_oid(struct index_state *istate)\n>   \treturn &istate->cache_tree->oid;\n>   }\n>   \n> -static int is_index_unchanged(struct repository *r)\n> +static int is_index_unchanged(struct repository *r, int amend)\n>   {\n>   \tstruct object_id head_oid, *cache_tree_oid;\n>   \tconst struct object_id *head_tree_oid;\n> @@ -856,7 +856,26 @@ static int is_index_unchanged(struct repository *r)\n>   \t\tif (repo_parse_commit(r, head_commit))\n>   \t\t\treturn -1;\n>   \n> -\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n> +\t\tif (amend) {\n> +\t\t\t/*\n> +\t\t\t * When amending (e.g. melding a \"fixup!\" or \"squash!\"),\n> +\t\t\t * the commit we are about to create replaces HEAD, so\n> +\t\t\t * its parent is HEAD's parent.  It is therefore empty\n> +\t\t\t * when the index matches the tree of HEAD's parent\n> +\t\t\t * rather than the tree of HEAD itself.\n> +\t\t\t */\n> +\t\t\tif (head_commit->parents) {\n> +\t\t\t\tstruct commit *parent =\n> +\t\t\t\t\thead_commit->parents->item;\n> +\t\t\t\tif (repo_parse_commit(r, parent))\n> +\t\t\t\t\treturn -1;\n> +\t\t\t\thead_tree_oid = get_commit_tree_oid(parent);\n> +\t\t\t} else {\n> +\t\t\t\thead_tree_oid = the_hash_algo->empty_tree;\n> +\t\t\t}\n> +\t\t} else {\n> +\t\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n> +\t\t}\n>   \t}\n>   \n>   \tif (!(cache_tree_oid = get_cache_tree_oid(istate)))\n> @@ -1786,7 +1805,7 @@ static int is_original_commit_empty(struct commit *commit)\n>    */\n>   static int allow_empty(struct repository *r,\n>   \t\t       struct replay_opts *opts,\n> -\t\t       struct commit *commit)\n> +\t\t       struct commit *commit, int amend)\n>   {\n>   \tint index_unchanged, originally_empty;\n>   \n> @@ -1798,13 +1817,33 @@ static int allow_empty(struct repository *r,\n>   \t * drop_redundant_commits determine whether the commit should be kept or\n>   \t * dropped. If neither is specified, halt.\n>   \t */\n> -\tindex_unchanged = is_index_unchanged(r);\n> +\tindex_unchanged = is_index_unchanged(r, amend);\n>   \tif (index_unchanged < 0)\n>   \t\treturn index_unchanged;\n>   \tif (!index_unchanged)\n>   \t\treturn 0; /* we do not have to say --allow-empty */\n>   \n> -\toriginally_empty = is_original_commit_empty(commit);\n> +\t/*\n> +\t * When amending (melding a \"fixup!\"/\"squash!\"), the resulting commit\n> +\t * replaces HEAD, so whether it \"started\" empty or \"became\" empty is\n> +\t * decided by whether the commit being melded into was itself empty: if\n> +\t * HEAD had content that the fixup cancelled out, the commit became empty\n> +\t * and is subject to keep/drop_redundant; if HEAD was already empty, the\n> +\t * commit started empty and is subject to allow_empty as usual.\n> +\t */\n> +\tif (amend) {\n> +\t\tstruct object_id head_oid;\n> +\t\tstruct commit *head_commit;\n> +\n> +\t\tif (repo_get_oid(r, \"HEAD\", &head_oid))\n> +\t\t\treturn error(_(\"could not resolve HEAD commit\"));\n> +\t\thead_commit = lookup_commit_reference(r, &head_oid);\n> +\t\tif (!head_commit)\n> +\t\t\treturn -1;\n> +\t\toriginally_empty = is_original_commit_empty(head_commit);\n> +\t} else {\n> +\t\toriginally_empty = is_original_commit_empty(commit);\n> +\t}\n>   \tif (originally_empty < 0)\n>   \t\treturn originally_empty;\n>   \tif (originally_empty)\n> @@ -2260,6 +2299,30 @@ static const char *reflog_message(struct replay_opts *opts,\n>   \treturn buf.buf;\n>   }\n>   \n> +/*\n> + * A \"fixup!\"/\"squash!\" that melds into HEAD may empty it out.  In that case,\n> + * with --empty=drop, we want to drop the commit entirely.  Since the commit\n> + * being amended has already been created (by the preceding \"pick\"), and the\n> + * index and worktree already match the tree of its parent, dropping it is a\n> + * matter of moving HEAD back to that parent.\n> + */\n> +static int reset_head_to_parent(struct repository *r, struct replay_opts *opts,\n> +\t\t\t\tstruct object_id *head)\n> +{\n> +\tstruct commit *head_commit = lookup_commit_reference(r, head);\n> +\n> +\tif (!head_commit || repo_parse_commit(r, head_commit))\n> +\t\treturn error(_(\"could not parse HEAD commit\"));\n> +\tif (!head_commit->parents)\n> +\t\treturn error(_(\"cannot drop the root commit\"));\n> +\n> +\treturn refs_update_ref(get_main_ref_store(r),\n> +\t\t\t       reflog_message(opts, \"fixup\",\n> +\t\t\t\t\t      \"dropping emptied commit\"),\n> +\t\t\t       \"HEAD\", &head_commit->parents->item->object.oid,\n> +\t\t\t       head, 0, UPDATE_REFS_MSG_ON_ERR);\n> +}\n> +\n>   static int do_pick_commit(struct repository *r,\n>   \t\t\t  struct todo_item *item,\n>   \t\t\t  struct replay_opts *opts,\n> @@ -2493,7 +2556,7 @@ static int do_pick_commit(struct repository *r,\n>   \t}\n>   \n>   \tdrop_commit = 0;\n> -\tallow = allow_empty(r, opts, commit);\n> +\tallow = allow_empty(r, opts, commit, flags & AMEND_MSG);\n>   \tif (allow < 0) {\n>   \t\tres = allow;\n>   \t\tgoto leave;\n> @@ -2506,9 +2569,24 @@ static int do_pick_commit(struct repository *r,\n>   \t\tunlink(git_path_merge_msg(r));\n>   \t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n>   \t\t\t\tNULL, REF_NO_DEREF);\n> -\t\tfprintf(stderr,\n> -\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n> -\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n> +\t\tif (flags & AMEND_MSG) {\n> +\t\t\t/*\n> +\t\t\t * The \"fixup!\"/\"squash!\" emptied out the commit it was\n> +\t\t\t * melded into; that commit was already created by the\n> +\t\t\t * preceding \"pick\", so drop it by moving HEAD back to\n> +\t\t\t * its parent.\n> +\t\t\t */\n> +\t\t\tres = reset_head_to_parent(r, opts, &head);\n> +\t\t\tif (res)\n> +\t\t\t\tgoto leave;\n> +\t\t\tfprintf(stderr,\n> +\t\t\t\t_(\"dropping %s %s -- resulting commit is empty\\n\"),\n> +\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n> +\t\t} else {\n> +\t\t\tfprintf(stderr,\n> +\t\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n> +\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n> +\t\t}\n>   \t} /* else allow == 0 and there's nothing special to do */\n>   \tif (!opts->no_commit && !drop_commit) {\n>   \t\tif (author || command == TODO_REVERT || (flags & AMEND_MSG))\n> diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\n> index 5033411a43..508dcc7527 100755\n> --- a/t/t3415-rebase-autosquash.sh\n> +++ b/t/t3415-rebase-autosquash.sh\n> @@ -510,4 +510,68 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n>   \ttest_commit_message HEAD -m \"something\"\n>   '\n>   \n> +test_expect_success 'fixup! that empties its target is dropped with --empty=drop' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addX fileX 1 &&\n> +\ttest_commit --no-tag changeX fileX 2 &&\n> +\ttest_commit --no-tag later fileW hello &&\n> +\techo 1 >fileX &&\n> +\tgit commit -m \"fixup! changeX\" fileX &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\t! grep changeX actual &&\n> +\tgrep addX actual &&\n> +\tgrep later actual &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileX &&\n> +\techo hello >expect &&\n> +\ttest_cmp expect fileW\n> +'\n> +\n> +test_expect_success 'fixup! that empties its target is kept with --empty=keep' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addY fileY 1 &&\n> +\ttest_commit --no-tag changeY fileY 2 &&\n> +\techo 1 >fileY &&\n> +\tgit commit -m \"fixup! changeY\" fileY &&\n> +\n> +\tgit rebase -i --autosquash --empty=keep HEAD~3 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\tgrep changeY actual &&\n> +\t: \"the retained commit is empty\" &&\n> +\tgit diff --exit-code HEAD~1 HEAD &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileY\n> +'\n> +\n> +test_expect_success 'fixup! that empties its target stops with --empty=stop' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addZ fileZ 1 &&\n> +\ttest_commit --no-tag changeZ fileZ 2 &&\n> +\techo 1 >fileZ &&\n> +\tgit commit -m \"fixup! changeZ\" fileZ &&\n> +\n> +\ttest_when_finished \"git rebase --abort\" &&\n> +\ttest_must_fail git rebase -i --autosquash --empty=stop HEAD~3\n> +'\n> +\n> +test_expect_success 'squash! that empties its target is dropped with --empty=drop' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addS fileS 1 &&\n> +\ttest_commit --no-tag changeS fileS 2 &&\n> +\techo 1 >fileS &&\n> +\tgit commit -m \"squash! changeS\" fileS &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~3 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\t! grep changeS actual &&\n> +\tgrep addS actual &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileS\n> +'\n> +\n>   test_done\n> \n> \n> \n> \n\n"},{"id":"547776","messageId":"DJV1I085BRB7.20RAPPD7AXC41@gmail.com","threadId":"65966","inReplyTo":"afb76b98-661a-4663-8e8b-fd00572db5ba@gmail.com","subject":"Re: [PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Farid Zakaria","fromEmail":"farid.m.zakaria@gmail.com","sentAt":"2026-07-10T16:42:31Z","receivedAt":"2026-07-10T16:42:33Z","isPatch":true,"body":"On Fri Jul 10, 2026 at 6:28 AM PDT, Phillip Wood wrote:\n\nPhillip,\n\nThank you for responding. This is my first submission to the Git mailing\nlist.\n\nI want to be forthecoming that I'm not familiar with the Git codebase,\nand (maybe unsurprisingly) I have been leveraging LLMs to help me\nunderstand the code and write the patch. I consulted the Git\ncontribution guidelines and it says that should be OK as long as it's\nnot \"slop\". I'm diligent to remaining in the loop (HITL) and reviewing\nthe code and tests to the best of my understanding of the codebase.\n\nI will send the V2 shortly. Thank you!\n\n> Hi Farid\n>\n> On 10/07/2026 05:13, Farid Zakaria wrote:\n>> When \"git rebase --autosquash\" melds a \"fixup!\" or \"squash!\" commit into\n>> its target, the result can be a commit that no longer changes anything\n>> relative to its parent, for example when the melded change reverts the\n>> target.  Rather than dropping or keeping this empty commit, the rebase\n>> stops with\n>> \n>> \tYou asked to amend the most recent commit, but doing so would\n>> \tmake it empty. ...\n>> \n>> and the \"--empty\" option has no effect on it.  This makes backing a\n>> change out of a series awkward: reverting a commit as a \"fixup!\" and\n>> running \"git rebase --autosquash --empty=drop\" ought to remove both the\n>> commit and its revert, but it halts instead.\n>\n> I agree this is a use case that we want to support\n>\n>> The reason is that allow_empty() decides emptiness with\n>> is_index_unchanged(), which compares the index to HEAD.  A \"fixup!\" is\n>> applied by amending HEAD, so the commit it produces has HEAD's parent as\n>> its parent; it is empty when the index matches the tree of that parent,\n>> not of HEAD.  A meld that cancels out its target is therefore never\n>> recognized as having become empty, and falls through to \"git commit\n>> --amend\", which refuses to create an empty commit.\n>\n> and with this diagnosis.\n>\n>> Teach is_index_unchanged() to compare against the tree of HEAD's parent\n>> when amending, and teach allow_empty() to classify the result as \"became\n>> empty\" (and thus subject to --empty) unless the commit being melded into\n>> was itself already empty, in which case it \"started empty\" and is\n>> governed by allow_empty as before.\n>\n> However, I think that rather than changing the current check which \n> changes the behavior of a fixup commit that becomes empty we should add \n> an additional check to see if applying the fixup makes the target commit \n> empty. With the patch here a fixup commit that becomes empty is only \n> seen as empty if the commit being fixed up is empty in which case we \n> always accept the fixup, whereas the current behavior is always to \n> respect what --empty says. When I'm planning out a series of commits I \n> sometimes create empty commits where the messages says what I'm \n> intending to do and then I create fixups for them when I get round to \n> writing the code. If one of those fixups becomes empty I want to know \n> about it because it means I need to drop the empty commit that's being \n> fixed up as well.\n>\n\nThank you for this catch. I will apply the changes you suggest in V2 and\nadd test cases for this missing behavior.\n\n>> When --empty=drop applies, the emptied commit has already been created\n>> by the preceding \"pick\", so drop it by moving HEAD back to its parent.\n>> Do so before the rewritten-commit list is flushed, so that --update-refs\n>> and the other rewrite consumers map the dropped commit to its parent.\n>\n> If we're dropping the commit then we should not record it as rewritten \n> so we need to remove the rewritten-pending file. Any labels and \n> update-ref commands that come immediately after the dropped commit will \n> see HEAD pointing to the dropped commits rewritten parent.\n>\n\nI will address this in V2 as well.\n\n>> Signed-off-by: Farid Zakaria <farid.m.zakaria@gmail.com>\n>> ---\n>> At Meta we maintain a fork of LLVM that we regularly rebase onto\n>> upstream.  A set of internal patches rides on top, and we keep each one\n>> as a single commit by folding follow-up changes into it with autosquash\n>> \"fixup!\" commits.  That works well for evolving a patch, but not for\n>> retiring one: to back an internal patch out today we delete it from the\n>> history by hand with an interactive rebase and then force-push, which is\n>> easy to get wrong on a shared branch.\n>\n> You'll still need a forced push though because you're dropping the \n> commit. I think the change you're proposing to git would be useful but \n> you could automate your existing workflow by setting GIT_SEQUENCE_EDITOR \n> to a script that drops the commit and it's fixups from the todo list.\n>\n\nTrue. I guess I should have clarified we run with a script that already\nuses GIT_SEQUENCE_EDITOR and force-pushes. I just wanted to avoid cases\nwhere a developer has to intervene in the rebase and force-push.\nDevelopers must still intervene and force-push when a conflict arises in\nour workflow though....\n\n>> One open question, for a possible follow-up.  A natural next step would\n>> be a \"revert!\" autosquash directive (and a \"git commit --revert\" to\n>> create it), mirroring \"fixup!\"/\"squash!\", so\n>> that retiring a patch would not require generating the reverse diff by\n>> hand.  I have deliberately left it out of this series, because its\n>> semantics are not obvious: in particular, whether a \"revert!\" commit\n>> should carry the reverse patch as its own content (and thus be an\n>> ordinary fixup that this patch already drops), or be an empty marker\n>> that instructs the rebase to revert the target commit during the meld.\n>> Opinions on whether such a directive is wanted, and which of those two\n>> shapes is preferred, would be welcome before I attempt it.\n>\n> I think having support for creating and squashing revert! (or possibly \n> drop!) commits is a good idea (I've a feeling there is some discussion \n> about that in the gitgitgadget issue tracker). Using an empty commit has \n> a marker has the advantage that applying it cannot create conflicts, so \n> you only have to deal with the conflicts caused by the commit being \n> dropped, not the by fixup not applying cleanly.\n>\n\nThis seems like a nice ergonomic improvement but I chose to leave it out\nsince it seems particularly thorny to get right.\nI know for us at Meta, we use Phabricator (similar to Gerrit) and\nworking with empty commits is I think problematic for the code review\ntooling (unsubstantiated).\n\nIf this current patch series makes it's way through though I can tackle\nan approach as an RFC.\n\n> Thanks\n>\n> Phillip\n>\n>> ---\n>> base-commit: f60db8d575adb79761d363e026fb49bddf330c73\n>> ---\n>>   Documentation/git-rebase.adoc | 12 ++++++\n>>   sequencer.c                   | 96 +++++++++++++++++++++++++++++++++++++++----\n>>   t/t3415-rebase-autosquash.sh  | 64 +++++++++++++++++++++++++++++\n>>   3 files changed, 163 insertions(+), 9 deletions(-)\n>> \n>> diff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\n>> index f6c22d1598..7eb8bbe95f 100644\n>> --- a/Documentation/git-rebase.adoc\n>> +++ b/Documentation/git-rebase.adoc\n>> @@ -282,6 +282,11 @@ by `git log --cherry-mark ...`) are detected and dropped as a\n>>   preliminary step (unless `--reapply-cherry-picks` or `--keep-base` is\n>>   passed).\n>>   +\n>> +A commit can also become empty as a result of `--autosquash`, when a\n>> +`fixup!` or `squash!` commit cancels out all of the changes of the\n>> +commit it is melded into.  Such a commit is treated the same way and is\n>> +dropped, kept, or stopped at according to this option.\n>> ++\n>>   See also INCOMPATIBLE OPTIONS below.\n>>   \n>>   --no-keep-empty::\n>> @@ -591,6 +596,13 @@ changed from `pick` to `squash`, `fixup` or `fixup -C`, respectively, and they\n>>   are moved right after the commit they modify.  The `--interactive` option can\n>>   be used to review and edit the todo list before proceeding.\n>>   +\n>> +If melding a `fixup!` or `squash!` commit cancels out all of the changes of\n>> +the commit it is applied to, the result is an empty commit.  The handling of\n>> +these empty commits can be configured with the `--empty` option: the emptied\n>> +commit is dropped, kept, or stopped at.  This makes it possible to back a\n>> +change out of a series by committing a revert of it as a `fixup!` and letting\n>> +`--autosquash --empty=drop` remove both.\n>> ++\n>>   The recommended way to create commits with squash markers is by using the\n>>   `--squash`, `--fixup`, `--fixup=amend:` or `--fixup=reword:` options of\n>>   linkgit:git-commit[1], which take the target commit as an argument and\n>> diff --git a/sequencer.c b/sequencer.c\n>> index 0fe8fed6c3..435b100e3d 100644\n>> --- a/sequencer.c\n>> +++ b/sequencer.c\n>> @@ -823,7 +823,7 @@ static struct object_id *get_cache_tree_oid(struct index_state *istate)\n>>   \treturn &istate->cache_tree->oid;\n>>   }\n>>   \n>> -static int is_index_unchanged(struct repository *r)\n>> +static int is_index_unchanged(struct repository *r, int amend)\n>>   {\n>>   \tstruct object_id head_oid, *cache_tree_oid;\n>>   \tconst struct object_id *head_tree_oid;\n>> @@ -856,7 +856,26 @@ static int is_index_unchanged(struct repository *r)\n>>   \t\tif (repo_parse_commit(r, head_commit))\n>>   \t\t\treturn -1;\n>>   \n>> -\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n>> +\t\tif (amend) {\n>> +\t\t\t/*\n>> +\t\t\t * When amending (e.g. melding a \"fixup!\" or \"squash!\"),\n>> +\t\t\t * the commit we are about to create replaces HEAD, so\n>> +\t\t\t * its parent is HEAD's parent.  It is therefore empty\n>> +\t\t\t * when the index matches the tree of HEAD's parent\n>> +\t\t\t * rather than the tree of HEAD itself.\n>> +\t\t\t */\n>> +\t\t\tif (head_commit->parents) {\n>> +\t\t\t\tstruct commit *parent =\n>> +\t\t\t\t\thead_commit->parents->item;\n>> +\t\t\t\tif (repo_parse_commit(r, parent))\n>> +\t\t\t\t\treturn -1;\n>> +\t\t\t\thead_tree_oid = get_commit_tree_oid(parent);\n>> +\t\t\t} else {\n>> +\t\t\t\thead_tree_oid = the_hash_algo->empty_tree;\n>> +\t\t\t}\n>> +\t\t} else {\n>> +\t\t\thead_tree_oid = get_commit_tree_oid(head_commit);\n>> +\t\t}\n>>   \t}\n>>   \n>>   \tif (!(cache_tree_oid = get_cache_tree_oid(istate)))\n>> @@ -1786,7 +1805,7 @@ static int is_original_commit_empty(struct commit *commit)\n>>    */\n>>   static int allow_empty(struct repository *r,\n>>   \t\t       struct replay_opts *opts,\n>> -\t\t       struct commit *commit)\n>> +\t\t       struct commit *commit, int amend)\n>>   {\n>>   \tint index_unchanged, originally_empty;\n>>   \n>> @@ -1798,13 +1817,33 @@ static int allow_empty(struct repository *r,\n>>   \t * drop_redundant_commits determine whether the commit should be kept or\n>>   \t * dropped. If neither is specified, halt.\n>>   \t */\n>> -\tindex_unchanged = is_index_unchanged(r);\n>> +\tindex_unchanged = is_index_unchanged(r, amend);\n>>   \tif (index_unchanged < 0)\n>>   \t\treturn index_unchanged;\n>>   \tif (!index_unchanged)\n>>   \t\treturn 0; /* we do not have to say --allow-empty */\n>>   \n>> -\toriginally_empty = is_original_commit_empty(commit);\n>> +\t/*\n>> +\t * When amending (melding a \"fixup!\"/\"squash!\"), the resulting commit\n>> +\t * replaces HEAD, so whether it \"started\" empty or \"became\" empty is\n>> +\t * decided by whether the commit being melded into was itself empty: if\n>> +\t * HEAD had content that the fixup cancelled out, the commit became empty\n>> +\t * and is subject to keep/drop_redundant; if HEAD was already empty, the\n>> +\t * commit started empty and is subject to allow_empty as usual.\n>> +\t */\n>> +\tif (amend) {\n>> +\t\tstruct object_id head_oid;\n>> +\t\tstruct commit *head_commit;\n>> +\n>> +\t\tif (repo_get_oid(r, \"HEAD\", &head_oid))\n>> +\t\t\treturn error(_(\"could not resolve HEAD commit\"));\n>> +\t\thead_commit = lookup_commit_reference(r, &head_oid);\n>> +\t\tif (!head_commit)\n>> +\t\t\treturn -1;\n>> +\t\toriginally_empty = is_original_commit_empty(head_commit);\n>> +\t} else {\n>> +\t\toriginally_empty = is_original_commit_empty(commit);\n>> +\t}\n>>   \tif (originally_empty < 0)\n>>   \t\treturn originally_empty;\n>>   \tif (originally_empty)\n>> @@ -2260,6 +2299,30 @@ static const char *reflog_message(struct replay_opts *opts,\n>>   \treturn buf.buf;\n>>   }\n>>   \n>> +/*\n>> + * A \"fixup!\"/\"squash!\" that melds into HEAD may empty it out.  In that case,\n>> + * with --empty=drop, we want to drop the commit entirely.  Since the commit\n>> + * being amended has already been created (by the preceding \"pick\"), and the\n>> + * index and worktree already match the tree of its parent, dropping it is a\n>> + * matter of moving HEAD back to that parent.\n>> + */\n>> +static int reset_head_to_parent(struct repository *r, struct replay_opts *opts,\n>> +\t\t\t\tstruct object_id *head)\n>> +{\n>> +\tstruct commit *head_commit = lookup_commit_reference(r, head);\n>> +\n>> +\tif (!head_commit || repo_parse_commit(r, head_commit))\n>> +\t\treturn error(_(\"could not parse HEAD commit\"));\n>> +\tif (!head_commit->parents)\n>> +\t\treturn error(_(\"cannot drop the root commit\"));\n>> +\n>> +\treturn refs_update_ref(get_main_ref_store(r),\n>> +\t\t\t       reflog_message(opts, \"fixup\",\n>> +\t\t\t\t\t      \"dropping emptied commit\"),\n>> +\t\t\t       \"HEAD\", &head_commit->parents->item->object.oid,\n>> +\t\t\t       head, 0, UPDATE_REFS_MSG_ON_ERR);\n>> +}\n>> +\n>>   static int do_pick_commit(struct repository *r,\n>>   \t\t\t  struct todo_item *item,\n>>   \t\t\t  struct replay_opts *opts,\n>> @@ -2493,7 +2556,7 @@ static int do_pick_commit(struct repository *r,\n>>   \t}\n>>   \n>>   \tdrop_commit = 0;\n>> -\tallow = allow_empty(r, opts, commit);\n>> +\tallow = allow_empty(r, opts, commit, flags & AMEND_MSG);\n>>   \tif (allow < 0) {\n>>   \t\tres = allow;\n>>   \t\tgoto leave;\n>> @@ -2506,9 +2569,24 @@ static int do_pick_commit(struct repository *r,\n>>   \t\tunlink(git_path_merge_msg(r));\n>>   \t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n>>   \t\t\t\tNULL, REF_NO_DEREF);\n>> -\t\tfprintf(stderr,\n>> -\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n>> -\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n>> +\t\tif (flags & AMEND_MSG) {\n>> +\t\t\t/*\n>> +\t\t\t * The \"fixup!\"/\"squash!\" emptied out the commit it was\n>> +\t\t\t * melded into; that commit was already created by the\n>> +\t\t\t * preceding \"pick\", so drop it by moving HEAD back to\n>> +\t\t\t * its parent.\n>> +\t\t\t */\n>> +\t\t\tres = reset_head_to_parent(r, opts, &head);\n>> +\t\t\tif (res)\n>> +\t\t\t\tgoto leave;\n>> +\t\t\tfprintf(stderr,\n>> +\t\t\t\t_(\"dropping %s %s -- resulting commit is empty\\n\"),\n>> +\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n>> +\t\t} else {\n>> +\t\t\tfprintf(stderr,\n>> +\t\t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n>> +\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n>> +\t\t}\n>>   \t} /* else allow == 0 and there's nothing special to do */\n>>   \tif (!opts->no_commit && !drop_commit) {\n>>   \t\tif (author || command == TODO_REVERT || (flags & AMEND_MSG))\n>> diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\n>> index 5033411a43..508dcc7527 100755\n>> --- a/t/t3415-rebase-autosquash.sh\n>> +++ b/t/t3415-rebase-autosquash.sh\n>> @@ -510,4 +510,68 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n>>   \ttest_commit_message HEAD -m \"something\"\n>>   '\n>>   \n>> +test_expect_success 'fixup! that empties its target is dropped with --empty=drop' '\n>> +\tgit reset --hard base &&\n>> +\ttest_commit --no-tag addX fileX 1 &&\n>> +\ttest_commit --no-tag changeX fileX 2 &&\n>> +\ttest_commit --no-tag later fileW hello &&\n>> +\techo 1 >fileX &&\n>> +\tgit commit -m \"fixup! changeX\" fileX &&\n>> +\n>> +\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n>> +\n>> +\tgit log --format=%s >actual &&\n>> +\t! grep changeX actual &&\n>> +\tgrep addX actual &&\n>> +\tgrep later actual &&\n>> +\techo 1 >expect &&\n>> +\ttest_cmp expect fileX &&\n>> +\techo hello >expect &&\n>> +\ttest_cmp expect fileW\n>> +'\n>> +\n>> +test_expect_success 'fixup! that empties its target is kept with --empty=keep' '\n>> +\tgit reset --hard base &&\n>> +\ttest_commit --no-tag addY fileY 1 &&\n>> +\ttest_commit --no-tag changeY fileY 2 &&\n>> +\techo 1 >fileY &&\n>> +\tgit commit -m \"fixup! changeY\" fileY &&\n>> +\n>> +\tgit rebase -i --autosquash --empty=keep HEAD~3 &&\n>> +\n>> +\tgit log --format=%s >actual &&\n>> +\tgrep changeY actual &&\n>> +\t: \"the retained commit is empty\" &&\n>> +\tgit diff --exit-code HEAD~1 HEAD &&\n>> +\techo 1 >expect &&\n>> +\ttest_cmp expect fileY\n>> +'\n>> +\n>> +test_expect_success 'fixup! that empties its target stops with --empty=stop' '\n>> +\tgit reset --hard base &&\n>> +\ttest_commit --no-tag addZ fileZ 1 &&\n>> +\ttest_commit --no-tag changeZ fileZ 2 &&\n>> +\techo 1 >fileZ &&\n>> +\tgit commit -m \"fixup! changeZ\" fileZ &&\n>> +\n>> +\ttest_when_finished \"git rebase --abort\" &&\n>> +\ttest_must_fail git rebase -i --autosquash --empty=stop HEAD~3\n>> +'\n>> +\n>> +test_expect_success 'squash! that empties its target is dropped with --empty=drop' '\n>> +\tgit reset --hard base &&\n>> +\ttest_commit --no-tag addS fileS 1 &&\n>> +\ttest_commit --no-tag changeS fileS 2 &&\n>> +\techo 1 >fileS &&\n>> +\tgit commit -m \"squash! changeS\" fileS &&\n>> +\n>> +\tgit rebase -i --autosquash --empty=drop HEAD~3 &&\n>> +\n>> +\tgit log --format=%s >actual &&\n>> +\t! grep changeS actual &&\n>> +\tgrep addS actual &&\n>> +\techo 1 >expect &&\n>> +\ttest_cmp expect fileS\n>> +'\n>> +\n>>   test_done\n>> \n>> \n>> \n>> \n\n"},{"id":"547790","messageId":"20260710182937.716304-1-i@yuxuan.ch","threadId":"65966","inReplyTo":"afb76b98-661a-4663-8e8b-fd00572db5ba@gmail.com","subject":"Re: [PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Yuxuan Chen","fromEmail":"i@yuxuan.ch","sentAt":"2026-07-10T18:30:14Z","receivedAt":"2026-07-10T18:30:19Z","isPatch":true,"body":"From: Yuxuan Chen <i@yuxuan.ch>\n\nHi Phillip,\n\nI'm Yuxuan, and I work with Farid at Meta. Thank you for reviewing this patch.\nIt addresses a workflow problem for us, and we appreciate your feedback.\n\nRegarding\n\n> Using an empty commit has a marker has the advantage that applying it cannot\n> create conflicts, so you only have to deal with the conflicts caused by the\n> commit being dropped, not the by fixup not applying cleanly.\n\nI am concerned, however, that representing a `drop!` commit as an empty marker\nwould be semantically unsound. We expect `rebase --autosquash` to drop the\ntarget commit, but until that rebase happens, the repository is not in a state\nwhere we consider the target commit dropped: the target's changes are still\npresent, and the empty marker changes nothing. Therefore, I think a `drop!`\ncommit should contain the inverse of the patch we intend to drop. That way,\nthe repository state reflects the intended removal even before autosquash\nrewrites the history.\n\nI recognize that applying the inverse patch may cause conflicts. However,\nthis is not a new problem; `git revert` has the same issue when the inverse\npatch does not apply cleanly. Such conflicts reflect the actual difficulty of\nundoing the change at that point in the history.\n\nThanks,\nYuxuan\n"},{"id":"547988","messageId":"a8d78707-ef0d-439c-ba0a-52a494967046@gmail.com","threadId":"65966","inReplyTo":"20260710182937.716304-1-i@yuxuan.ch","subject":"Re: [PATCH] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"phillip.wood123@gmail.com","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T13:18:07Z","receivedAt":"2026-07-13T13:18:13Z","isPatch":true,"body":"Hi Yuxuan\n\nOn 10/07/2026 19:30, Yuxuan Chen wrote:\n> \n>> Using an empty commit has a marker has the advantage that applying it cannot\n>> create conflicts, so you only have to deal with the conflicts caused by the\n>> commit being dropped, not the by fixup not applying cleanly.\n> \n> I am concerned, however, that representing a `drop!` commit as an empty marker\n> would be semantically unsound. We expect `rebase --autosquash` to drop the\n> target commit, but until that rebase happens, the repository is not in a state\n> where we consider the target commit dropped: the target's changes are still\n> present, and the empty marker changes nothing. Therefore, I think a `drop!`\n> commit should contain the inverse of the patch we intend to drop. That way,\n> the repository state reflects the intended removal even before autosquash\n> rewrites the history.\n\nThat's a good point. Looking at the gitgitgadget issue tracker [1], \nthere is a suggestion to add a new option to revert that behaves like\n\n     git revert -n <commit> &&\n     git commit -m 'drop! '\"$(git show -s --oneline <commit>)\"\n\nand then \"git rebase --autosquash\" would replace \"pick\" with \"drop\" for \nthe commit we want to drop and drop the \"drop!\" commit as well. That \navoids conflicts when dropping the commit and means anything built on \ntop of the \"drop!\" commit before the rebase does not see the changes in \nthe commit that we want to drop because it has been reverted. That seems \nto be the best of both worlds.\n\n> I recognize that applying the inverse patch may cause conflicts. However,\n> this is not a new problem; `git revert` has the same issue when the inverse\n> patch does not apply cleanly. Such conflicts reflect the actual difficulty of\n> undoing the change at that point in the history.\nI agree conflicts are a fact of life when rebasing, but I think it is \nworth avoiding them where we can.\n\nThanks\n\nPhillip\n\n[1] https://github.com/gitgitgadget/git/issues/259\n"},{"id":"551384","messageId":"20260827-fz-autosquash-empty-v4-1-f98ffd575780@gmail.com","threadId":"65966","inReplyTo":"20260709-fz-autosquash-empty-v1-1-84cb494c3613@gmail.com","subject":"[PATCH v4] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Farid Zakaria","fromEmail":"farid.m.zakaria@gmail.com","sentAt":"2026-08-27T18:19:02Z","receivedAt":"2026-08-27T18:19:31Z","isPatch":true,"body":"When \"git rebase --autosquash\" squashes a \"fixup!\" or \"squash!\" commit\ninto its target, the result can be a commit that no longer changes\nanything relative to its parent, for example when the squashed change\nreverts the target.  Rather than dropping or keeping that commit, the\nrebase stops with\n\n\tYou asked to amend the most recent commit, but doing so would\n\tmake it empty. ...\n\nand \"--empty\" has no effect on it.  This makes backing a change out of a\nseries awkward: reverting a commit as a \"fixup!\" and running \"git rebase\n--autosquash --empty=drop\" ought to remove both the commit and its\nrevert, but it halts instead.\n\nA \"fixup\" is applied by amending HEAD, so the commit it produces is\nempty when the index matches the tree of HEAD's parent rather than the\ntree of HEAD.  allow_empty() only knows about the latter, so it never\nnotices that the fixup cancelled the commit out and \"git commit --amend\"\nis left to refuse to create the empty commit.\n\nCheck for this case separately and honor \"--empty\" for it, subject to\ntwo restrictions.\n\nFirst, \"--empty\" only governs commits that become empty, so a commit\nthat was picked empty to begin with must be left alone.  To tell the two\napart, record in \"struct replay_ctx\" what the \"pick\" that created the\ncommit at HEAD was, and write it to \"$GIT_DIR/rebase-merge/fixup-target\"\nso that it survives a stop for conflict resolution.  Only a commit\ncreated by a \"pick\" is a candidate: when the todo list has been edited\nso that a chain starts after \"reset\", \"exec\" or \"break\", we do not know\nhow the commit at HEAD came to be and keep it.\n\nSecond, only the last fixup of a chain may drop the commit.  Were an\nearlier one to drop it, the fixups still to come would be squashed into\nthe previous commit instead, so a commit emptied mid-chain is kept --\nempty for the time being -- and the decision is deferred to the end of\nthe chain.\n\nWith \"--empty=drop\" the emptied commit has already been created by the\n\"pick\", so drop it by moving HEAD back to its parent and report the new\nPICK_RESULT_DROPPED_HEAD, so that neither that commit nor any of the\nfixups squashed into it is recorded as rewritten and the post-rewrite\nmachinery has nothing to report.  A \"label\" or \"update-ref\" that follows\nthen sees HEAD at the parent.\n\nA conflicted fixup that the user resolves by undoing the commit it is\nbeing squashed into leaves the same empty commit behind, so give\ncommit_staged_changes() the same treatment.\n\nSigned-off-by: Farid Zakaria <farid.m.zakaria@gmail.com>\n---\nChanges in v4 (thanks again to Phillip Wood's review):\n- Rebased onto 'master' now that pw/rebase-drop-notes-with-commit has\n  graduated; the drop is reported with a new member of that topic's\n  \"enum pick_result\" instead of an out-parameter.\n- Only a commit that *becomes* empty is dropped: whether the \"pick\" that\n  created it was empty is now remembered in \"struct replay_ctx\" and\n  written to \"$GIT_DIR/rebase-merge/fixup-target\" so it survives a stop\n  for conflict resolution.  A commit picked empty is left alone.\n- Only the final fixup of a chain may drop the commit.  One that empties\n  it mid-chain keeps it, empty for the time being, so that the remaining\n  fixups still land on it rather than on the previous commit.\n- A chain that is not preceded by a \"pick\" -- because the todo list was\n  edited to start it with \"reset\", \"exec\" or \"break\" -- never drops HEAD.\n- commit_staged_changes() honors \"--empty=drop\" as well, for a\n  conflicted fixup that the user resolves by undoing its target.\n- allow_empty() is left in place rather than being bypassed, so a fixup\n  whose own contents are already upstream is still reported.\n- Dropped the tests that expected an already-empty commit to be dropped;\n  added tests for the mid-chain, no-preceding-pick and conflict cases,\n  and moved the post-rewrite check to t5407 next to the one added by\n  pw/rebase-drop-notes-with-commit.\n- Reworded the documentation to talk about squashing rather than\n  melding, matching the rest of git-rebase(1).\n- Link to v3: https://lore.kernel.org/r/20260711-fz-autosquash-empty-v3-1-d227b63eb511@gmail.com\n\nsequencer: let autosquash drop a commit it empties out\n\nAt Meta we maintain a fork of LLVM that we regularly rebase onto\nupstream.  A set of internal patches rides on top, and we keep each one\nas a single commit by folding follow-up changes into it with autosquash\n\"fixup!\" commits.  That works well for evolving a patch, but not for\nretiring one: to back an internal patch out today we hand-edit the\ninteractive rebase todo list to delete the commit and its scattered\nfixups, which is fiddly and easy to get wrong.  (The history is rewritten\neither way, so a force-push is still needed; what this avoids is the\nmanual todo surgery.)\n\nIt would be nicer to retire a patch the same way we amend one: commit a\nrevert of it as a \"fixup!\" and let autosquash fold the two together.\nThe net change is empty, so the commit should just drop out of the\nseries.  Today it does not -- the rebase stops instead.\n\nFor example, starting from a commit we want to retire:\n\n    $ git log --oneline\n    4d5e6f7 add feature patch\n    9a1b2c3 base\n\n    # revert the feature and mark the revert as a fixup of it\n    $ git revert --no-edit HEAD\n    $ git commit --amend -m \"fixup! add feature patch\"\n\n    $ git rebase -i --autosquash --empty=drop 9a1b2c3\n    Rebasing (2/2)\n    You asked to amend the most recent commit, but doing so would\n    make it empty. You can repeat your command with --allow-empty [...]\n    Could not apply 8e9f0a1... # fixup! add feature patch\n\nThe \"--empty=drop\" is ignored.  \"--empty\" only governs commits that are\npicked empty, whereas a \"fixup!\" is applied by amending, and the\nemptiness of an amended commit is measured against the wrong parent.  So\nthe rebase falls through to \"git commit --amend\", which refuses to\ncreate an empty commit, and halts.\n\nWith this patch the emptied commit is recognized and handled according\nto \"--empty\", the same as any other commit that becomes empty during a\nrebase:\n\n    $ git rebase -i --autosquash --empty=drop 9a1b2c3\n    Rebasing (2/2)\n    dropping 8e9f0a1... fixup! add feature patch -- squashing it in empties the commit\n    Successfully rebased and updated refs/heads/main.\n\n    $ git log --oneline\n    9a1b2c3 base\n\n\"--empty=keep\" retains it as an empty commit, and \"--empty=stop\" (the\ndefault under \"-i\") halts so the user can decide -- matching how these\noptions already behave for commits that become empty when picked.\n\nAs \"--empty\" only governs commits that *become* empty, a commit that was\npicked empty to begin with is never dropped, and neither is one that a\nfixup empties out in the middle of a chain, since the fixups that follow\nit would then be squashed into the previous commit instead.\n\nChanges in v3:\n * Switch the new tests' assertions from grep to test_grep for better\n   diagnostics (per review).\n * Link to v2: https://lore.kernel.org/r/20260710-fz-autosquash-empty-v2-1-fa1e277e05f8@gmail.com\n\nChanges in v2 (thanks to Phillip Wood's review):\n * An emptied fixup/squash now honors --empty in all cases, including\n   when the commit it was folded into started out empty; v1 kept that\n   case regardless of --empty.\n * On drop, the dropped commit and its fixup are no longer recorded as\n   rewritten, so nothing spurious reaches the post-rewrite machinery.\n * Added tests for the empty-placeholder + fixup cases and for the\n   not-recorded-as-rewritten behavior; adjusted t3415 \"abort last squash\".\n * Link to v1: https://lore.kernel.org/r/20260709-fz-autosquash-empty-v1-1-84cb494c3613@gmail.com\n---\n Documentation/git-rebase.adoc |  11 ++\n sequencer.c                   | 299 +++++++++++++++++++++++++++++++++++++++++-\n t/t3415-rebase-autosquash.sh  | 151 +++++++++++++++++++++\n t/t5407-post-rewrite-hook.sh  |  21 +++\n 4 files changed, 478 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-rebase.adoc b/Documentation/git-rebase.adoc\nindex f6c22d1598..a171d3831a 100644\n--- a/Documentation/git-rebase.adoc\n+++ b/Documentation/git-rebase.adoc\n@@ -282,6 +282,11 @@ by `git log --cherry-mark ...`) are detected and dropped as a\n preliminary step (unless `--reapply-cherry-picks` or `--keep-base` is\n passed).\n +\n+A commit can also become empty when `--autosquash` squashes a `fixup!`\n+or `squash!` commit into it that cancels out all of its changes.  This\n+option governs such a commit as well; it is dropped, kept, or stopped\n+at just like a commit that becomes empty when it is picked.\n++\n See also INCOMPATIBLE OPTIONS below.\n \n --no-keep-empty::\n@@ -591,6 +596,12 @@ changed from `pick` to `squash`, `fixup` or `fixup -C`, respectively, and they\n are moved right after the commit they modify.  The `--interactive` option can\n be used to review and edit the todo list before proceeding.\n +\n+Squashing a `fixup!` or `squash!` commit into its target can cancel out all\n+of the changes of that target, leaving an empty commit behind.  What happens\n+then is governed by the `--empty` option, so a change can be backed out of a\n+series by committing a revert of it as a `fixup!` and letting\n+`--autosquash --empty=drop` remove the two together.\n++\n The recommended way to create commits with squash markers is by using the\n `--squash`, `--fixup`, `--fixup=amend:` or `--fixup=reword:` options of\n linkgit:git-commit[1], which take the target commit as an argument and\ndiff --git a/sequencer.c b/sequencer.c\nindex 65afd100d9..685e822203 100644\n--- a/sequencer.c\n+++ b/sequencer.c\n@@ -210,6 +210,31 @@ static GIT_PATH_FUNC(rebase_path_no_reschedule_failed_exec, \"rebase-merge/no-res\n static GIT_PATH_FUNC(rebase_path_drop_redundant_commits, \"rebase-merge/drop_redundant_commits\")\n static GIT_PATH_FUNC(rebase_path_keep_redundant_commits, \"rebase-merge/keep_redundant_commits\")\n static GIT_PATH_FUNC(rebase_path_trailer, \"rebase-merge/trailer\")\n+/*\n+ * The file that remembers, across a stop for conflict resolution, what\n+ * \"enum fixup_target\" recorded about the commit a chain of fixup and\n+ * squash commands is being applied to.\n+ */\n+static GIT_PATH_FUNC(rebase_path_fixup_target, \"rebase-merge/fixup-target\")\n+\n+/*\n+ * What we know about the commit that the current chain of fixup and squash\n+ * commands is being applied to.  A commit is only dropped when squashing\n+ * the fixups into it empties it out if it was picked with changes of its\n+ * own, so anything but FIXUP_TARGET_PICKED_NONEMPTY keeps it.\n+ */\n+enum fixup_target {\n+\t/*\n+\t * The commit was not created by a \"pick\", either because it was\n+\t * dropped or because the todo list starts the chain with some\n+\t * other command such as \"reset\", \"exec\" or \"break\".\n+\t */\n+\tFIXUP_TARGET_UNKNOWN = 0,\n+\t/* The commit was picked empty, so the fixups did not empty it. */\n+\tFIXUP_TARGET_PICKED_EMPTY,\n+\t/* The commit was picked with changes, so the fixups may empty it. */\n+\tFIXUP_TARGET_PICKED_NONEMPTY\n+};\n \n /*\n  * A 'struct replay_ctx' represents the private state of the sequencer.\n@@ -234,6 +259,11 @@ struct replay_ctx {\n \t * Whether message contains a commit message.\n \t */\n \tunsigned have_message :1;\n+\t/*\n+\t * What the commit that the current chain of fixup and squash\n+\t * commands is being applied to was picked as.\n+\t */\n+\tenum fixup_target fixup_target;\n };\n \n struct replay_ctx* replay_ctx_new(void)\n@@ -587,6 +617,38 @@ static int write_message(const void *buf, size_t len, const char *filename,\n \treturn 0;\n }\n \n+/* The two names that rebase_path_fixup_target() stores. */\n+static const char *const fixup_target_name[] = {\n+\t[FIXUP_TARGET_PICKED_EMPTY] = \"picked-empty\",\n+\t[FIXUP_TARGET_PICKED_NONEMPTY] = \"picked-non-empty\"\n+};\n+\n+/*\n+ * Remember what the commit that the current chain of fixup and squash\n+ * commands is being applied to was picked as, both in the sequencer state\n+ * and on disk so that it survives a stop for conflict resolution.\n+ */\n+static int set_fixup_target(struct replay_opts *opts, enum fixup_target target)\n+{\n+\tstruct replay_ctx *ctx = opts->ctx;\n+\tconst char *name;\n+\n+\tif (ctx->fixup_target == target)\n+\t\treturn 0;\n+\n+\tctx->fixup_target = target;\n+\tif (!is_rebase_i(opts))\n+\t\treturn 0;\n+\n+\tif (target == FIXUP_TARGET_UNKNOWN) {\n+\t\tunlink(rebase_path_fixup_target());\n+\t\treturn 0;\n+\t}\n+\n+\tname = fixup_target_name[target];\n+\treturn write_message(name, strlen(name), rebase_path_fixup_target(), 1);\n+}\n+\n int read_oneliner(struct strbuf *buf,\n \tconst char *path, unsigned flags)\n {\n@@ -1818,6 +1880,41 @@ static int allow_empty(struct repository *r,\n \t\treturn 0;\n }\n \n+/*\n+ * A \"fixup\" or \"squash\" is applied by amending HEAD, so the commit it\n+ * produces is empty when the index matches the tree of HEAD's parent,\n+ * rather than the tree of HEAD itself that is_index_unchanged() looks at.\n+ * Returns 1 if amending HEAD would leave it empty, 0 if not, and negative\n+ * on error.\n+ */\n+static int is_amended_head_empty(struct repository *r)\n+{\n+\tconst struct object_id *parent_tree_oid;\n+\tstruct object_id *cache_tree_oid;\n+\tstruct commit *head;\n+\n+\thead = lookup_commit_reference_by_name(\"HEAD\");\n+\tif (!head || repo_parse_commit(r, head))\n+\t\treturn error(_(\"could not parse HEAD commit\"));\n+\n+\tif (head->parents) {\n+\t\tstruct commit *parent = head->parents->item;\n+\n+\t\tif (repo_parse_commit(r, parent))\n+\t\t\treturn error(_(\"could not parse parent commit %s\"),\n+\t\t\t\t     oid_to_hex(&parent->object.oid));\n+\t\tparent_tree_oid = get_commit_tree_oid(parent);\n+\t} else {\n+\t\tparent_tree_oid = the_hash_algo->empty_tree; /* HEAD is root */\n+\t}\n+\n+\tcache_tree_oid = get_cache_tree_oid(r->index);\n+\tif (!cache_tree_oid)\n+\t\treturn -1;\n+\n+\treturn oideq(cache_tree_oid, parent_tree_oid);\n+}\n+\n static struct {\n \tchar c;\n \tconst char *str;\n@@ -2273,11 +2370,39 @@ static const char *reflog_message(struct replay_opts *opts,\n \treturn buf.buf;\n }\n \n+/*\n+ * Drop the commit at HEAD by moving HEAD back to its parent.  The index and\n+ * the worktree already match the tree of that parent, so nothing else needs\n+ * to be updated.  \"action\" names the command doing the dropping and is only\n+ * used for the reflog message.\n+ */\n+static int drop_head_commit(struct repository *r, struct replay_opts *opts,\n+\t\t\t    const char *action)\n+{\n+\tstruct commit *head = lookup_commit_reference_by_name(\"HEAD\");\n+\n+\tif (!head || repo_parse_commit(r, head))\n+\t\treturn error(_(\"could not parse HEAD commit\"));\n+\tif (!head->parents)\n+\t\treturn error(_(\"cannot drop the root commit\"));\n+\n+\treturn refs_update_ref(get_main_ref_store(r),\n+\t\t\t       reflog_message(opts, action,\n+\t\t\t\t\t      \"dropping emptied commit\"),\n+\t\t\t       \"HEAD\", &head->parents->item->object.oid,\n+\t\t\t       &head->object.oid, 0, UPDATE_REFS_MSG_ON_ERR);\n+}\n+\n enum pick_result {\n \tPICK_RESULT_ERROR = -1,\n \tPICK_RESULT_OK,\n \tPICK_RESULT_CONFLICTS,\n \tPICK_RESULT_DROPPED,\n+\t/*\n+\t * The fixups were squashed into a commit that they emptied out, so\n+\t * that commit was dropped along with them.\n+\t */\n+\tPICK_RESULT_DROPPED_HEAD,\n };\n \n static enum pick_result do_pick_commit(struct repository *r,\n@@ -2293,7 +2418,7 @@ static enum pick_result do_pick_commit(struct repository *r,\n \tconst char *base_label, *next_label, *reflog_action;\n \tchar *author = NULL;\n \tstruct commit_message msg = { NULL, NULL, NULL, NULL };\n-\tint res, unborn = 0, reword = 0, allow, drop_commit = 0;\n+\tint res, unborn = 0, reword = 0, allow, drop_commit = 0, drop_head = 0;\n \tenum todo_command command = item->command;\n \tstruct commit *commit = item->commit;\n \n@@ -2303,6 +2428,20 @@ static enum pick_result do_pick_commit(struct repository *r,\n \telse\n \t\treflog_action = sequencer_reflog_action(opts);\n \n+\t/*\n+\t * Remember whether this commit is picked with changes of its own, as\n+\t * only such a commit is dropped when the fixups that follow it empty\n+\t * it out again.\n+\t */\n+\tif (is_rebase_i(opts) && command == TODO_PICK) {\n+\t\tint empty = is_original_commit_empty(commit);\n+\n+\t\tif (empty < 0 ||\n+\t\t    set_fixup_target(opts, empty ? FIXUP_TARGET_PICKED_EMPTY :\n+\t\t\t\t     FIXUP_TARGET_PICKED_NONEMPTY))\n+\t\t\treturn PICK_RESULT_ERROR;\n+\t}\n+\n \tif (opts->no_commit) {\n \t\t/*\n \t\t * We do not intend to commit immediately.  We just want to\n@@ -2540,7 +2679,51 @@ static enum pick_result do_pick_commit(struct repository *r,\n \t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n \t\t\toid_to_hex(&commit->object.oid), msg.subject);\n \t} /* else allow == 0 and there's nothing special to do */\n-\tif (!opts->no_commit && !drop_commit) {\n+\n+\t/*\n+\t * allow_empty() above only notices a commit that adds nothing to\n+\t * HEAD.  A \"fixup\" or \"squash\" can also cancel out the changes of\n+\t * the commit it is squashed into, which leaves that commit empty\n+\t * instead, so check for that here and honor --empty for it.\n+\t */\n+\tif ((flags & AMEND_MSG) && !drop_commit &&\n+\t    ctx->fixup_target == FIXUP_TARGET_PICKED_NONEMPTY) {\n+\t\tint emptied = is_amended_head_empty(r);\n+\n+\t\tif (emptied < 0) {\n+\t\t\tres = emptied;\n+\t\t\tgoto leave;\n+\t\t}\n+\n+\t\tif (emptied && (!final_fixup || opts->keep_redundant_commits)) {\n+\t\t\t/*\n+\t\t\t * Keep the commit, empty for now, when more fixups\n+\t\t\t * are still to be squashed into it, as dropping it\n+\t\t\t * here would squash them into the previous commit\n+\t\t\t * instead.  Also keep it when --empty=keep asks us to.\n+\t\t\t */\n+\t\t\tflags |= ALLOW_EMPTY;\n+\t\t} else if (emptied && opts->drop_redundant_commits) {\n+\t\t\tunlink(git_path_merge_msg(r));\n+\t\t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n+\t\t\t\t\tNULL, REF_NO_DEREF);\n+\t\t\tres = drop_head_commit(r, opts,\n+\t\t\t\t\t       command_to_string(command));\n+\t\t\tif (res)\n+\t\t\t\tgoto leave;\n+\t\t\tdrop_head = 1;\n+\t\t\tfprintf(stderr,\n+\t\t\t\t_(\"dropping %s %s -- squashing it in empties the commit\\n\"),\n+\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n+\t\t}\n+\t\t/*\n+\t\t * Otherwise --empty=stop is in effect, and \"git commit\n+\t\t * --amend\" below refuses to make the commit empty, which\n+\t\t * halts the rebase.\n+\t\t */\n+\t}\n+\n+\tif (!opts->no_commit && !drop_commit && !drop_head) {\n \t\tif (author || command == TODO_REVERT || (flags & AMEND_MSG))\n \t\t\tres = do_commit(r, msg_file, author, reflog_action,\n \t\t\t\t\topts, flags,\n@@ -2587,6 +2770,8 @@ static enum pick_result do_pick_commit(struct repository *r,\n \t\treturn PICK_RESULT_ERROR;\n \telse if (res > 0)\n \t\treturn PICK_RESULT_CONFLICTS;\n+\telse if (drop_head)\n+\t\treturn PICK_RESULT_DROPPED_HEAD;\n \telse if (drop_commit)\n \t\treturn PICK_RESULT_DROPPED;\n \telse\n@@ -3318,6 +3503,17 @@ static int read_populate_opts(struct replay_opts *opts)\n \t\t}\n \t\tstrbuf_reset(&buf);\n \n+\t\tif (read_oneliner(&buf, rebase_path_fixup_target(),\n+\t\t\t\t  READ_ONELINER_SKIP_IF_EMPTY)) {\n+\t\t\tenum fixup_target target;\n+\n+\t\t\tfor (target = FIXUP_TARGET_PICKED_EMPTY;\n+\t\t\t     target <= FIXUP_TARGET_PICKED_NONEMPTY; target++)\n+\t\t\t\tif (!strcmp(buf.buf, fixup_target_name[target]))\n+\t\t\t\t\tctx->fixup_target = target;\n+\t\t\tstrbuf_reset(&buf);\n+\t\t}\n+\n \t\tif (read_oneliner(&ctx->current_fixups,\n \t\t\t\t  rebase_path_current_fixups(),\n \t\t\t\t  READ_ONELINER_SKIP_IF_EMPTY)) {\n@@ -5057,9 +5253,26 @@ static int pick_one_commit(struct repository *r,\n \t\t\t\t    peek_command(todo_list, 1));\n \t\treturn 0;\n \t} else if (pick_res == PICK_RESULT_DROPPED) {\n+\t\t/*\n+\t\t * When a \"pick\" is dropped HEAD stays where it was, so a\n+\t\t * \"fixup\" that follows would be squashed into a commit we\n+\t\t * know nothing about.  A dropped \"fixup\" on the other hand\n+\t\t * leaves the commit it targets untouched.\n+\t\t */\n+\t\tif (!is_fixup(item->command))\n+\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n \t\tif (is_final_fixup(todo_list))\n \t\t\tflush_rewritten_pending();\n \t\treturn 0;\n+\t} else if (pick_res == PICK_RESULT_DROPPED_HEAD) {\n+\t\t/*\n+\t\t * The commit the fixups were squashed into is gone, so\n+\t\t * neither it nor any of them were rewritten and there is\n+\t\t * nothing left for the post-rewrite machinery to report.\n+\t\t */\n+\t\tunlink(rebase_path_rewritten_pending());\n+\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n+\t\treturn 0;\n \t} else if (pick_res == PICK_RESULT_CONFLICTS &&\n \t\t   is_fixup(item->command)) {\n \t\treturn error_failed_squash(r, item->commit, opts,\n@@ -5115,6 +5328,16 @@ static int pick_commits(struct repository *r,\n \n \t\tif (save_todo(todo_list, opts, reschedule))\n \t\t\treturn -1;\n+\n+\t\t/*\n+\t\t * Only a commit created by a \"pick\" is dropped when the\n+\t\t * fixups squashed into it empty it out, so forget about the\n+\t\t * last \"pick\" as soon as any other command runs.\n+\t\t */\n+\t\tif (item->command != TODO_PICK && !is_fixup(item->command) &&\n+\t\t    !is_noop(item->command))\n+\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n+\n \t\tif (is_rebase_i(opts)) {\n \t\t\tif (item->command != TODO_COMMENT) {\n \t\t\t\tFILE *f = fopen(rebase_path_msgnum(), \"w\");\n@@ -5520,6 +5743,53 @@ static int commit_staged_changes(struct repository *r,\n \t\t}\n \t}\n \n+\t/*\n+\t * If resolving the conflicts of the last \"fixup\" or \"squash\" of a\n+\t * chain undid the commit they are being squashed into, honor\n+\t * --empty for that commit just as do_pick_commit() does when the\n+\t * chain applies cleanly.\n+\t */\n+\tif ((flags & AMEND_MSG) && opts->drop_redundant_commits &&\n+\t    ctx->fixup_target == FIXUP_TARGET_PICKED_NONEMPTY &&\n+\t    !is_fixup(peek_command(todo_list, 0))) {\n+\t\tint emptied = is_amended_head_empty(r);\n+\n+\t\tif (emptied < 0) {\n+\t\t\tret = emptied;\n+\t\t\tgoto out;\n+\t\t}\n+\t\tif (emptied) {\n+\t\t\tret = drop_head_commit(r, opts, \"continue\");\n+\t\t\tif (ret)\n+\t\t\t\tgoto out;\n+\n+\t\t\t/*\n+\t\t\t * Neither the dropped commit nor the fixups squashed\n+\t\t\t * into it were rewritten, so leave nothing behind for\n+\t\t\t * the post-rewrite machinery to report.\n+\t\t\t */\n+\t\t\tunlink(rebase_path_stopped_sha());\n+\t\t\tunlink(rebase_path_rewritten_pending());\n+\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n+\n+\t\t\tunlink(rebase_path_amend());\n+\t\t\tunlink(rebase_path_fixup_msg());\n+\t\t\tunlink(rebase_path_squash_msg());\n+\t\t\tunlink(git_path_merge_head(r));\n+\t\t\tunlink(git_path_merge_msg(r));\n+\t\t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n+\t\t\t\t\tNULL, REF_NO_DEREF);\n+\t\t\tif (ctx->current_fixup_count > 0) {\n+\t\t\t\tunlink(rebase_path_current_fixups());\n+\t\t\t\tstrbuf_reset(&ctx->current_fixups);\n+\t\t\t\tctx->current_fixup_count = 0;\n+\t\t\t}\n+\n+\t\t\tret = 0;\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n \tif (run_git_commit(final_fixup ? NULL : rebase_path_message(),\n \t\t\t   reflog_action, opts, flags)) {\n \t\tret = error(_(\"could not commit staged changes.\"));\n@@ -6488,6 +6758,7 @@ int todo_list_write_to_file(struct repository *r, struct todo_list *todo_list,\n \n /* skip picking commits whose parents are unchanged */\n static int skip_unnecessary_picks(struct repository *r,\n+\t\t\t\t  struct replay_opts *opts,\n \t\t\t\t  struct todo_list *todo_list,\n \t\t\t\t  struct object_id *base_oid)\n {\n@@ -6527,8 +6798,28 @@ static int skip_unnecessary_picks(struct repository *r,\n \t\ttodo_list->current = 0;\n \t\ttodo_list->done_nr += i;\n \n-\t\tif (is_fixup(peek_command(todo_list, 0)))\n+\t\tif (is_fixup(peek_command(todo_list, 0))) {\n+\t\t\t/*\n+\t\t\t * The picks that were skipped never reach\n+\t\t\t * do_pick_commit(), so record here what the last of\n+\t\t\t * them left at HEAD for the fixups that follow it.\n+\t\t\t */\n+\t\t\tstruct commit *base = lookup_commit_reference(r,\n+\t\t\t\t\t\t\t\t      base_oid);\n+\t\t\tint empty;\n+\n+\t\t\tif (!base)\n+\t\t\t\treturn error(_(\"could not parse commit '%s'\"),\n+\t\t\t\t\t     oid_to_hex(base_oid));\n+\t\t\tempty = is_original_commit_empty(base);\n+\t\t\tif (empty < 0 ||\n+\t\t\t    set_fixup_target(opts,\n+\t\t\t\t\t     empty ? FIXUP_TARGET_PICKED_EMPTY :\n+\t\t\t\t\t     FIXUP_TARGET_PICKED_NONEMPTY))\n+\t\t\t\treturn -1;\n+\n \t\t\trecord_in_rewritten(base_oid, peek_command(todo_list, 0));\n+\t\t}\n \t}\n \n \treturn 0;\n@@ -6727,7 +7018,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla\n \t\tBUG(\"invalid todo list after expanding IDs:\\n%s\",\n \t\t    new_todo.buf.buf);\n \n-\tif (opts->allow_ff && skip_unnecessary_picks(r, &new_todo, &oid)) {\n+\tif (opts->allow_ff && skip_unnecessary_picks(r, opts, &new_todo, &oid)) {\n \t\ttodo_list_release(&new_todo);\n \t\treturn error(_(\"could not skip unnecessary pick commands\"));\n \t}\ndiff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\nindex 07a5a11678..06b501b2af 100755\n--- a/t/t3415-rebase-autosquash.sh\n+++ b/t/t3415-rebase-autosquash.sh\n@@ -510,4 +510,155 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n \ttest_commit_message HEAD -m \"something\"\n '\n \n+test_expect_success 'fixup! that empties its target is dropped with --empty=drop' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addX fileX 1 &&\n+\ttest_commit --no-tag changeX fileX 2 &&\n+\ttest_commit --no-tag later fileW hello &&\n+\techo 1 >fileX &&\n+\tgit commit -m \"fixup! changeX\" fileX &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n+\n+\tgit log --format=%s >actual &&\n+\ttest_grep ! changeX actual &&\n+\ttest_grep addX actual &&\n+\ttest_grep later actual &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileX &&\n+\techo hello >expect &&\n+\ttest_cmp expect fileW\n+'\n+\n+test_expect_success 'fixup! that empties its target is kept with --empty=keep' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addY fileY 1 &&\n+\ttest_commit --no-tag changeY fileY 2 &&\n+\techo 1 >fileY &&\n+\tgit commit -m \"fixup! changeY\" fileY &&\n+\n+\tgit rebase -i --autosquash --empty=keep HEAD~3 &&\n+\n+\tgit log --format=%s >actual &&\n+\ttest_grep changeY actual &&\n+\t: \"the commit that was kept is empty\" &&\n+\tgit diff --exit-code HEAD~1 HEAD &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileY\n+'\n+\n+test_expect_success 'fixup! that empties its target halts by default' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addZ fileZ 1 &&\n+\ttest_commit --no-tag changeZ fileZ 2 &&\n+\techo 1 >fileZ &&\n+\tgit commit -m \"fixup! changeZ\" fileZ &&\n+\n+\ttest_when_finished \"git rebase --abort\" &&\n+\ttest_must_fail git rebase -i --autosquash HEAD~3\n+'\n+\n+test_expect_success 'squash! that empties its target is dropped with --empty=drop' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addS fileS 1 &&\n+\ttest_commit --no-tag changeS fileS 2 &&\n+\techo 1 >fileS &&\n+\tgit commit -m \"squash! changeS\" fileS &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~3 &&\n+\n+\tgit log --format=%s >actual &&\n+\ttest_grep ! changeS actual &&\n+\ttest_grep addS actual &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileS\n+'\n+\n+test_expect_success 'a target emptied in the middle of a chain is not dropped' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addM fileM 1 &&\n+\ttest_commit --no-tag changeM fileM 2 &&\n+\techo 1 >fileM &&\n+\tgit commit -m \"fixup! changeM\" fileM &&\n+\ttest_commit --no-tag \"fixup! changeM\" fileN later &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n+\n+\t: \"the second fixup! refills the commit the first one emptied\" &&\n+\tgit log --format=%s >actual &&\n+\ttest_grep changeM actual &&\n+\techo 1 >expect &&\n+\ttest_cmp expect fileM &&\n+\techo later >expect &&\n+\ttest_cmp expect fileN\n+'\n+\n+test_expect_success 'a commit picked empty is kept when a fixup! leaves it empty' '\n+\tgit reset --hard base &&\n+\tgit commit --allow-empty -m placeholder &&\n+\tgit commit --allow-empty -m \"fixup! placeholder\" &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~2 &&\n+\n+\t: \"--empty only governs commits that become empty\" &&\n+\tgit log --format=%s >actual &&\n+\ttest_grep placeholder actual &&\n+\tgit diff --exit-code HEAD~1 HEAD\n+'\n+\n+test_expect_success 'fixup! filling in an empty commit keeps a non-empty commit' '\n+\tgit reset --hard base &&\n+\tgit commit --allow-empty -m placeholder &&\n+\ttest_commit --no-tag \"fixup! placeholder\" fileP content &&\n+\n+\tgit rebase -i --autosquash --empty=drop HEAD~2 &&\n+\n+\tgit log --format=%s >actual &&\n+\ttest_grep placeholder actual &&\n+\techo content >expect &&\n+\ttest_cmp expect fileP &&\n+\ttest_must_fail git diff --exit-code HEAD~1 HEAD\n+'\n+\n+test_expect_success 'a fixup! not preceded by a pick does not drop its target' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addQ fileQ 1 &&\n+\ttest_commit --no-tag changeQ fileQ 2 &&\n+\techo 1 >fileQ &&\n+\tgit commit -m \"fixup! changeQ\" fileQ &&\n+\n+\t: \"an exec between the pick and the fixup hides what was picked\" &&\n+\ttest_when_finished \"git rebase --abort\" &&\n+\tset_fake_editor &&\n+\ttest_must_fail env FAKE_LINES=\"1 2 exec_true 3\" \\\n+\t\tgit rebase -i --autosquash --empty=drop HEAD~3\n+'\n+\n+test_expect_success 'resolving a conflicted fixup! by emptying its target drops it' '\n+\tgit reset --hard base &&\n+\ttest_commit --no-tag addC fileC 1 &&\n+\ttest_commit --no-tag changeC fileC 2 &&\n+\ttest_commit --no-tag otherC fileC 3 &&\n+\techo 1 >fileC &&\n+\tgit commit -m \"fixup! changeC\" fileC &&\n+\n+\ttest_when_finished \"test_might_fail git rebase --abort\" &&\n+\t: \"the fixup! is built on otherC, so it conflicts with changeC\" &&\n+\ttest_must_fail git rebase -i --autosquash --empty=drop HEAD~4 &&\n+\n+\t: \"resolve it by undoing changeC, which leaves changeC empty\" &&\n+\techo 1 >fileC &&\n+\tgit add fileC &&\n+\t: \"changeC is now gone, so otherC conflicts with addC\" &&\n+\ttest_must_fail git rebase --continue &&\n+\techo 3 >fileC &&\n+\tgit add fileC &&\n+\tgit rebase --continue &&\n+\n+\tgit log --format=%s >actual &&\n+\ttest_grep ! changeC actual &&\n+\ttest_grep addC actual &&\n+\ttest_grep otherC actual\n+'\n+\n test_done\ndiff --git a/t/t5407-post-rewrite-hook.sh b/t/t5407-post-rewrite-hook.sh\nindex ca8a10fbb1..a35671fea2 100755\n--- a/t/t5407-post-rewrite-hook.sh\n+++ b/t/t5407-post-rewrite-hook.sh\n@@ -333,4 +333,25 @@ test_expect_success 'rebase with commits that become empty' '\n \tverify_hook_input\n '\n \n+test_expect_success 'rebase drops a commit that its fixup empties' '\n+\tgit checkout -b empty-fixup A &&\n+\ttest_commit --no-tag P1 file1 one &&\n+\ttest_commit --no-tag P2 file1 two &&\n+\ttest_commit --no-tag P3 file2 three &&\n+\techo one >file1 &&\n+\tgit commit -m \"fixup! P2\" file1 &&\n+\tp1=$(git rev-parse HEAD~3) &&\n+\tp3=$(git rev-parse HEAD~1) &&\n+\tclear_hook_input &&\n+\n+\tgit rebase -i --autosquash --empty=drop B &&\n+\n+\techo rebase >expected.args &&\n+\tcat >expected.data <<-EOF &&\n+\t$p1 $(git rev-parse HEAD~1)\n+\t$p3 $(git rev-parse HEAD)\n+\tEOF\n+\tverify_hook_input\n+'\n+\n test_done\n\n---\nbase-commit: f78ce2f7b6df702f93d40b85d6bda92a3f65da79\nchange-id: 20260709-fz-autosquash-empty-b6692cf36c60\n\n"},{"id":"551573","messageId":"511300fe-112d-4f20-bd3f-e401e68c4a27@gmail.com","threadId":"65966","inReplyTo":"20260827-fz-autosquash-empty-v4-1-f98ffd575780@gmail.com","subject":"Re: [PATCH v4] sequencer: honor --empty when a fixup!/squash! empties its target","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-31T16:09:38Z","receivedAt":"2026-08-31T16:10:02Z","isPatch":true,"body":"Hi Farid\n\nOn 27/08/2026 19:19, Farid Zakaria wrote:\n> When \"git rebase --autosquash\" squashes a \"fixup!\" or \"squash!\" commit\n> into its target, the result can be a commit that no longer changes\n> anything relative to its parent, for example when the squashed change\n> reverts the target.  Rather than dropping or keeping that commit, the\n> rebase stops with\n> \n> \tYou asked to amend the most recent commit, but doing so would\n> \tmake it empty. ...\n> \n> and \"--empty\" has no effect on it.  This makes backing a change out of a\n> series awkward: reverting a commit as a \"fixup!\" and running \"git rebase\n> --autosquash --empty=drop\" ought to remove both the commit and its\n> revert, but it halts instead.\n> \n> A \"fixup\" is applied by amending HEAD, so the commit it produces is\n> empty when the index matches the tree of HEAD's parent rather than the\n> tree of HEAD.  allow_empty() only knows about the latter, so it never\n> notices that the fixup cancelled the commit out and \"git commit --amend\"\n> is left to refuse to create the empty commit.\n> \n> Check for this case separately and honor \"--empty\" for it, subject to\n> two restrictions.\n> \n> First, \"--empty\" only governs commits that become empty, so a commit\n> that was picked empty to begin with must be left alone.  To tell the two\n> apart, record in \"struct replay_ctx\" what the \"pick\" that created the\n> commit at HEAD was, and write it to \"$GIT_DIR/rebase-merge/fixup-target\"\n> so that it survives a stop for conflict resolution.  Only a commit\n> created by a \"pick\" is a candidate: when the todo list has been edited\n> so that a chain starts after \"reset\", \"exec\" or \"break\", we do not know\n> how the commit at HEAD came to be and keep it.\n> \n> Second, only the last fixup of a chain may drop the commit.  Were an\n> earlier one to drop it, the fixups still to come would be squashed into\n> the previous commit instead, so a commit emptied mid-chain is kept --\n> empty for the time being -- and the decision is deferred to the end of\n> the chain.\n> \n> With \"--empty=drop\" the emptied commit has already been created by the\n> \"pick\", so drop it by moving HEAD back to its parent and report the new\n> PICK_RESULT_DROPPED_HEAD, so that neither that commit nor any of the\n> fixups squashed into it is recorded as rewritten and the post-rewrite\n> machinery has nothing to report.  A \"label\" or \"update-ref\" that follows\n> then sees HEAD at the parent.\n> \n> A conflicted fixup that the user resolves by undoing the commit it is\n> being squashed into leaves the same empty commit behind, so give\n> commit_staged_changes() the same treatment.\n\nThis all sounds good - lets look at the implementation ...\n\n> Signed-off-by: Farid Zakaria <farid.m.zakaria@gmail.com>\n> @@ -2303,6 +2428,20 @@ static enum pick_result do_pick_commit(struct repository *r,\n>   \telse\n>   \t\treflog_action = sequencer_reflog_action(opts);\n>   \n> +\t/*\n> +\t * Remember whether this commit is picked with changes of its own, as\n> +\t * only such a commit is dropped when the fixups that follow it empty\n> +\t * it out again.\n> +\t */\n> +\tif (is_rebase_i(opts) && command == TODO_PICK) {\n> +\t\tint empty = is_original_commit_empty(commit);\n\nSo we only support dropping a \"pick\" that becomes empty, not a \"reword\"\nor \"edit\". It would be strange to reword or edit a commit that is\ndestined to be dropped so that makes sense.\n\n> +\n> +\t\tif (empty < 0 ||\n> +\t\t    set_fixup_target(opts, empty ? FIXUP_TARGET_PICKED_EMPTY :\n> +\t\t\t\t     FIXUP_TARGET_PICKED_NONEMPTY))\n> +\t\t\treturn PICK_RESULT_ERROR;\n> +\t}\n> +\n>   \tif (opts->no_commit) {\n>   \t\t/*\n>   \t\t * We do not intend to commit immediately.  We just want to\n> @@ -2540,7 +2679,51 @@ static enum pick_result do_pick_commit(struct repository *r,\n>   \t\t\t_(\"dropping %s %s -- patch contents already upstream\\n\"),\n>   \t\t\toid_to_hex(&commit->object.oid), msg.subject);\n>   \t} /* else allow == 0 and there's nothing special to do */\n> -\tif (!opts->no_commit && !drop_commit) {\n> +\n> +\t/*\n> +\t * allow_empty() above only notices a commit that adds nothing to\n> +\t * HEAD.  A \"fixup\" or \"squash\" can also cancel out the changes of\n> +\t * the commit it is squashed into, which leaves that commit empty\n> +\t * instead, so check for that here and honor --empty for it.\n> +\t */\n> +\tif ((flags & AMEND_MSG) && !drop_commit &&\n\nIf we have a chain of fixups that looks like\n\n\tpick C\n\tfixup revert-C\n\tfixup becomes-empty\n\nThe \"!drop_commit\" above means that we wont drop \"C\" even though\nsquashing \"revert-C\" made it empty because \"drop_commit == 1\" after\n\"fixup becomes-empty\".\n\n> -\tif (!opts->no_commit && !drop_commit) {\n> +\n> +\t/*\n> +\t * allow_empty() above only notices a commit that adds nothing to\n> +\t * HEAD.  A \"fixup\" or \"squash\" can also cancel out the changes of\n> +\t * the commit it is squashed into, which leaves that commit empty\n> +\t * instead, so check for that here and honor --empty for it.\n> +\t */\n> +\tif ((flags & AMEND_MSG) && !drop_commit &&\n> +\t    ctx->fixup_target == FIXUP_TARGET_PICKED_NONEMPTY) {\n> +\t\tint emptied = is_amended_head_empty(r);\n> +\n> +\t\tif (emptied < 0) {\n> +\t\t\tres = emptied;\n> +\t\t\tgoto leave;\n> +\t\t}\n> +\n> +\t\tif (emptied && (!final_fixup || opts->keep_redundant_commits)) {\n\nI'm not sure we need to check \"emptied\" here it should be fine to set\nALLOW_EMPTY unconditionally. That would allow us to move the call to\nis_amended_head_empty() into the conditional block below, so we only\ncall it on the final fixup when dropping empty commits.\n\n> +\t\t\t/*\n> +\t\t\t * Keep the commit, empty for now, when more fixups\n> +\t\t\t * are still to be squashed into it, as dropping it\n> +\t\t\t * here would squash them into the previous commit\n> +\t\t\t * instead.  Also keep it when --empty=keep asks us to.\n> +\t\t\t */\n> +\t\t\tflags |= ALLOW_EMPTY;\n\nWith a chain of fixups that looks like\n\n\tpick C\n\tfixup becomes-empty\n\nand --empty=stop we wont stop because we've added ALLOW_EMPTY to flags.\nHowever, because we're amending a non-empty commit it turns out the\nexisting code doesn't stop either, as it sees an non-empty commit when\nit amends HEAD, so we're not making things any worse. With\n\n\tpick empty\n\tfixup becomes-empty\n\nthen the behavior is unchanged and we do stop. That inconsistency is\nsomething we should fix but not as part of this series.\n\n> +\t\t} else if (emptied && opts->drop_redundant_commits) {\n> +\t\t\tunlink(git_path_merge_msg(r));\n> +\t\t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n> +\t\t\t\t\tNULL, REF_NO_DEREF);\n> +\t\t\tres = drop_head_commit(r, opts,\n> +\t\t\t\t\t       command_to_string(command));\n> +\t\t\tif (res)\n> +\t\t\t\tgoto leave;\n> +\t\t\tdrop_head = 1;\n> +\t\t\tfprintf(stderr,\n> +\t\t\t\t_(\"dropping %s %s -- squashing it in empties the commit\\n\"),\n> +\t\t\t\toid_to_hex(&commit->object.oid), msg.subject);\n> +\t\t}\n> +\t\t/*\n> +\t\t * Otherwise --empty=stop is in effect, and \"git commit\n> +\t\t * --amend\" below refuses to make the commit empty, which\n> +\t\t * halts the rebase.\n> +\t\t */\n> +\t}\n> +\n> +\tif (!opts->no_commit && !drop_commit && !drop_head) {\n>  \t\tif (author || command == TODO_REVERT || (flags & AMEND_MSG))\n\n> @@ -5057,9 +5253,26 @@ static int pick_one_commit(struct repository *r,\n>   \t\t\t\t    peek_command(todo_list, 1));\n>   \t\treturn 0;\n>   \t} else if (pick_res == PICK_RESULT_DROPPED) {\n> +\t\t/*\n> +\t\t * When a \"pick\" is dropped HEAD stays where it was, so a\n> +\t\t * \"fixup\" that follows would be squashed into a commit we\n> +\t\t * know nothing about. \n\nDropping a commit and then squashing fixups into the previous pick is a\nbug which we can fix using the changes in this patch. We should not drop\na pick that's followed by fixups, instead we should wait to the end of\nthe fixup chain to see if it is still empty. That doesn't necessarily\nneed to be part of this series though.\n\n> + A dropped \"fixup\" on the other hand\n> +\t\t * leaves the commit it targets untouched.\n> +\t\t */\n> +\t\tif (!is_fixup(item->command))\n> +\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n>   \t\tif (is_final_fixup(todo_list))\n>   \t\t\tflush_rewritten_pending();\n>   \t\treturn 0;\n> +\t} else if (pick_res == PICK_RESULT_DROPPED_HEAD) {\n> +\t\t/*\n> +\t\t * The commit the fixups were squashed into is gone, so\n> +\t\t * neither it nor any of them were rewritten and there is\n> +\t\t * nothing left for the post-rewrite machinery to report.\n> +\t\t */\n> +\t\tunlink(rebase_path_rewritten_pending());\n> +\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n\nI guess this doesn't do any harm, but do we actually need it? We know\nthere are no more fixups in the chain so the next command will call\nset_fixup_target() anyway.\n\n> +\t\treturn 0;\n>   \t} else if (pick_res == PICK_RESULT_CONFLICTS &&\n>   \t\t   is_fixup(item->command)) {\n>   \t\treturn error_failed_squash(r, item->commit, opts,\n> @@ -5115,6 +5328,16 @@ static int pick_commits(struct repository *r,\n>   \n>   \t\tif (save_todo(todo_list, opts, reschedule))\n>   \t\t\treturn -1;\n> +\n> +\t\t/*\n> +\t\t * Only a commit created by a \"pick\" is dropped when the\n> +\t\t * fixups squashed into it empty it out, so forget about the\n> +\t\t * last \"pick\" as soon as any other command runs.\n> +\t\t */\n> +\t\tif (item->command != TODO_PICK && !is_fixup(item->command) &&\n> +\t\t    !is_noop(item->command))\n> +\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n> +\n\nIt is a bit unfortunate that this is separated from setting the fixup\ntarget for a \"pick\" command, it would be easier to follow if we did that\nhere rather than in do_pick_commit() and moved this below the next\nline to give\n\n>   \t\tif (is_rebase_i(opts)) {\n\n\tif (item->command == TODO_PICK) {\n\t\tint empty = is_original_commit_empty(commit);\n\n\t\tif (empty < 0 ||\n\t\t    set_fixup_target(opts, empty ? FIXUP_TARGET_PICKED_EMPTY :\n\t\t\t\t     FIXUP_TARGET_PICKED_NONEMPTY))\n\t\t\treturn -1;\n\t} else if (item->command != noop && !is_fixup(item->command)) {\n\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n\t}\n\n>   \t\t\tif (item->command != TODO_COMMENT) {\n>   \t\t\t\tFILE *f = fopen(rebase_path_msgnum(), \"w\");\n> @@ -5520,6 +5743,53 @@ static int commit_staged_changes(struct repository *r,\n>   \t\t}\n>   \t}\n>   \n> +\t/*\n> +\t * If resolving the conflicts of the last \"fixup\" or \"squash\" of a\n> +\t * chain undid the commit they are being squashed into, honor\n> +\t * --empty for that commit just as do_pick_commit() does when the\n> +\t * chain applies cleanly.\n> +\t */\n> +\tif ((flags & AMEND_MSG) && opts->drop_redundant_commits &&\n> +\t    ctx->fixup_target == FIXUP_TARGET_PICKED_NONEMPTY &&\n> +\t    !is_fixup(peek_command(todo_list, 0))) {\n> +\t\tint emptied = is_amended_head_empty(r);\n> +\n> +\t\tif (emptied < 0) {\n> +\t\t\tret = emptied;\n> +\t\t\tgoto out;\n> +\t\t}\n> +\t\tif (emptied) {\n> +\t\t\tret = drop_head_commit(r, opts, \"continue\");\n> +\t\t\tif (ret)\n> +\t\t\t\tgoto out;\n> +\n> +\t\t\t/*\n> +\t\t\t * Neither the dropped commit nor the fixups squashed\n> +\t\t\t * into it were rewritten, so leave nothing behind for\n> +\t\t\t * the post-rewrite machinery to report.\n> +\t\t\t */\n> +\t\t\tunlink(rebase_path_stopped_sha());\n> +\t\t\tunlink(rebase_path_rewritten_pending());\n> +\t\t\tset_fixup_target(opts, FIXUP_TARGET_UNKNOWN);\n> +\n> +\t\t\tunlink(rebase_path_amend());\n> +\t\t\tunlink(rebase_path_fixup_msg());\n> +\t\t\tunlink(rebase_path_squash_msg());\n> +\t\t\tunlink(git_path_merge_head(r));\n> +\t\t\tunlink(git_path_merge_msg(r));\n> +\t\t\trefs_delete_ref(get_main_ref_store(r), \"\", \"AUTO_MERGE\",\n> +\t\t\t\t\tNULL, REF_NO_DEREF);\n> +\t\t\tif (ctx->current_fixup_count > 0) {\n> +\t\t\t\tunlink(rebase_path_current_fixups());\n> +\t\t\t\tstrbuf_reset(&ctx->current_fixups);\n> +\t\t\t\tctx->current_fixup_count = 0;\n> +\t\t\t}\n\nThis cleanup looks like it could easily go stale - can we have a single\nplace where we clean up and jump to that? Would a label just below \"if\n(run_git_commit(...))\" do the job? That way we only need to remember to\nclean up the files that \"git commit\" removes here.\n\n> +\t\t\tret = 0;\n> +\t\t\tgoto out;\n> +\t\t}\n> +\t}\n> +\n>   \tif (run_git_commit(final_fixup ? NULL : rebase_path_message(),\n>   \t\t\t   reflog_action, opts, flags)) {\n>   \t\tret = error(_(\"could not commit staged changes.\"));\n\n> @@ -6527,8 +6798,28 @@ static int skip_unnecessary_picks(struct repository *r,\n>   \t\ttodo_list->current = 0;\n>   \t\ttodo_list->done_nr += i;\n>   \n> -\t\tif (is_fixup(peek_command(todo_list, 0)))\n> +\t\tif (is_fixup(peek_command(todo_list, 0))) {\n> +\t\t\t/*\n> +\t\t\t * The picks that were skipped never reach\n> +\t\t\t * do_pick_commit(), so record here what the last of\n> +\t\t\t * them left at HEAD for the fixups that follow it.\n> +\t\t\t */\n> +\t\t\tstruct commit *base = lookup_commit_reference(r,\n> +\t\t\t\t\t\t\t\t      base_oid);\n\nIf we changed the loop above to remember the commit as \"struct commit\n*base_commit\", rather than the object id as \"struct object_id base_oid\"\nwe wouldn't need to lookup the commit again here. The loop above breaks\non the first command that isn't a \"pick\" so the logic here looks sound.\n\n> +\t\t\tint empty;\n> +\n> +\t\t\tif (!base)\n> +\t\t\t\treturn error(_(\"could not parse commit '%s'\"),\n> +\t\t\t\t\t     oid_to_hex(base_oid));\n> +\t\t\tempty = is_original_commit_empty(base);\n> +\t\t\tif (empty < 0 ||\n> +\t\t\t    set_fixup_target(opts,\n> +\t\t\t\t\t     empty ? FIXUP_TARGET_PICKED_EMPTY :\n> +\t\t\t\t\t     FIXUP_TARGET_PICKED_NONEMPTY))\n> +\t\t\t\treturn -1;\n> +\n>   \t\t\trecord_in_rewritten(base_oid, peek_command(todo_list, 0));\n> +\t\t}\n>   \t}\n\n\n> diff --git a/t/t3415-rebase-autosquash.sh b/t/t3415-rebase-autosquash.sh\n> index 07a5a11678..06b501b2af 100755\n> --- a/t/t3415-rebase-autosquash.sh\n> +++ b/t/t3415-rebase-autosquash.sh\n> @@ -510,4 +510,155 @@ test_expect_success 'pick and fixup respect commit.cleanup' '\n>   \ttest_commit_message HEAD -m \"something\"\n>   '\n>   \n> +test_expect_success 'fixup! that empties its target is dropped with --empty=drop' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addX fileX 1 &&\n> +\ttest_commit --no-tag changeX fileX 2 &&\n> +\ttest_commit --no-tag later fileW hello &&\n> +\techo 1 >fileX &&\n> +\tgit commit -m \"fixup! changeX\" fileX &&\n\nI think those two commands are just\n\n\ttest_commit --no-tag \"fixup! changeX\" fileX 1\n\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep ! changeX actual &&\n> +\ttest_grep addX actual &&\n> +\ttest_grep later actual &&\n\nIt would be easier to see what this was checking if we used test_cmp()\nrather than a bunch of test_grep() calls.\n\nThe test coverage looks reasonable, I would add\n\ntest_expect_success 'fixup! that becomes empty, after its target has become empty' '\n\tgit checkout -f first-commit &&\n\ttest_commit two file1 2 &&\n\ttest_commit --no-tag \"fixup! two\" file1 0 &&\n\ttest_commit --no-tag \"fixup! two\" file3 3 &&\n\ttest_commit four file2 4 &&\n\n\tgit rebase --autosquash --empty=drop --reapply-cherry-picks base 2>err &&\n\ttest_grep \"contents already upstream\" err &&\n\ttest_grep \"squashing it in empties the commit\" err &&\n\ttest_commit_message HEAD -m four &&\n\tgit diff --exit-code four HEAD &&\n\ttest_cmp_rev base HEAD~1\n'\n\nto check that we still drop the amended commit when the final fixup\nbecomes empty.\n\nThis is looking good, hopefully with a couple of tweaks it'll be ready\nto be merged.\n\nThanks\n\nPhillip\n\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileX &&\n> +\techo hello >expect &&\n> +\ttest_cmp expect fileW\n> +'\n> +\n> +test_expect_success 'fixup! that empties its target is kept with --empty=keep' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addY fileY 1 &&\n> +\ttest_commit --no-tag changeY fileY 2 &&\n> +\techo 1 >fileY &&\n> +\tgit commit -m \"fixup! changeY\" fileY &&\n> +\n> +\tgit rebase -i --autosquash --empty=keep HEAD~3 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep changeY actual &&\n> +\t: \"the commit that was kept is empty\" &&\n> +\tgit diff --exit-code HEAD~1 HEAD &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileY\n> +'\n> +\n> +test_expect_success 'fixup! that empties its target halts by default' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addZ fileZ 1 &&\n> +\ttest_commit --no-tag changeZ fileZ 2 &&\n> +\techo 1 >fileZ &&\n> +\tgit commit -m \"fixup! changeZ\" fileZ &&\n> +\n> +\ttest_when_finished \"git rebase --abort\" &&\n> +\ttest_must_fail git rebase -i --autosquash HEAD~3\n> +'\n> +\n> +test_expect_success 'squash! that empties its target is dropped with --empty=drop' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addS fileS 1 &&\n> +\ttest_commit --no-tag changeS fileS 2 &&\n> +\techo 1 >fileS &&\n> +\tgit commit -m \"squash! changeS\" fileS &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~3 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep ! changeS actual &&\n> +\ttest_grep addS actual &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileS\n> +'\n> +\n> +test_expect_success 'a target emptied in the middle of a chain is not dropped' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addM fileM 1 &&\n> +\ttest_commit --no-tag changeM fileM 2 &&\n> +\techo 1 >fileM &&\n> +\tgit commit -m \"fixup! changeM\" fileM &&\n> +\ttest_commit --no-tag \"fixup! changeM\" fileN later &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~4 &&\n> +\n> +\t: \"the second fixup! refills the commit the first one emptied\" &&\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep changeM actual &&\n> +\techo 1 >expect &&\n> +\ttest_cmp expect fileM &&\n> +\techo later >expect &&\n> +\ttest_cmp expect fileN\n> +'\n> +\n> +test_expect_success 'a commit picked empty is kept when a fixup! leaves it empty' '\n> +\tgit reset --hard base &&\n> +\tgit commit --allow-empty -m placeholder &&\n> +\tgit commit --allow-empty -m \"fixup! placeholder\" &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~2 &&\n> +\n> +\t: \"--empty only governs commits that become empty\" &&\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep placeholder actual &&\n> +\tgit diff --exit-code HEAD~1 HEAD\n> +'\n> +\n> +test_expect_success 'fixup! filling in an empty commit keeps a non-empty commit' '\n> +\tgit reset --hard base &&\n> +\tgit commit --allow-empty -m placeholder &&\n> +\ttest_commit --no-tag \"fixup! placeholder\" fileP content &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop HEAD~2 &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep placeholder actual &&\n> +\techo content >expect &&\n> +\ttest_cmp expect fileP &&\n> +\ttest_must_fail git diff --exit-code HEAD~1 HEAD\n> +'\n> +\n> +test_expect_success 'a fixup! not preceded by a pick does not drop its target' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addQ fileQ 1 &&\n> +\ttest_commit --no-tag changeQ fileQ 2 &&\n> +\techo 1 >fileQ &&\n> +\tgit commit -m \"fixup! changeQ\" fileQ &&\n> +\n> +\t: \"an exec between the pick and the fixup hides what was picked\" &&\n> +\ttest_when_finished \"git rebase --abort\" &&\n> +\tset_fake_editor &&\n> +\ttest_must_fail env FAKE_LINES=\"1 2 exec_true 3\" \\\n> +\t\tgit rebase -i --autosquash --empty=drop HEAD~3\n> +'\n> +\n> +test_expect_success 'resolving a conflicted fixup! by emptying its target drops it' '\n> +\tgit reset --hard base &&\n> +\ttest_commit --no-tag addC fileC 1 &&\n> +\ttest_commit --no-tag changeC fileC 2 &&\n> +\ttest_commit --no-tag otherC fileC 3 &&\n> +\techo 1 >fileC &&\n> +\tgit commit -m \"fixup! changeC\" fileC &&\n> +\n> +\ttest_when_finished \"test_might_fail git rebase --abort\" &&\n> +\t: \"the fixup! is built on otherC, so it conflicts with changeC\" &&\n> +\ttest_must_fail git rebase -i --autosquash --empty=drop HEAD~4 &&\n> +\n> +\t: \"resolve it by undoing changeC, which leaves changeC empty\" &&\n> +\techo 1 >fileC &&\n> +\tgit add fileC &&\n> +\t: \"changeC is now gone, so otherC conflicts with addC\" &&\n> +\ttest_must_fail git rebase --continue &&\n> +\techo 3 >fileC &&\n> +\tgit add fileC &&\n> +\tgit rebase --continue &&\n> +\n> +\tgit log --format=%s >actual &&\n> +\ttest_grep ! changeC actual &&\n> +\ttest_grep addC actual &&\n> +\ttest_grep otherC actual\n> +'\n> +\n>   test_done\n> diff --git a/t/t5407-post-rewrite-hook.sh b/t/t5407-post-rewrite-hook.sh\n> index ca8a10fbb1..a35671fea2 100755\n> --- a/t/t5407-post-rewrite-hook.sh\n> +++ b/t/t5407-post-rewrite-hook.sh\n> @@ -333,4 +333,25 @@ test_expect_success 'rebase with commits that become empty' '\n>   \tverify_hook_input\n>   '\n>   \n> +test_expect_success 'rebase drops a commit that its fixup empties' '\n> +\tgit checkout -b empty-fixup A &&\n> +\ttest_commit --no-tag P1 file1 one &&\n> +\ttest_commit --no-tag P2 file1 two &&\n> +\ttest_commit --no-tag P3 file2 three &&\n> +\techo one >file1 &&\n> +\tgit commit -m \"fixup! P2\" file1 &&\n> +\tp1=$(git rev-parse HEAD~3) &&\n> +\tp3=$(git rev-parse HEAD~1) &&\n> +\tclear_hook_input &&\n> +\n> +\tgit rebase -i --autosquash --empty=drop B &&\n> +\n> +\techo rebase >expected.args &&\n> +\tcat >expected.data <<-EOF &&\n> +\t$p1 $(git rev-parse HEAD~1)\n> +\t$p3 $(git rev-parse HEAD)\n> +\tEOF\n> +\tverify_hook_input\n> +'\n> +\n>   test_done\n> \n> ---\n> base-commit: f78ce2f7b6df702f93d40b85d6bda92a3f65da79\n> change-id: 20260709-fz-autosquash-empty-b6692cf36c60\n> \n> \n\n"}]}