mirror of
https://github.com/git-for-windows/git.git
synced 2026-06-27 00:58:30 -05:00
Merge branch 'hn/history-squash' into seen
The experimental "git history" command has been taught a new "squash" subcommand to fold a range of commits into a single commit, replaying any descendants on top. * hn/history-squash: history: re-edit a squash with every message history: add squash subcommand to fold a range history: give commit_tree_ext a message template history: extract helper for a commit's parent tree
This commit is contained in:
@@ -59,6 +59,10 @@ all advice messages.
|
||||
forceDeleteBranch::
|
||||
Shown when the user tries to delete a not fully merged
|
||||
branch without the force option set.
|
||||
historyUpdateRefs::
|
||||
Shown when `git history squash` refuses because a ref points
|
||||
into the range being folded, to tell the user about
|
||||
`--update-refs=head`.
|
||||
ignoredHook::
|
||||
Shown when a hook is ignored because the hook is not
|
||||
set as executable.
|
||||
|
||||
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
|
||||
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>...]
|
||||
git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
|
||||
|
||||
DESCRIPTION
|
||||
-----------
|
||||
@@ -113,6 +114,31 @@ linkgit:gitglossary[7].
|
||||
It is invalid to select either all or no hunks, as that would lead to
|
||||
one of the commits becoming empty.
|
||||
|
||||
`squash <revision-range>`::
|
||||
Fold all commits in _<revision-range>_ into the oldest commit of that
|
||||
range. The resulting commit keeps the oldest commit's message and
|
||||
authorship and takes the tree of the range's newest commit, so the
|
||||
whole range collapses into a single commit. Commits above the range
|
||||
are replayed on top of the result.
|
||||
+
|
||||
The range is given in the usual `<base>..<tip>` form, where _<base>_ is
|
||||
the commit just below the oldest commit to squash. For example, `git
|
||||
history squash @~3..` folds the three most recent commits into one, and
|
||||
`git history squash @~5..@~2` squashes an interior range while leaving
|
||||
the two newest commits in place.
|
||||
+
|
||||
The oldest commit's message and authorship are preserved by default. With
|
||||
`--reedit-message`, an editor opens pre-filled with the messages of all the
|
||||
folded commits so you can combine them. A merge commit inside the range is
|
||||
folded like any other, but the range must have a single base, so a range
|
||||
that reaches more than one entry point (for example a side branch that
|
||||
forked before the range and was later merged into it) is rejected.
|
||||
+
|
||||
The folded commits disappear from the history, so with the default
|
||||
`--update-refs=branches` the command refuses when another ref points at
|
||||
one of them. Rerun with `--update-refs=head` to rewrite only the current
|
||||
branch and leave those refs pointing at the old commits.
|
||||
|
||||
OPTIONS
|
||||
-------
|
||||
|
||||
|
||||
1
advice.c
1
advice.c
@@ -58,6 +58,7 @@ static struct {
|
||||
[ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
|
||||
[ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
|
||||
[ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
|
||||
[ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
|
||||
[ADVICE_IGNORED_HOOK] = { "ignoredHook" },
|
||||
[ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
|
||||
[ADVICE_MERGE_CONFLICT] = { "mergeConflict" },
|
||||
|
||||
1
advice.h
1
advice.h
@@ -25,6 +25,7 @@ enum advice_type {
|
||||
ADVICE_FETCH_SHOW_FORCED_UPDATES,
|
||||
ADVICE_FORCE_DELETE_BRANCH,
|
||||
ADVICE_GRAFT_FILE_DEPRECATED,
|
||||
ADVICE_HISTORY_UPDATE_REFS,
|
||||
ADVICE_IGNORED_HOOK,
|
||||
ADVICE_IMPLICIT_IDENTITY,
|
||||
ADVICE_MERGE_CONFLICT,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#define USE_THE_REPOSITORY_VARIABLE
|
||||
|
||||
#include "builtin.h"
|
||||
#include "advice.h"
|
||||
#include "cache-tree.h"
|
||||
#include "commit.h"
|
||||
#include "commit-reach.h"
|
||||
@@ -34,6 +35,8 @@
|
||||
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
|
||||
#define GIT_HISTORY_SPLIT_USAGE \
|
||||
N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
|
||||
#define GIT_HISTORY_SQUASH_USAGE \
|
||||
N_("git history squash <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
|
||||
|
||||
static void change_data_free(void *util, const char *str UNUSED)
|
||||
{
|
||||
@@ -105,6 +108,7 @@ enum commit_tree_flags {
|
||||
static int commit_tree_ext(struct repository *repo,
|
||||
const char *action,
|
||||
struct commit *commit_with_message,
|
||||
const char *message_template,
|
||||
const struct commit_list *parents,
|
||||
const struct object_id *old_tree,
|
||||
const struct object_id *new_tree,
|
||||
@@ -134,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
|
||||
original_author = xmemdupz(ptr, len);
|
||||
find_commit_subject(original_message, &original_body);
|
||||
|
||||
if (!message_template)
|
||||
message_template = original_body;
|
||||
|
||||
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
|
||||
ret = fill_commit_message(repo, old_tree, new_tree,
|
||||
original_body, action, &commit_message);
|
||||
message_template, action, &commit_message);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
} else {
|
||||
strbuf_addstr(&commit_message, original_body);
|
||||
strbuf_addstr(&commit_message, message_template);
|
||||
}
|
||||
|
||||
original_extra_headers = read_commit_extra_headers(commit_with_message,
|
||||
@@ -161,6 +168,25 @@ out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int first_parent_tree_oid(struct repository *repo,
|
||||
struct commit *commit,
|
||||
struct object_id *out)
|
||||
{
|
||||
struct commit *parent = commit->parents ? commit->parents->item : NULL;
|
||||
|
||||
if (!parent) {
|
||||
oidcpy(out, repo->hash_algo->empty_tree);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (repo_parse_commit(repo, parent))
|
||||
return error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&parent->object.oid));
|
||||
|
||||
oidcpy(out, &repo_get_commit_tree(repo, parent)->object.oid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int commit_tree_with_edited_message(struct repository *repo,
|
||||
const char *action,
|
||||
struct commit *original,
|
||||
@@ -168,23 +194,13 @@ static int commit_tree_with_edited_message(struct repository *repo,
|
||||
{
|
||||
struct object_id parent_tree_oid;
|
||||
const struct object_id *tree_oid;
|
||||
struct commit *parent;
|
||||
|
||||
tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
|
||||
|
||||
parent = original->parents ? original->parents->item : NULL;
|
||||
if (parent) {
|
||||
if (repo_parse_commit(repo, parent)) {
|
||||
return error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&parent->object.oid));
|
||||
}
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
|
||||
return -1;
|
||||
|
||||
parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
|
||||
} else {
|
||||
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
|
||||
}
|
||||
|
||||
return commit_tree_ext(repo, action, original, original->parents,
|
||||
return commit_tree_ext(repo, action, original, NULL, original->parents,
|
||||
&parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -474,18 +490,10 @@ 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);
|
||||
}
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
|
||||
return -1;
|
||||
|
||||
return oideq(&result->object.oid, &parent_tree_oid);
|
||||
}
|
||||
@@ -673,7 +681,7 @@ static int cmd_history_fixup(int argc,
|
||||
goto out;
|
||||
|
||||
if (!skip_commit) {
|
||||
ret = commit_tree_ext(repo, "fixup", original, original->parents,
|
||||
ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
|
||||
&original_tree->object.oid, &merge_result.tree->object.oid,
|
||||
&rewritten, flags);
|
||||
if (ret < 0) {
|
||||
@@ -829,16 +837,9 @@ static int split_commit(struct repository *repo,
|
||||
struct tree *split_tree;
|
||||
int ret;
|
||||
|
||||
if (original->parents) {
|
||||
if (repo_parse_commit(repo, original->parents->item)) {
|
||||
ret = error(_("unable to parse parent commit %s"),
|
||||
oid_to_hex(&original->parents->item->object.oid));
|
||||
goto out;
|
||||
}
|
||||
|
||||
parent_tree_oid = *get_commit_tree_oid(original->parents->item);
|
||||
} else {
|
||||
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
|
||||
if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0) {
|
||||
ret = -1;
|
||||
goto out;
|
||||
}
|
||||
original_commit_tree_oid = get_commit_tree_oid(original);
|
||||
|
||||
@@ -891,7 +892,7 @@ 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_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
|
||||
ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
|
||||
&split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing first commit"));
|
||||
@@ -908,7 +909,7 @@ 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_ext(repo, "split-out", original, parents, old_tree_oid,
|
||||
ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
|
||||
new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing second commit"));
|
||||
@@ -1185,6 +1186,268 @@ out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
/*
|
||||
* Resolve a "<base>..<tip>" revision range into the base commit just outside
|
||||
* the range (which becomes the parent of the squashed commit), the oldest
|
||||
* commit contained in the range (whose message the squash reuses), and the
|
||||
* range tip (whose tree becomes the result). A merge inside the range is fine,
|
||||
* but the range must have a single base and must not reach a root commit.
|
||||
*/
|
||||
static int resolve_squash_range(struct repository *repo,
|
||||
const char *range,
|
||||
struct commit **base_out,
|
||||
struct commit **oldest_out,
|
||||
struct commit **tip_out,
|
||||
struct oidset *interior_out)
|
||||
{
|
||||
struct rev_info revs;
|
||||
struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
|
||||
struct strvec args = STRVEC_INIT;
|
||||
size_t i;
|
||||
int ret;
|
||||
|
||||
repo_init_revisions(repo, &revs, NULL);
|
||||
strvec_push(&args, "ignored");
|
||||
strvec_push(&args, "--reverse");
|
||||
strvec_push(&args, "--topo-order");
|
||||
strvec_push(&args, "--boundary");
|
||||
strvec_push(&args, "--ancestry-path");
|
||||
strvec_push(&args, range);
|
||||
setup_revisions_from_strvec(&args, &revs, NULL);
|
||||
if (args.nr != 1) {
|
||||
ret = error(_("'%s' does not name a revision range"), range);
|
||||
goto out;
|
||||
}
|
||||
|
||||
/*
|
||||
* A squash needs a base to reparent onto, so the argument has to
|
||||
* exclude something, as in "<base>..<tip>". A single revision has no
|
||||
* such bottom commit and cannot be squashed.
|
||||
*/
|
||||
for (i = 0; i < revs.cmdline.nr; i++)
|
||||
if (revs.cmdline.rev[i].flags & UNINTERESTING)
|
||||
break;
|
||||
if (i == revs.cmdline.nr) {
|
||||
ret = error(_("'%s' is not a '<base>..<tip>' range"), range);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (prepare_revision_walk(&revs) < 0) {
|
||||
ret = error(_("error preparing revisions"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
while ((commit = get_revision(&revs))) {
|
||||
if (commit->object.flags & BOUNDARY) {
|
||||
if (base) {
|
||||
ret = error(_("range '%s' has more than one base; "
|
||||
"cannot squash"), range);
|
||||
goto out;
|
||||
}
|
||||
base = commit;
|
||||
continue;
|
||||
}
|
||||
if (!oldest)
|
||||
oldest = commit;
|
||||
if (tip)
|
||||
oidset_insert(interior_out, &tip->object.oid);
|
||||
tip = commit;
|
||||
}
|
||||
|
||||
if (!oldest) {
|
||||
ret = error(_("the range '%s' is empty"), range);
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (!base)
|
||||
BUG("a non-empty range must have a boundary commit");
|
||||
|
||||
*base_out = base;
|
||||
*oldest_out = oldest;
|
||||
*tip_out = tip;
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
reset_revision_walk();
|
||||
release_revisions(&revs);
|
||||
strvec_clear(&args);
|
||||
return ret;
|
||||
}
|
||||
|
||||
struct interior_ref_cb {
|
||||
const struct oidset *interior;
|
||||
const char *name;
|
||||
};
|
||||
|
||||
static int find_interior_ref(const struct reference *ref, void *cb_data)
|
||||
{
|
||||
struct interior_ref_cb *data = cb_data;
|
||||
|
||||
if (oidset_contains(data->interior, ref->oid)) {
|
||||
data->name = xstrdup(ref->name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int build_squash_message(struct repository *repo,
|
||||
struct commit *base,
|
||||
struct commit *tip,
|
||||
struct strbuf *out)
|
||||
{
|
||||
struct rev_info revs;
|
||||
struct commit *commit;
|
||||
struct strvec args = STRVEC_INIT;
|
||||
int n = 0, ret;
|
||||
|
||||
repo_init_revisions(repo, &revs, NULL);
|
||||
strvec_push(&args, "ignored");
|
||||
strvec_push(&args, "--reverse");
|
||||
strvec_push(&args, "--topo-order");
|
||||
strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
|
||||
oid_to_hex(&tip->object.oid));
|
||||
setup_revisions_from_strvec(&args, &revs, NULL);
|
||||
|
||||
if (prepare_revision_walk(&revs) < 0) {
|
||||
ret = error(_("error preparing revisions"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
while ((commit = get_revision(&revs))) {
|
||||
const char *message, *body;
|
||||
struct strbuf one = STRBUF_INIT;
|
||||
|
||||
message = repo_logmsg_reencode(repo, commit, NULL, NULL);
|
||||
find_commit_subject(message, &body);
|
||||
strbuf_addstr(&one, body);
|
||||
strbuf_trim_trailing_newline(&one);
|
||||
|
||||
if (n++)
|
||||
strbuf_addch(out, '\n');
|
||||
strbuf_addbuf(out, &one);
|
||||
strbuf_addch(out, '\n');
|
||||
|
||||
strbuf_release(&one);
|
||||
repo_unuse_commit_buffer(repo, commit, message);
|
||||
}
|
||||
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
reset_revision_walk();
|
||||
release_revisions(&revs);
|
||||
strvec_clear(&args);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static int cmd_history_squash(int argc,
|
||||
const char **argv,
|
||||
const char *prefix,
|
||||
struct repository *repo)
|
||||
{
|
||||
const char * const usage[] = {
|
||||
GIT_HISTORY_SQUASH_USAGE,
|
||||
NULL,
|
||||
};
|
||||
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_END(),
|
||||
};
|
||||
struct strbuf reflog_msg = STRBUF_INIT;
|
||||
struct strbuf message = STRBUF_INIT;
|
||||
struct oidset interior = OIDSET_INIT;
|
||||
struct commit *base, *oldest, *tip, *rewritten;
|
||||
const struct object_id *base_tree_oid, *tip_tree_oid;
|
||||
struct commit_list *parents = NULL;
|
||||
struct rev_info revs = { 0 };
|
||||
int ret;
|
||||
|
||||
argc = parse_options(argc, argv, prefix, options, usage, 0);
|
||||
if (argc != 1) {
|
||||
ret = error(_("command expects a single revision range"));
|
||||
goto out;
|
||||
}
|
||||
repo_config(repo, git_default_config, NULL);
|
||||
|
||||
if (action == REF_ACTION_DEFAULT)
|
||||
action = REF_ACTION_BRANCHES;
|
||||
|
||||
ret = resolve_squash_range(repo, argv[0], &base, &oldest, &tip,
|
||||
&interior);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
|
||||
if (action == REF_ACTION_BRANCHES) {
|
||||
struct interior_ref_cb cb = { .interior = &interior };
|
||||
|
||||
refs_for_each_ref(get_main_ref_store(repo),
|
||||
find_interior_ref, &cb);
|
||||
if (cb.name) {
|
||||
ret = error(_("'%s' points into the squashed range"),
|
||||
cb.name);
|
||||
advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
|
||||
_("Use --update-refs=head to rewrite only "
|
||||
"the current branch and leave such refs "
|
||||
"untouched."));
|
||||
free((char *)cb.name);
|
||||
goto out;
|
||||
}
|
||||
}
|
||||
|
||||
if (flags & COMMIT_TREE_EDIT_MESSAGE) {
|
||||
ret = build_squash_message(repo, base, tip, &message);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
}
|
||||
|
||||
ret = setup_revwalk(repo, action, tip, &revs);
|
||||
if (ret < 0)
|
||||
goto out;
|
||||
|
||||
base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
|
||||
tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
|
||||
commit_list_append(base, &parents);
|
||||
|
||||
ret = commit_tree_ext(repo, "squash", oldest,
|
||||
message.len ? message.buf : NULL, parents,
|
||||
base_tree_oid, tip_tree_oid, &rewritten, flags);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed writing squashed commit"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
|
||||
|
||||
ret = handle_reference_updates(&revs, action, tip, rewritten,
|
||||
reflog_msg.buf, dry_run,
|
||||
REPLAY_EMPTY_COMMIT_ABORT);
|
||||
if (ret < 0) {
|
||||
ret = error(_("failed replaying descendants"));
|
||||
goto out;
|
||||
}
|
||||
|
||||
ret = 0;
|
||||
|
||||
out:
|
||||
strbuf_release(&reflog_msg);
|
||||
strbuf_release(&message);
|
||||
oidset_clear(&interior);
|
||||
commit_list_free(parents);
|
||||
release_revisions(&revs);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int cmd_history(int argc,
|
||||
const char **argv,
|
||||
const char *prefix,
|
||||
@@ -1195,6 +1458,7 @@ int cmd_history(int argc,
|
||||
GIT_HISTORY_FIXUP_USAGE,
|
||||
GIT_HISTORY_REWORD_USAGE,
|
||||
GIT_HISTORY_SPLIT_USAGE,
|
||||
GIT_HISTORY_SQUASH_USAGE,
|
||||
NULL,
|
||||
};
|
||||
parse_opt_subcommand_fn *fn = NULL;
|
||||
@@ -1203,6 +1467,7 @@ int cmd_history(int argc,
|
||||
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
|
||||
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
|
||||
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
|
||||
OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
|
||||
OPT_END(),
|
||||
};
|
||||
|
||||
|
||||
@@ -406,6 +406,7 @@ integration_tests = [
|
||||
't3452-history-split.sh',
|
||||
't3453-history-fixup.sh',
|
||||
't3454-history-drop.sh',
|
||||
't3455-history-squash.sh',
|
||||
't3500-cherry.sh',
|
||||
't3501-revert-cherry-pick.sh',
|
||||
't3502-cherry-pick-merge.sh',
|
||||
|
||||
497
t/t3455-history-squash.sh
Executable file
497
t/t3455-history-squash.sh
Executable file
@@ -0,0 +1,497 @@
|
||||
#!/bin/sh
|
||||
|
||||
test_description='tests for git-history squash subcommand'
|
||||
|
||||
. ./test-lib.sh
|
||||
|
||||
test_expect_success 'setup linear history touching two files' '
|
||||
test_commit base file a &&
|
||||
git tag start &&
|
||||
test_commit --no-tag one other x &&
|
||||
test_commit --no-tag two file c &&
|
||||
test_commit three file d
|
||||
'
|
||||
|
||||
test_expect_success 'errors on missing range argument' '
|
||||
test_must_fail git history squash 2>err &&
|
||||
test_grep "command expects a single revision range" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on too many arguments' '
|
||||
test_must_fail git history squash start.. HEAD 2>err &&
|
||||
test_grep "command expects a single revision range" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on an empty range' '
|
||||
test_must_fail git history squash HEAD..HEAD 2>err &&
|
||||
test_grep "the range .* is empty" err
|
||||
'
|
||||
|
||||
test_expect_success 'errors on a single revision that is not a range' '
|
||||
test_must_fail git history squash HEAD 2>err &&
|
||||
test_grep "is not a .*range" err &&
|
||||
test_must_fail git history squash HEAD~1 2>err &&
|
||||
test_grep "is not a .*range" err
|
||||
'
|
||||
|
||||
test_expect_success 'squashes a range into a single commit without changing the tree' '
|
||||
git reset --hard three &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
git log --format="%s" -1 >subject &&
|
||||
echo one >expect &&
|
||||
test_cmp expect subject &&
|
||||
git reflog >reflog &&
|
||||
test_grep "squash: updating" reflog
|
||||
'
|
||||
|
||||
test_expect_success 'squashes an interior range and replays descendants verbatim' '
|
||||
git reset --hard three &&
|
||||
final_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start..@~1 &&
|
||||
|
||||
git log --format="%s" start..HEAD >actual &&
|
||||
cat >expect <<-\EOF &&
|
||||
three
|
||||
one
|
||||
EOF
|
||||
test_cmp expect actual &&
|
||||
|
||||
test_cmp_rev start HEAD~2 &&
|
||||
test "$final_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'squashes when the base is the root commit' '
|
||||
git reset --hard three &&
|
||||
root=$(git rev-list --max-parents=0 HEAD) &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash "$root.." &&
|
||||
|
||||
git rev-list --count "$root..HEAD" >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev "$root" HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'squashing a single-commit range replays the rest' '
|
||||
git reset --hard three &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start..@~2 &&
|
||||
|
||||
git log --format="%s" start..HEAD >actual &&
|
||||
cat >expect <<-\EOF &&
|
||||
three
|
||||
two
|
||||
one
|
||||
EOF
|
||||
test_cmp expect actual &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'reuses the message of a fixup! commit in the range' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag reg1 file b &&
|
||||
git commit --allow-empty -m "fixup! reg1" &&
|
||||
test_commit reg2 file c &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git log --format="%s" -1 >actual &&
|
||||
echo reg1 >expect &&
|
||||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success 'keeps the oldest message even if it is a fixup!' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag "fixup! something" file b &&
|
||||
test_commit tail file c &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git log --format="%s" -1 >actual &&
|
||||
echo "fixup! something" >expect &&
|
||||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success 'preserves authorship of the oldest commit' '
|
||||
git reset --hard start &&
|
||||
GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
|
||||
test_commit --no-tag oldest file b &&
|
||||
test_commit newest file c &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git log -1 --format="%an <%ae>" >actual &&
|
||||
echo "Squasher <squash@example.com>" >expect &&
|
||||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success '--reedit-message offers every folded-in message' '
|
||||
git reset --hard start &&
|
||||
echo b >file &&
|
||||
git add file &&
|
||||
git commit -m "re-one subject" -m "re-one body line" &&
|
||||
test_commit --no-tag re-two file c &&
|
||||
test_commit re-three file d &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
cp "$1" buffer &&
|
||||
echo combined >"$1"
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
git history squash --reedit-message start.. &&
|
||||
|
||||
test_grep "re-one subject" buffer &&
|
||||
test_grep "re-one body line" buffer &&
|
||||
test_grep re-two buffer &&
|
||||
test_grep re-three buffer &&
|
||||
git log --format="%s" -1 >actual &&
|
||||
echo combined >expect &&
|
||||
test_cmp expect actual
|
||||
'
|
||||
|
||||
test_expect_success '--reedit-message aborts on an empty message' '
|
||||
git reset --hard three &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
write_script editor <<-\EOF &&
|
||||
>"$1"
|
||||
EOF
|
||||
test_set_editor "$(pwd)/editor" &&
|
||||
test_must_fail git history squash --reedit-message start.. &&
|
||||
|
||||
test_cmp_rev "$head_before" HEAD
|
||||
'
|
||||
|
||||
test_expect_success '--dry-run predicts the rewrite without performing it' '
|
||||
git reset --hard three &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash --dry-run start.. >out &&
|
||||
predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git history squash start.. &&
|
||||
test "$predicted" = "$(git rev-parse HEAD)" &&
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success '--update-refs=head only moves HEAD' '
|
||||
git reset --hard three &&
|
||||
git branch -f other HEAD &&
|
||||
other_before=$(git rev-parse other) &&
|
||||
|
||||
git history squash --update-refs=head start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev "$other_before" other
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to fold a range a ref points into' '
|
||||
git reset --hard three &&
|
||||
git branch -f mid HEAD~1 &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
test_grep "error: .* points into the squashed range" err &&
|
||||
test_grep "hint: .*--update-refs=head" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git branch -D mid
|
||||
'
|
||||
|
||||
test_expect_success 'advice.historyUpdateRefs silences the hint' '
|
||||
git reset --hard three &&
|
||||
git branch -f mid HEAD~1 &&
|
||||
|
||||
test_must_fail git -c advice.historyUpdateRefs=false \
|
||||
history squash start.. 2>err &&
|
||||
test_grep "points into the squashed range" err &&
|
||||
test_grep ! "hint:" err &&
|
||||
|
||||
git branch -D mid
|
||||
'
|
||||
|
||||
test_expect_success '--update-refs=head folds past a ref pointing into the range' '
|
||||
git reset --hard three &&
|
||||
git branch -f mid HEAD~1 &&
|
||||
mid_before=$(git rev-parse mid) &&
|
||||
|
||||
git history squash --update-refs=head start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev "$mid_before" mid &&
|
||||
|
||||
git branch -D mid
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to fold a range a tag points into' '
|
||||
git reset --hard three &&
|
||||
git tag -f mark HEAD~1 &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
test_grep "refs/tags/mark" err &&
|
||||
test_grep "points into the squashed range" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git tag -d mark
|
||||
'
|
||||
|
||||
test_expect_success 'squashes a range whose internal merge has a single base' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag before-side file b &&
|
||||
git checkout -b inner-side &&
|
||||
test_commit --no-tag on-inner-side inner x &&
|
||||
git checkout - &&
|
||||
test_commit --no-tag after-side file c &&
|
||||
git merge --no-ff -m merge inner-side &&
|
||||
git branch -D inner-side &&
|
||||
test_commit --no-tag after-merge file d &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
git log --format="%s" -1 >subject &&
|
||||
echo before-side >expect &&
|
||||
test_cmp expect subject &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file inner
|
||||
'
|
||||
|
||||
test_expect_success 'folds a merge of a branch that forked at the base' '
|
||||
git reset --hard start &&
|
||||
git checkout -b base-fork-side &&
|
||||
test_commit --no-tag base-fork-side side x &&
|
||||
git checkout - &&
|
||||
test_commit --no-tag base-fork-main file b &&
|
||||
git merge --no-ff -m "merge base-fork-side" base-fork-side &&
|
||||
git branch -D base-fork-side &&
|
||||
test_commit --no-tag base-fork-tail file c &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev start HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file side
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range whose tip is a merge commit' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag tipmerge-base file b &&
|
||||
git checkout -b tipmerge-side &&
|
||||
test_commit --no-tag tipmerge-side side x &&
|
||||
git checkout - &&
|
||||
test_commit --no-tag tipmerge-main file c &&
|
||||
git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
|
||||
git branch -D tipmerge-side &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file side
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range whose base is a merge commit' '
|
||||
git reset --hard start &&
|
||||
git checkout -b basemerge-side &&
|
||||
test_commit --no-tag basemerge-side side x &&
|
||||
git checkout - &&
|
||||
test_commit --no-tag basemerge-main file b &&
|
||||
git merge --no-ff -m "merge basemerge-side" basemerge-side &&
|
||||
git branch -D basemerge-side &&
|
||||
base=$(git rev-parse HEAD) &&
|
||||
test_commit --no-tag basemerge-one file c &&
|
||||
test_commit --no-tag basemerge-two file d &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash "$base.." &&
|
||||
|
||||
git rev-list --count "$base..HEAD" >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test_cmp_rev "$base" HEAD^ &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to squash a range with more than one base' '
|
||||
git reset --hard start &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
git checkout -b forked-before &&
|
||||
test_commit forked-side fside x &&
|
||||
git checkout - &&
|
||||
test_commit forked-main file b &&
|
||||
git merge --no-ff -m merge forked-before &&
|
||||
merged=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash forked-main.. 2>err &&
|
||||
test_grep "more than one base" err &&
|
||||
test_cmp_rev "$merged" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with two interior merges' '
|
||||
git reset --hard start &&
|
||||
test_commit --no-tag two-merge-a file a1 &&
|
||||
git checkout -b two-merge-s1 &&
|
||||
test_commit --no-tag two-merge-s1 s1 x &&
|
||||
git checkout - &&
|
||||
git merge --no-ff -m "merge s1" two-merge-s1 &&
|
||||
test_commit --no-tag two-merge-b file b1 &&
|
||||
git checkout -b two-merge-s2 &&
|
||||
test_commit --no-tag two-merge-s2 s2 y &&
|
||||
git checkout - &&
|
||||
git merge --no-ff -m "merge s2" two-merge-s2 &&
|
||||
git branch -D two-merge-s1 two-merge-s2 &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file s1 &&
|
||||
test_path_is_file s2
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with a nested merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b nested-outer &&
|
||||
test_commit --no-tag nested-outer outer x &&
|
||||
git checkout -b nested-inner &&
|
||||
test_commit --no-tag nested-inner inner y &&
|
||||
git checkout nested-outer &&
|
||||
git merge --no-ff -m "merge inner" nested-inner &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag nested-main file b1 &&
|
||||
git merge --no-ff -m "merge outer" nested-outer &&
|
||||
git branch -D nested-outer nested-inner &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file outer &&
|
||||
test_path_is_file inner
|
||||
'
|
||||
|
||||
test_expect_success 'folds a range with an octopus merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag octo-base file a1 &&
|
||||
git checkout -b octo-1 &&
|
||||
test_commit --no-tag octo-1 o1 x &&
|
||||
git checkout "$main" &&
|
||||
git checkout -b octo-2 &&
|
||||
test_commit --no-tag octo-2 o2 y &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m octopus octo-1 octo-2 &&
|
||||
git branch -D octo-1 octo-2 &&
|
||||
tip_tree=$(git rev-parse HEAD^{tree}) &&
|
||||
|
||||
git history squash start.. &&
|
||||
|
||||
git rev-list --count start..HEAD >count &&
|
||||
echo 1 >expect &&
|
||||
test_cmp expect count &&
|
||||
test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
|
||||
test_path_is_file o1 &&
|
||||
test_path_is_file o2
|
||||
'
|
||||
|
||||
test_expect_success 'refuses an octopus merge with an arm forked before the base' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
git checkout -b octo-pre &&
|
||||
test_commit octo-pre-side pside x &&
|
||||
git checkout "$main" &&
|
||||
test_commit octo-pre-main file b1 &&
|
||||
octo_base=$(git rev-parse HEAD) &&
|
||||
git checkout -b octo-within &&
|
||||
test_commit --no-tag octo-within wside y &&
|
||||
git checkout "$main" &&
|
||||
git merge --no-ff -m octopus octo-pre octo-within &&
|
||||
merged=$(git rev-parse HEAD) &&
|
||||
git branch -D octo-pre octo-within &&
|
||||
|
||||
test_must_fail git history squash "$octo_base.." 2>err &&
|
||||
test_grep "more than one base" err &&
|
||||
test_cmp_rev "$merged" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'refuses when a descendant above the range is a merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag desc-base file b &&
|
||||
git tag desc-tip &&
|
||||
git checkout -b desc-above &&
|
||||
test_commit --no-tag desc-above above x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag desc-main file c &&
|
||||
git merge --no-ff -m "merge desc-above" desc-above &&
|
||||
git branch -D desc-above &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash start..desc-tip 2>err &&
|
||||
test_grep "merge commits is not supported" err &&
|
||||
test_cmp_rev "$head_before" HEAD
|
||||
'
|
||||
|
||||
test_expect_success 'refuses to fold a range a ref points into at a merge' '
|
||||
git reset --hard start &&
|
||||
main=$(git symbolic-ref --short HEAD) &&
|
||||
test_commit --no-tag refmerge-base file b &&
|
||||
git checkout -b refmerge-side &&
|
||||
test_commit --no-tag refmerge-side side x &&
|
||||
git checkout "$main" &&
|
||||
test_commit --no-tag refmerge-main file c &&
|
||||
git merge --no-ff -m "interior merge" refmerge-side &&
|
||||
git branch -D refmerge-side &&
|
||||
git branch at-merge HEAD &&
|
||||
test_commit --no-tag refmerge-tail file d &&
|
||||
head_before=$(git rev-parse HEAD) &&
|
||||
|
||||
test_must_fail git history squash start.. 2>err &&
|
||||
test_grep "at-merge" err &&
|
||||
test_grep "points into the squashed range" err &&
|
||||
test_cmp_rev "$head_before" HEAD &&
|
||||
|
||||
git branch -D at-merge
|
||||
'
|
||||
|
||||
test_done
|
||||
Reference in New Issue
Block a user