]> git.ipfire.org Git - thirdparty/git.git/commitdiff
builtin/history: introduce "fixup" subcommand
authorPatrick Steinhardt <ps@pks.im>
Mon, 27 Apr 2026 05:53:53 +0000 (07:53 +0200)
committerJunio C Hamano <gitster@pobox.com>
Mon, 27 Apr 2026 13:20:57 +0000 (22:20 +0900)
The newly introduced git-history(1) command provides functionality to
easily edit commit history while also rebasing dependent branches. The
functionality exposed by this command is still somewhat limited though.

One common use case when editing commit history that is not yet covered
is fixing up a specific commit. Introduce a new subcommand that allows
the user to do exactly that by performing a three-way merge into the
target's commit tree, using HEAD's tree as the merge base. The flow is
thus essentially:

    $ echo changes >file
    $ git add file
    $ git history fixup HEAD~

Like with the other commands, this will automatically rebase dependent
branches, as well. Unlike the other commands though:

  - The command does not work in a bare repository as it interacts with
    the index.

  - The command may run into merge conflicts. If so, the command will
    simply abort.

Especially the second item limits the usefulness of this command a bit.
But there are plans to introduce first-class conflicts into Git, which
will help use cases like this one.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
Documentation/git-history.adoc
builtin/history.c
t/meson.build
t/t3453-history-fixup.sh [new file with mode: 0755]

index 24dc907033b46970f65cc0c7f82b661112d07bfc..2ba812179533b82d19dd1eacf34d8efefca0afd9 100644 (file)
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
 SYNOPSIS
 --------
 [synopsis]
+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
 
@@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
 This command is related to linkgit:git-rebase[1] in that both commands can be
 used to rewrite history. There are a couple of major differences though:
 
-* linkgit:git-history[1] can work in a bare repository as it does not need to
-  touch either the index or the worktree.
+* Most subcommands of linkgit:git-history[1] can work in a bare repository as
+  they do not need to touch either the index or the worktree. The `fixup`
+  subcommand is an exception to this, as it reads staged changes from the index.
 * linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
   current point in time. This may change in the future.
 * linkgit:git-history[1] by default updates all branches that are descendants
@@ -48,11 +50,28 @@ conflicts. This limitation is by design as history rewrites are not intended to
 be stateful operations. The limitation can be lifted once (if) Git learns about
 first-class conflicts.
 
+When using `fixup` with `--empty=drop`, dropping the root commit is not yet
+supported.
+
 COMMANDS
 --------
 
 The following commands are available to rewrite history in different ways:
 
+`fixup <commit>`::
+       Apply the currently staged changes to the specified commit. This is
+       similar in nature to `git commit --fixup=<commit>` followed by `git
+       rebase --autosquash <commit>~`. Changes are applied to the target
+       commit by performing a three-way merge between the HEAD commit, the
+       target commit and the tree generated from staged changes.
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
++
+If applying the staged changes would result in a conflict, the command
+aborts with an error. All branches that are descendants of the original
+commit are updated to point to the rewritten history.
+
 `reword <commit>`::
        Rewrite the commit message of the specified commit. All the other
        details of this commit remain unchanged. This command will spawn an
@@ -87,6 +106,31 @@ OPTIONS
        objects will be written into the repository, so applying these printed
        ref updates is generally safe.
 
+`--reedit-message`::
+       Open an editor to modify the target commit's message.
+
+`--empty=(drop|keep|abort)`::
+       Control what happens when a commit becomes empty as a result of the
+       fixup. This can happen in two situations:
++
+--
+* The fixup target itself becomes empty because the staged changes exactly
+  cancel out all changes introduced by that commit.
+
+* A descendant commit becomes empty during replay because it introduced the
+  same change that was just fixed up into an ancestor.
+--
++
+With `drop` (the default), empty commits are removed from the rewritten
+history. Descendants of a dropped target commit are replayed directly onto
+the target's parent. Note that dropping the root commit is not supported;
+see LIMITATIONS.
++
+With `keep`, empty commits are retained in the rewritten history as-is.
++
+With `abort`, the command stops with an error if any commit would become
+empty.
+
 `--update-refs=(branches|head)`::
        Control which references will be updated by the command, if any. With
        `branches`, all local branches that point to commits which are
@@ -96,6 +140,36 @@ OPTIONS
 EXAMPLES
 --------
 
