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
This commit is contained in:
Junio C Hamano
2026-05-20 10:30:57 +09:00
6 changed files with 1068 additions and 30 deletions
+76 -2
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
~~~~~~~~~~~~~~
+268 -23
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(),
+24 -5
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;
+19
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()`. */
+1
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
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