Merge branch 'ps/history-fixup'

"git history" learned "fixup" command.

* ps/history-fixup:
  builtin/history: introduce "fixup" subcommand
  builtin/history: generalize function to commit trees
  replay: allow callers to control what happens with empty commits
main
Junio C Hamano 2026-05-20 10:30:57 +09:00
commit ca7d7d6424
6 changed files with 1068 additions and 30 deletions

View 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
~~~~~~~~~~~~~~


View 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 \
@ -91,13 +94,18 @@ static int fill_commit_message(struct repository *repo,
return 0;
}

static int commit_tree_with_edited_message_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
struct commit **out)
enum commit_tree_flags {
COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
};

static int commit_tree_ext(struct repository *repo,
const char *action,
struct commit *commit_with_message,
const struct commit_list *parents,
const struct object_id *old_tree,
const struct object_id *new_tree,
struct commit **out,
enum commit_tree_flags flags)
{
const char *exclude_gpgsig[] = {
/* We reencode the message, so the encoding needs to be stripped. */
@ -122,10 +130,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);

ret = fill_commit_message(repo, old_tree, new_tree,
original_body, action, &commit_message);
if (ret < 0)
goto out;
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
ret = fill_commit_message(repo, old_tree, new_tree,
original_body, action, &commit_message);
if (ret < 0)
goto out;
} else {
strbuf_addstr(&commit_message, original_body);
}

original_extra_headers = read_commit_extra_headers(commit_with_message,
exclude_gpgsig);
@ -168,8 +180,8 @@ static int commit_tree_with_edited_message(struct repository *repo,
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
}

return commit_tree_with_edited_message_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out);
return commit_tree_ext(repo, action, original, original->parents,
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}

enum ref_action {
@ -326,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;
@ -425,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,
@ -478,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;
@ -616,9 +861,8 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit);
ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
goto out;
@ -634,9 +878,8 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;

ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
parents, old_tree_oid,
new_tree_oid, &second_commit);
ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
goto out;
@ -717,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;
@ -738,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(),

View File

@ -269,7 +269,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
struct commit *onto,
struct merge_options *merge_opt,
struct merge_result *result,
enum replay_mode mode)
enum replay_mode mode,
enum replay_empty_commit_action empty)
{
struct commit *base, *replayed_base;
struct tree *pickme_tree, *base_tree, *replayed_base_tree;
@ -321,12 +322,25 @@ static struct commit *pick_regular_commit(struct repository *repo,
}
merge_opt->ancestor = NULL;
merge_opt->branch2 = NULL;

if (!result->clean)
return NULL;
/* Drop commits that become empty */

/* Handle commits that become empty */
if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
!oideq(&pickme_tree->object.oid, &base_tree->object.oid))
return replayed_base;
!oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {
switch (empty) {
case REPLAY_EMPTY_COMMIT_DROP:
return replayed_base;
case REPLAY_EMPTY_COMMIT_KEEP:
break;
case REPLAY_EMPTY_COMMIT_ABORT:
result->clean = error(_("commit %s became empty after replay"),
oid_to_hex(&pickme->object.oid));
return NULL;
}
}

return create_commit(repo, result->tree, pickme, replayed_base, mode);
}

@ -417,7 +431,7 @@ int replay_revisions(struct rev_info *revs,

last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
mode == REPLAY_MODE_REVERT ? last_commit : onto,
&merge_opt, &result, mode);
&merge_opt, &result, mode, opts->empty);
if (!last_commit)
break;

@ -458,6 +472,11 @@ int replay_revisions(struct rev_info *revs,
}
}

if (result.clean < 0) {
ret = -1;
goto out;
}

if (!result.clean) {
ret = 1;
goto out;

View File

@ -6,6 +6,19 @@
struct repository;
struct rev_info;

/*
* Controls what happens when a replayed commit becomes empty (i.e. its tree
* is identical to its parent's tree after the replay).
*/
enum replay_empty_commit_action {
/* Silently discard the empty commit. */
REPLAY_EMPTY_COMMIT_DROP,
/* Keep the empty commit as-is. */
REPLAY_EMPTY_COMMIT_KEEP,
/* Abort with an error. */
REPLAY_EMPTY_COMMIT_ABORT,
};

/*
* A set of options that can be passed to `replay_revisions()`.
*/
@ -43,6 +56,12 @@ struct replay_revisions_options {
* Requires `onto` to be set.
*/
int contained;

/*
* Controls what to do when a replayed commit becomes empty.
* Defaults to REPLAY_EMPTY_COMMIT_DROP.
*/
enum replay_empty_commit_action empty;
};

/* This struct is used as an out-parameter by `replay_revisions()`. */

View 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',

680
t/t3453-history-fixup.sh Executable file
View File

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