+Fixup a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline --stat
+abc1234 (HEAD -> main) third
+ third.txt | 1 +
+def5678 second
+ second.txt | 1 +
+ghi9012 first
+ first.txt | 1 +
+
+$ echo "change" >>unrelated.txt
+$ git add unrelated.txt
+$ git history fixup ghi9012
+
+$ git log --oneline --stat
+jkl3456 (HEAD -> main) third
+ third.txt | 1 +
+mno7890 second
+ second.txt | 1 +
+pqr1234 first
+ first.txt     | 1 +
+ unrelated.txt | 1 +
+----------
+
+The staged addition of `unrelated.txt` has been incorporated into the `first`
+commit. All descendant commits have been replayed on top of the rewritten
+history.
+
 Split a commit
 ~~~~~~~~~~~~~~
 
index 549e352c746c871d7b0378cc15d8ffaacbc3ff4f..0fc06fb2045814a5467862ecefcd436528a1b817 100644 (file)
@@ -10,6 +10,7 @@
 #include "gettext.h"
 #include "hex.h"
 #include "lockfile.h"
+#include "merge-ort.h"
 #include "oidmap.h"
 #include "parse-options.h"
 #include "path.h"
@@ -23,6 +24,8 @@
 #include "unpack-trees.h"
 #include "wt-status.h"
 
+#define GIT_HISTORY_FIXUP_USAGE \
+       N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
 #define GIT_HISTORY_REWORD_USAGE \
        N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
 #define GIT_HISTORY_SPLIT_USAGE \
@@ -335,10 +338,13 @@ static int handle_reference_updates(struct rev_info *revs,
                                    struct commit *original,
                                    struct commit *rewritten,
                                    const char *reflog_msg,
-                                   int dry_run)
+                                   int dry_run,
+                                   enum replay_empty_commit_action empty)
 {
        const struct name_decoration *decoration;
-       struct replay_revisions_options opts = { 0 };
+       struct replay_revisions_options opts = {
+               .empty = empty,
+       };
        struct replay_result result = { 0 };
        struct ref_transaction *transaction = NULL;
        struct strbuf err = STRBUF_INIT;
@@ -434,6 +440,236 @@ out:
        return ret;
 }
 
+static int commit_became_empty(struct repository *repo,
+                              struct commit *original,
+                              struct tree *result)
+{
+       struct commit *parent = original->parents ? original->parents->item : NULL;
+       struct object_id parent_tree_oid;
+
+       if (parent) {
+               if (repo_parse_commit(repo, parent))
+                       return error(_("unable to parse parent of %s"),
+                                    oid_to_hex(&original->object.oid));
+
+               parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+       } else {
+               oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+       }
+
+       return oideq(&result->object.oid, &parent_tree_oid);
+}
+
+static int parse_opt_empty(const struct option *opt, const char *arg, int unset)
+{
+       enum replay_empty_commit_action *value = opt->value;
+
+       BUG_ON_OPT_NEG(unset);
+
+       if (!strcmp(arg, "drop"))
+               *value = REPLAY_EMPTY_COMMIT_DROP;
+       else if (!strcmp(arg, "keep"))
+               *value = REPLAY_EMPTY_COMMIT_KEEP;
+       else if (!strcmp(arg, "abort"))
+               *value = REPLAY_EMPTY_COMMIT_ABORT;
+       else
+               die(_("unrecognized '--empty=' action '%s'; "
+                     "valid values are \"drop\", \"keep\", and \"abort\"."), arg);
+
+       return 0;
+}
+
+static int cmd_history_fixup(int argc,
+                            const char **argv,
+                            const char *prefix,
+                            struct repository *repo)
+{
+       const char * const usage[] = {
+               GIT_HISTORY_FIXUP_USAGE,
+               NULL,
+       };
+       enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+       enum ref_action action = REF_ACTION_DEFAULT;
+       enum commit_tree_flags flags = 0;
+       int dry_run = 0;
+       struct option options[] = {
+               OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+                              N_("control which refs should be updated"),
+                              PARSE_OPT_NONEG, parse_ref_action),
+               OPT_BOOL('n', "dry-run", &dry_run,
+                        N_("perform a dry-run without updating any refs")),
+               OPT_BIT(0, "reedit-message", &flags,
+                       N_("open an editor to modify the commit message"),
+                       COMMIT_TREE_EDIT_MESSAGE),
+               OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
+                              N_("how to handle commits that become empty"),
+                              PARSE_OPT_NONEG, parse_opt_empty),
+               OPT_END(),
+       };
+       struct merge_result merge_result = { 0 };
+       struct merge_options merge_opts = { 0 };
+       struct strbuf reflog_msg = STRBUF_INIT;
+       struct commit *head_commit, *original, *rewritten;
+       struct tree *head_tree, *original_tree, *index_tree;
+       struct rev_info revs = { 0 };
+       bool skip_commit = false;
+       int ret;
+
+       argc = parse_options(argc, argv, prefix, options, usage, 0);
+       if (argc != 1) {
+               ret = error(_("command expects a single revision"));
+               goto out;
+       }
+       repo_config(repo, git_default_config, NULL);
+
+       if (action == REF_ACTION_DEFAULT)
+               action = REF_ACTION_BRANCHES;
+
+       if (is_bare_repository()) {
+               ret = error(_("cannot run fixup in a bare repository"));
+               goto out;
+       }
+
+       /* Resolve the original commit, which is the one we want to fix up. */
+       original = lookup_commit_reference_by_name(argv[0]);
+       if (!original) {
+               ret = error(_("commit cannot be found: %s"), argv[0]);
+               goto out;
+       }
+
+       /*
+        * Resolve HEAD so we can use its tree as the merge base: the staged
+        * changes are expressed as a diff from HEAD's tree to the index tree.
+        */
+       head_commit = lookup_commit_reference_by_name("HEAD");
+       if (!head_commit) {
+               ret = error(_("cannot look up HEAD"));
+               goto out;
+       }
+
+       head_tree = repo_get_commit_tree(repo, head_commit);
+       if (!head_tree) {
+               ret = error(_("cannot get tree for HEAD"));
+               goto out;
+       }
+
+       if (repo_read_index(repo) < 0) {
+               ret = error(_("unable to read index"));
+               goto out;
+       }
+
+       if (!repo_index_has_changes(repo, head_tree, NULL)) {
+               ret = error(_("nothing to fixup: no staged changes"));
+               goto out;
+       }
+
+       /*
+        * Write the index as a tree object. This is the "theirs" side of the
+        * three-way merge: it is HEAD's tree with the staged changes applied.
+        */
+       index_tree = write_in_core_index_as_tree(repo, repo->index);
+       if (!index_tree) {
+               ret = error(_("unable to write index as a tree"));
+               goto out;
+       }
+
+       original_tree = repo_get_commit_tree(repo, original);
+       if (!original_tree) {
+               ret = error(_("cannot get tree for commit %s"), argv[0]);
+               goto out;
+       }
+
+       /*
+        * Perform the three-way merge to reapply changes in the index onto the
+        * target commit. This is using basically the same logic as a
+        * cherry-pick, where the base commit is our HEAD, ours is the original
+        * tree and theirs is the index tree.
+        */
+       init_basic_merge_options(&merge_opts, repo);
+       merge_opts.ancestor = "HEAD";
+       merge_opts.branch1 = argv[0];
+       merge_opts.branch2 = "staged";
+       merge_incore_nonrecursive(&merge_opts, head_tree,
+                                 original_tree, index_tree, &merge_result);
+
+       if (merge_result.clean < 0) {
+               ret = error(_("merge failed while applying fixup"));
+               goto out;
+       }
+
+       if (!merge_result.clean) {
+               ret = error(_("fixup would produce conflicts; aborting"));
+               goto out;
+       }
+
+       ret = commit_became_empty(repo, original, merge_result.tree);
+       if (ret < 0)
+               goto out;
+       if (ret > 0) {
+               switch (empty) {
+               case REPLAY_EMPTY_COMMIT_DROP:
+                       /*
+                        * Drop the target commit by replaying its descendants
+                        * directly onto its parent.
+                        */
+                       rewritten = original->parents ? original->parents->item : NULL;
+
+                       /*
+                        * TODO: we don't yet have the ability to drop root
+                        * commits, but there's ultimately no good reason for
+                        * this restriction to exist other than a technical
+                        * limitation.
+                        */
+                       if (!rewritten) {
+                               ret = error(_("cannot drop root commit %s: "
+                                             "it has no parent to replay onto"),
+                                           argv[0]);
+                               goto out;
+                       }
+
+                       skip_commit = true;
+                       break;
+               case REPLAY_EMPTY_COMMIT_KEEP:
+                       /* Proceed and record the empty commit. */
+                       break;
+               case REPLAY_EMPTY_COMMIT_ABORT:
+                       ret = error(_("fixup makes commit %s empty"), argv[0]);
+                       goto out;
+               }
+       }
+
+       ret = setup_revwalk(repo, action, original, &revs);
+       if (ret)
+               goto out;
+
+       if (!skip_commit) {
+               ret = commit_tree_ext(repo, "fixup", original, original->parents,
+                                     &original_tree->object.oid, &merge_result.tree->object.oid,
+                                     &rewritten, flags);
+               if (ret < 0) {
+                       ret = error(_("failed writing fixed-up commit"));
+                       goto out;
+               }
+       }
+
+       strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+       ret = handle_reference_updates(&revs, action, original, rewritten,
+                                      reflog_msg.buf, dry_run, empty);
+       if (ret < 0) {
+               ret = error(_("failed replaying descendants"));
+               goto out;
+       }
+
+       ret = 0;
+
+out:
+       merge_finalize(&merge_opts, &merge_result);
+       strbuf_release(&reflog_msg);
+       release_revisions(&revs);
+       return ret;
+}
+
 static int cmd_history_reword(int argc,
                              const char **argv,
                              const char *prefix,
@@ -487,7 +723,7 @@ static int cmd_history_reword(int argc,
        strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
 
        ret = handle_reference_updates(&revs, action, original, rewritten,
-                                      reflog_msg.buf, dry_run);
+                                      reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
        if (ret < 0) {
                ret = error(_("failed replaying descendants"));
                goto out;
@@ -724,7 +960,7 @@ static int cmd_history_split(int argc,
        strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
 
        ret = handle_reference_updates(&revs, action, original, rewritten,
-                                      reflog_msg.buf, dry_run);
+                                      reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
        if (ret < 0) {
                ret = error(_("failed replaying descendants"));
                goto out;
@@ -745,12 +981,14 @@ int cmd_history(int argc,
                struct repository *repo)
 {
        const char * const usage[] = {
+               GIT_HISTORY_FIXUP_USAGE,
                GIT_HISTORY_REWORD_USAGE,
                GIT_HISTORY_SPLIT_USAGE,
                NULL,
        };
        parse_opt_subcommand_fn *fn = NULL;
        struct option options[] = {
+               OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
                OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
                OPT_SUBCOMMAND("split", &fn, cmd_history_split),
                OPT_END(),
index 7528e5cda5fef0e13c1b8ab5b37bd14393eed8d2..f502ad8ec9771027e753d06087bc5ff64f25d6e1 100644 (file)
@@ -397,6 +397,7 @@ integration_tests = [
   't3450-history.sh',
   't3451-history-reword.sh',
   't3452-history-split.sh',
+  't3453-history-fixup.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
new file mode 100755 (executable)
index 0000000..868298e
--- /dev/null
@@ -0,0 +1,680 @@
+#!/bin/sh
+
+test_description='tests for git-history fixup subcommand'
+
+. ./test-lib.sh
+
+fixup_with_message () {
+       cat >message &&
+       write_script fake-editor.sh <<-\EOF &&
+       cp message "$1"
+       EOF
+       test_set_editor "$(pwd)"/fake-editor.sh &&
+       git history fixup --reedit-message "$@" &&
+       rm fake-editor.sh message
+}
+
+expect_changes () {
+       git log --format="%s" --numstat "$@" >actual.raw &&
+       sed '/^$/d' <actual.raw >actual &&
+       cat >expect &&
+       test_cmp expect actual
+}
+
+test_expect_success 'errors on missing commit argument' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               test_must_fail git history fixup 2>err &&
+               test_grep "command expects a single revision" err
+       )
+'
+
+test_expect_success 'errors on too many arguments' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               test_must_fail git history fixup HEAD HEAD 2>err &&
+               test_grep "command expects a single revision" err
+       )
+'
+
+test_expect_success 'errors on unknown revision' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               test_must_fail git history fixup does-not-exist 2>err &&
+               test_grep "commit cannot be found: does-not-exist" err
+       )
+'
+
+test_expect_success 'errors when nothing is staged' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               test_must_fail git history fixup HEAD 2>err &&
+               test_grep "nothing to fixup: no staged changes" err
+       )
+'
+
+test_expect_success 'errors in a bare repository' '
+       test_when_finished "rm -rf repo repo.git" &&
+       git init repo &&
+       test_commit -C repo initial &&
+       git clone --bare repo repo.git &&
+       test_must_fail git -C repo.git history fixup HEAD 2>err &&
+       test_grep "cannot run fixup in a bare repository" err
+'
+
+test_expect_success 'errors with invalid --empty= value' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       test_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&
+       test_grep "unrecognized.*--empty.*bogus" err
+'
+
+test_expect_success 'can fixup the tip commit' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               echo content >file.txt &&
+               git add file.txt &&
+               git commit -m "add file" &&
+
+               echo fix >>file.txt &&
+               git add file.txt &&
+
+               expect_changes <<-\EOF &&
+               add file
+               1       0       file.txt
+               initial
+               1       0       initial.t
+               EOF
+
+               git symbolic-ref HEAD >branch-expect &&
+               git history fixup HEAD &&
+               git symbolic-ref HEAD >branch-actual &&
+               test_cmp branch-expect branch-actual &&
+
+               expect_changes <<-\EOF &&
+               add file
+               2       0       file.txt
+               initial
+               1       0       initial.t
+               EOF
+
+               # Verify the fix is in the tip commit tree
+               git show HEAD:file.txt >actual &&
+               printf "content\nfix\n" >expect &&
+               test_cmp expect actual &&
+
+               git reflog >reflog &&
+               test_grep "fixup: updating HEAD" reflog
+       )
+'
+
+test_expect_success 'can fixup a commit in the middle of history' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit first &&
+               echo content >file.txt &&
+               git add file.txt &&
+               git commit -m "add file" &&
+               test_commit third &&
+
+               echo fix >>file.txt &&
+               git add file.txt &&
+
+               expect_changes <<-\EOF &&
+               third
+               1       0       third.t
+               add file
+               1       0       file.txt
+               first
+               1       0       first.t
+               EOF
+
+               git history fixup HEAD~ &&
+
+               expect_changes <<-\EOF &&
+               third
+               1       0       third.t
+               add file
+               2       0       file.txt
+               first
+               1       0       first.t
+               EOF
+
+               # Verify the fix landed in the "add file" commit.
+               git show HEAD~:file.txt >actual &&
+               printf "content\nfix\n" >expect &&
+               test_cmp expect actual &&
+
+               # And verify that the replayed commit also has the change.
+               git show HEAD:file.txt >actual &&
+               printf "content\nfix\n" >expect &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success 'can fixup root commit' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               echo initial >root.txt &&
+               git add root.txt &&
+               git commit -m "root" &&
+               test_commit second &&
+
+               expect_changes <<-\EOF &&
+               second
+               1       0       second.t
+               root
+               1       0       root.txt
+               EOF
+
+               echo fix >>root.txt &&
+               git add root.txt &&
+               git history fixup HEAD~ &&
+
+               expect_changes <<-\EOF &&
+               second
+               1       0       second.t
+               root
+               2       0       root.txt
+               EOF
+
+               git show HEAD~:root.txt >actual &&
+               printf "initial\nfix\n" >expect &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success 'preserves commit message and authorship' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               echo content >file.txt &&
+               git add file.txt &&
+               git commit --author="Original <original@example.com>" -m "original message" &&
+
+               echo fix >>file.txt &&
+               git add file.txt &&
+               git history fixup HEAD &&
+
+               # Message preserved
+               git log -1 --format="%s" >actual &&
+               echo "original message" >expect &&
+               test_cmp expect actual &&
+
+               # Authorship preserved
+               git log -1 --format="%an <%ae>" >actual &&
+               echo "Original <original@example.com>" >expect &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success 'updates all descendant branches by default' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+               test_commit base &&
+               git branch branch &&
+               test_commit ours &&
+               git switch branch &&
+               test_commit theirs &&
+               git switch main &&
+
+               echo fix >fix.txt &&
+               git add fix.txt &&
+               git history fixup base &&
+
+               expect_changes --branches <<-\EOF &&
+               theirs
+               1       0       theirs.t
+               ours
+               1       0       ours.t
+               base
+               1       0       base.t
+               1       0       fix.txt
+               EOF
+
+               # Both branches should have the fix in the base
+               git show main~:fix.txt >actual &&
+               echo fix >expect &&
+               test_cmp expect actual &&
+               git show branch~:fix.txt >actual &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success 'can fixup commit on a different branch' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit base &&
+               git branch theirs &&
+               test_commit ours &&
+               git switch theirs &&
+               test_commit theirs &&
+
+               # Stage a change while on "theirs"
+               echo fix >fix.txt &&
+               git add fix.txt &&
+
+               # Ensure that "ours" does not change, as it does not contain
+               # the commit in question.
+               git rev-parse ours >ours-before &&
+               git history fixup theirs &&
+               git rev-parse ours >ours-after &&
+               test_cmp ours-before ours-after &&
+
+               git show HEAD:fix.txt >actual &&
+               echo fix >expect &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success '--dry-run prints ref updates without modifying repo' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+               test_commit base &&
+               git branch branch &&
+               test_commit main-tip &&
+               git switch branch &&
+               test_commit branch-tip &&
+               git switch main &&
+
+               echo fix >fix.txt &&
+               git add fix.txt &&
+
+               git refs list >refs-before &&
+               git history fixup --dry-run base >updates &&
+               git refs list >refs-after &&
+               test_cmp refs-before refs-after &&
+
+               test_grep "update refs/heads/main" updates &&
+               test_grep "update refs/heads/branch" updates &&
+
+               expect_changes --branches <<-\EOF &&
+               branch-tip
+               1       0       branch-tip.t
+               main-tip
+               1       0       main-tip.t
+               base
+               1       0       base.t
+               EOF
+
+               git update-ref --stdin <updates &&
+               expect_changes --branches <<-\EOF
+               branch-tip
+               1       0       branch-tip.t
+               main-tip
+               1       0       main-tip.t
+               base
+               1       0       base.t
+               1       0       fix.txt
+               EOF
+       )
+'
+
+test_expect_success '--update-refs=head updates only HEAD' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+               test_commit base &&
+               git branch branch &&
+               test_commit main-tip &&
+               git switch branch &&
+               test_commit branch-tip &&
+
+               echo fix >fix.txt &&
+               git add fix.txt &&
+
+               # Only HEAD (branch) should be updated
+               git history fixup --update-refs=head base &&
+
+               # The main branch should be unaffected.
+               expect_changes main <<-\EOF &&
+               main-tip
+               1       0       main-tip.t
+               base
+               1       0       base.t
+               EOF
+
+               # But the currently checked out branch should be modified.
+               expect_changes branch <<-\EOF
+               branch-tip
+               1       0       branch-tip.t
+               base
+               1       0       base.t
+               1       0       fix.txt
+               EOF
+       )
+'
+
+test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+               test_commit base &&
+               git branch other &&
+               test_commit main-tip &&
+               git switch other &&
+               test_commit other-tip &&
+
+               echo fix >fix.txt &&
+               git add fix.txt &&
+
+               test_must_fail git history fixup --update-refs=head main-tip 2>err &&
+               test_grep "rewritten commit must be an ancestor of HEAD" err
+       )
+'
+
+test_expect_success 'aborts when fixup would produce conflicts' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               echo "line one" >file.txt &&
+               git add file.txt &&
+               git commit -m "first" &&
+
+               echo "line two" >file.txt &&
+               git add file.txt &&
+               git commit -m "second" &&
+
+               echo "conflicting change" >file.txt &&
+               git add file.txt &&
+
+               git refs list >refs-before &&
+               test_must_fail git history fixup HEAD~ 2>err &&
+               test_grep "fixup would produce conflicts" err &&
+               git refs list >refs-after &&
+               test_cmp refs-before refs-after
+       )
+'
+
+test_expect_success '--reedit-message opens editor for the commit message' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               test_commit initial &&
+               echo content >file.txt &&
+               git add file.txt &&
+               git commit -m "add file" &&
+
+               echo fix >>file.txt &&
+               git add file.txt &&
+
+               fixup_with_message HEAD <<-\EOF &&
+               add file with fix
+               EOF
+
+               expect_changes --branches <<-\EOF
+               add file with fix
+               2       0       file.txt
+               initial
+               1       0       initial.t
+               EOF
+       )
+'
+
+test_expect_success 'retains unstaged working tree changes after fixup' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+               touch a b &&
+               git add . &&
+               git commit -m "initial commit" &&
+               echo staged >a &&
+               echo unstaged >b &&
+               git add a &&
+               git history fixup HEAD &&
+
+               # b is still modified in the worktree but not staged
+               cat >expect <<-\EOF &&
+                M b
+               EOF
+               git status --porcelain --untracked-files=no >actual &&
+               test_cmp expect actual
+       )
+'
+
+test_expect_success 'index is clean after fixup when target is HEAD' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               test_commit initial &&
+               echo fix >fix.txt &&
+               git add fix.txt &&
+               git history fixup HEAD &&
+
+               git status --porcelain --untracked-files=no >actual &&
+               test_must_be_empty actual
+       )
+'
+
+test_expect_success 'index is unchanged on conflict' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               echo base >file.txt &&
+               git add file.txt &&
+               git commit -m base &&
+               echo change >file.txt &&
+               git add file.txt &&
+               git commit -m change &&
+
+               echo conflict >file.txt &&
+               git add file.txt &&
+
+               git diff --cached >index-before &&
+               test_must_fail git history fixup HEAD~ &&
+               git diff --cached >index-after &&
+               test_cmp index-before index-after
+       )
+'
+
+test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+
+               test_commit first &&
+               test_commit second &&
+               test_commit third &&
+
+               git rm second.t &&
+               git history fixup --empty=drop HEAD~ &&
+
+               expect_changes <<-\EOF &&
+               third
+               1       0       third.t
+               first
+               1       0       first.t
+               EOF
+               test_must_fail git show HEAD:second.t
+       )
+'
+
+test_expect_success '--empty=drop errors out when dropping the root commit' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               test_commit first &&
+               test_commit second &&
+
+               git rm first.t &&
+               test_must_fail git history fixup --empty=drop HEAD~ 2>err &&
+               test_grep "cannot drop root commit" err
+       )
+'
+
+test_expect_success '--empty=drop can drop the HEAD commit' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               test_commit first &&
+               test_commit second &&
+
+               git rm second.t &&
+               git history fixup --empty=drop HEAD &&
+
+               expect_changes <<-\EOF
+               first
+               1       0       first.t
+               EOF
+       )
+'
+
+test_expect_success '--empty=drop drops empty replayed commits' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               touch base remove-me &&
+               git add . &&
+               git commit -m "base" &&
+               git rm remove-me &&
+               git commit -m "remove" &&
+               touch reintroduce remove-me &&
+               git add . &&
+               git commit -m "reintroduce" &&
+
+               git rm remove-me &&
+               git history fixup --empty=drop HEAD~2 &&
+
+               expect_changes <<-\EOF
+               reintroduce
+               0       0       reintroduce
+               0       0       remove-me
+               base
+               0       0       base
+               EOF
+       )
+'
+
+test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               test_commit first &&
+               test_commit second &&
+               test_commit third &&
+
+               git rm second.t &&
+               git history fixup --empty=keep HEAD~ &&
+
+               expect_changes <<-\EOF
+               third
+               1       0       third.t
+               second
+               first
+               1       0       first.t
+               EOF
+       )
+'
+
+test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               touch base remove-me &&
+               git add . &&
+               git commit -m "base" &&
+               git rm remove-me &&
+               git commit -m "remove" &&
+               touch reintroduce remove-me &&
+               git add . &&
+               git commit -m "reintroduce" &&
+
+               git rm remove-me &&
+               git history fixup --empty=keep HEAD~2 &&
+
+               expect_changes <<-\EOF
+               reintroduce
+               0       0       reintroduce
+               0       0       remove-me
+               remove
+               base
+               0       0       base
+               EOF
+       )
+'
+
+test_expect_success '--empty=abort errors out when fixup target becomes empty' '
+       test_when_finished "rm -rf repo" &&
+       git init repo &&
+       (
+               cd repo &&
+
+               test_commit first &&
+               test_commit second &&
+
+               git rm first.t &&
+               test_must_fail git history fixup --empty=abort HEAD~ 2>err &&
+               test_grep "fixup makes commit.*empty" err
+       )
+'
+
+test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '
+       test_when_finished "rm -rf repo" &&
+       git init repo --initial-branch=main &&
+       (
+               cd repo &&
+
+               touch base remove-me &&
+               git add . &&
+               git commit -m "base" &&
+               git rm remove-me &&
+               git commit -m "remove" &&
+               touch reintroduce remove-me &&
+               git add . &&
+               git commit -m "reintroduce" &&
+
+               git rm remove-me &&
+               test_must_fail git history fixup --empty=abort HEAD~2 2>err &&
+               test_grep "became empty after replay" err
+       )
+'
+
+test_done