diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc index 81f80a9274..e2a3487778 100644 --- a/Documentation/config/advice.adoc +++ b/Documentation/config/advice.adoc @@ -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. diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc index 28b477cd37..6f3b031d2a 100644 --- a/Documentation/git-history.adoc +++ b/Documentation/git-history.adoc @@ -12,6 +12,7 @@ git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=( git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] git history reword [--dry-run] [--update-refs=(branches|head)] git history split [--dry-run] [--update-refs=(branches|head)] [--] [...] +git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] DESCRIPTION ----------- @@ -43,8 +44,11 @@ at once. LIMITATIONS ----------- -This command does not (yet) work with histories that contain merges. You -should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead. +This command does not (yet) replay merge commits onto the rewritten +history: if a commit that would be replayed is a merge, the operation is +rejected, and you should use linkgit:git-rebase[1] with the +`--rebase-merges` flag instead. The `squash` subcommand can still fold a +merge that lies inside the range, as long as the range has a single base. Furthermore, the command does not support operations that can result in merge conflicts. This limitation is by design as history rewrites are not intended to @@ -113,6 +117,50 @@ 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 `:: + Fold all commits in __ into the oldest commit of that + range. The resulting commit keeps the oldest commit's 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 `..` form, where __ is +the commit just below the oldest commit to squash. For example, `git +history squash HEAD~3..HEAD` folds the three most recent commits into +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range +while leaving the two newest commits in place. Several revisions may be +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is +already on `topic`. Rev-list options may also be given, but any that would +change how the range is walked are overridden with a warning. ++ +The oldest commit's message is preserved by default, except that an `amend!` +commit targeting it replaces its message. 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. ++ +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it +targets is also in the range, so the fold does not silently absorb a +marker meant for a commit outside it. As an exception, a range made up entirely +of markers for one target is combined into a single commit, keeping the last +`amend!` message if there is one. ++ +With `--reedit-message` the template mirrors `git rebase -i --autosquash`: +each `fixup!`, `squash!`, or `amend!` is grouped under the commit it +targets rather than shown in commit order. A `fixup!` message is dropped +(commented out in full), a `squash!` keeps its body with only the marker +subject commented, and an `amend!` replaces its target's message, unless +a `squash!` folded into that target first, in which case it keeps its +body like a `squash!`. ++ +A branch or tag that points at a commit inside the range would be left +dangling once those commits are folded away, so with the default +`--update-refs=branches` the command refuses. Rerun with +`--update-refs=head` to rewrite only the current branch and leave such +refs pointing at the old commits. + OPTIONS ------- @@ -123,7 +171,8 @@ OPTIONS ref updates is generally safe. `--reedit-message`:: - Open an editor to modify the target commit's message. + Open an editor to modify the rewritten commit's message. For `squash` + the editor is pre-filled with the messages of all the folded commits. `--empty=(drop|keep|abort)`:: Control what happens when a commit becomes empty as a result of the diff --git a/advice.c b/advice.c index 63bf8b0c5f..401d047391 100644 --- a/advice.c +++ b/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" }, diff --git a/advice.h b/advice.h index 66f6cd6a77..3f0b4f0485 100644 --- a/advice.h +++ b/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, diff --git a/builtin/history.c b/builtin/history.c index 000155ad9c..92e1571301 100644 --- a/builtin/history.c +++ b/builtin/history.c @@ -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 [--dry-run] [--update-refs=(branches|head)]") #define GIT_HISTORY_SPLIT_USAGE \ N_("git history split [--dry-run] [--update-refs=(branches|head)] [--] [...]") +#define GIT_HISTORY_SQUASH_USAGE \ + N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] ") static void change_data_free(void *util, const char *str UNUSED) { @@ -108,6 +111,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, @@ -137,13 +141,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, @@ -164,6 +171,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, @@ -171,23 +197,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); } @@ -475,18 +491,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); } @@ -674,7 +682,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) { @@ -830,16 +838,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); @@ -892,7 +893,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")); @@ -909,7 +910,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")); @@ -1006,6 +1007,479 @@ out: return ret; } +/* + * Resolve a ".." 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 **argv, + 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 commit_list *boundaries = NULL, *b; + struct strvec args = STRVEC_INIT; + size_t i; + int ret; + + repo_init_revisions(repo, &revs, NULL); + revs.reverse = 1; + revs.topo_order = 1; + revs.sort_order = REV_SORT_IN_GRAPH_ORDER; + revs.simplify_history = 0; + revs.boundary = 1; + + strvec_push(&args, "ignored"); + strvec_push(&args, "--ancestry-path"); + strvec_pushv(&args, argv); + setup_revisions_from_strvec(&args, &revs, NULL); + if (args.nr != 1) { + ret = error(_("unrecognized argument: %s"), args.v[1]); + goto out; + } + + if (revs.reverse != 1 || revs.topo_order != 1 || + revs.sort_order != REV_SORT_IN_GRAPH_ORDER || + revs.simplify_history != 0 || revs.boundary != 1) { + warning(_("ignoring rev-list options that would change how the " + "range is walked")); + revs.reverse = 1; + revs.topo_order = 1; + revs.sort_order = REV_SORT_IN_GRAPH_ORDER; + revs.simplify_history = 0; + revs.boundary = 1; + } + + /* + * A squash needs a base to reparent onto, so the range has to exclude + * something, as in "..". A revision range with no such + * bottom commit 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(_("not a '..' revision range")); + goto out; + } + + if (prepare_revision_walk(&revs) < 0) { + ret = error(_("error preparing revisions")); + goto out; + } + + /* + * Set boundary commits aside for the base check below, and put every + * in-range commit but the tip into the interior set. A ref pointing + * at an interior commit would dangle once the range is folded away. + */ + while ((commit = get_revision(&revs))) { + if (commit->object.flags & BOUNDARY) { + commit_list_insert(commit, &boundaries); + continue; + } + if (!oldest) + oldest = commit; + if (tip) + oidset_insert(interior_out, &tip->object.oid); + tip = commit; + } + + if (!oldest) { + ret = error(_("the revision range is empty")); + goto out; + } else if (oldest == tip) { + ret = error(_("the revision range holds a single commit; " + "nothing to squash")); + goto out; + } else if (!oldest->parents) { + BUG("an in-range commit must have a parent"); + } + base = oldest->parents->item; + + /* + * A boundary other than the base is an in-range commit reaching a + * commit outside the range, so the range has more than one base. + */ + for (b = boundaries; b; b = b->next) { + if (b->item != base) { + ret = error(_("the revision range has more than one base; " + "cannot squash")); + goto out; + } + } + + *base_out = base; + *oldest_out = oldest; + *tip_out = tip; + ret = 0; + +out: + commit_list_free(boundaries); + reset_revision_walk(); + release_revisions(&revs); + strvec_clear(&args); + return ret; +} + +static const char *autosquash_target(const char *subject) +{ + const char *rest; + + while (skip_prefix(subject, "fixup! ", &rest) || + skip_prefix(subject, "squash! ", &rest) || + skip_prefix(subject, "amend! ", &rest)) + subject = rest; + return subject; +} + +static int reject_dangling_fixups(struct repository *repo, + struct commit *base, + struct commit *tip, + struct commit *oldest, + struct commit **msg_source, + struct commit **amend_source) +{ + struct todo_list todo = TODO_LIST_INIT; + struct replay_opts opts = REPLAY_OPTS_INIT; + struct rev_info revs; + struct commit *commit, *last_amend = NULL; + struct strvec args = STRVEC_INIT; + char *dangling_subject = NULL, *dangling_target = NULL; + bool mixed_target = false, all_fixups_one_target; + bool past_oldest_group = false; + int i, ret, nr_dangling = 0; + + *msg_source = oldest; + *amend_source = NULL; + + 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))) + strbuf_addf(&todo.buf, "pick %s\n", + oid_to_hex(&commit->object.oid)); + + if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 || + todo_list_rearrange_squash(&todo) < 0) { + ret = error(_("could not check the range for fixups")); + goto out; + } + + for (i = 0; i < todo.nr; i++) { + const char *message, *subject_start, *target; + char *subject; + size_t sublen; + + message = repo_logmsg_reencode(repo, todo.items[i].commit, + NULL, NULL); + sublen = find_commit_subject(message, &subject_start); + + if (todo.items[i].command != TODO_PICK) { + if (!past_oldest_group && + starts_with(subject_start, "amend! ")) + *amend_source = todo.items[i].commit; + repo_unuse_commit_buffer(repo, todo.items[i].commit, message); + continue; + } + if (i) + past_oldest_group = true; + + subject = xmemdupz(subject_start, sublen); + target = autosquash_target(subject); + if (target != subject) { + nr_dangling++; + if (!dangling_target) { + dangling_target = xstrdup(target); + dangling_subject = xstrdup(subject); + } else if (strcmp(dangling_target, target)) { + mixed_target = true; + } + if (starts_with(subject, "amend! ")) + last_amend = todo.items[i].commit; + } + free(subject); + repo_unuse_commit_buffer(repo, todo.items[i].commit, message); + } + + all_fixups_one_target = nr_dangling == todo.nr && !mixed_target; + if (nr_dangling && !all_fixups_one_target) { + ret = error(_("cannot squash '%s': its target is not in the " + "range"), dangling_subject); + } else { + if (last_amend) + *msg_source = last_amend; + ret = 0; + } + +out: + free(dangling_subject); + free(dangling_target); + todo_list_release(&todo); + replay_opts_release(&opts); + 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 bool amend_replaces_target(struct todo_list *todo, int target) +{ + int i; + + for (i = target + 1; i < todo->nr && + todo->items[i].command != TODO_PICK; i++) { + if (todo->items[i].command == TODO_SQUASH) + return false; + if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG) + return true; + } + return false; +} + +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; + struct todo_list todo = TODO_LIST_INIT; + struct replay_opts opts = REPLAY_OPTS_INIT; + int i, nr_commits, 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))) + strbuf_addf(&todo.buf, "pick %s\n", + oid_to_hex(&commit->object.oid)); + + if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 || + todo_list_rearrange_squash(&todo) < 0) { + ret = error(_("could not prepare the squash message")); + goto out; + } + + nr_commits = todo.nr; + for (i = 0; i < nr_commits; i++) { + struct todo_item *item = &todo.items[i]; + const char *message, *body; + size_t commented_len; + bool skip, squashing; + + squashing = item->command == TODO_SQUASH || + (item->flags & TODO_REPLACE_FIXUP_MSG); + if (item->command == TODO_PICK) + skip = amend_replaces_target(&todo, i); + else + skip = !squashing; + + message = repo_logmsg_reencode(repo, item->commit, NULL, NULL); + find_commit_subject(message, &body); + + if (skip) + commented_len = strlen(body); + else if (squashing) + commented_len = squash_subject_comment_len(body, 1); + else + commented_len = 0; + + if (!i) + add_squash_combination_header(out, nr_commits); + strbuf_addch(out, '\n'); + add_squash_message_header(out, i + 1, skip); + strbuf_addstr(out, "\n\n"); + strbuf_add_commented_lines(out, body, commented_len, comment_line_str); + strbuf_addstr(out, body + commented_len); + strbuf_complete_line(out); + + repo_unuse_commit_buffer(repo, item->commit, message); + } + + ret = 0; + +out: + todo_list_release(&todo); + replay_opts_release(&opts); + 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, *msg_source, + *amend_source; + const struct object_id *base_tree_oid, *tip_tree_oid; + const char *message_template = NULL; + struct commit_list *parents = NULL; + struct rev_info revs = { 0 }; + int ret; + + argc = parse_options(argc, argv, prefix, options, usage, + PARSE_OPT_KEEP_UNKNOWN_OPT); + if (!argc) { + ret = error(_("command expects a 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, &base, &oldest, &tip, + &interior); + if (ret < 0) + goto out; + + ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source, + &amend_source); + if (ret < 0) + goto out; + if (amend_source) { + const char *amend_message, *body; + + amend_message = repo_logmsg_reencode(repo, amend_source, + NULL, NULL); + find_commit_subject(amend_message, &body); + body = skip_blank_lines(body + commit_subject_length(body)); + strbuf_addstr(&message, body); + message_template = message.buf; + repo_unuse_commit_buffer(repo, amend_source, amend_message); + } + + 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) { + strbuf_reset(&message); + ret = build_squash_message(repo, base, tip, &message); + if (ret < 0) + goto out; + message_template = message.buf; + } + + 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", msg_source, message_template, + parents, + base_tree_oid, tip_tree_oid, &rewritten, flags); + if (ret < 0) { + ret = error(_("failed writing squashed commit")); + goto out; + } + + strbuf_addstr(&reflog_msg, "squash: updating "); + strbuf_join_argv(&reflog_msg, argc, argv, ' '); + + 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; +} + static int update_worktree(struct repository *repo, const struct commit *old_head, const struct commit *new_head, @@ -1194,6 +1668,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; @@ -1202,6 +1677,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(), }; diff --git a/sequencer.c b/sequencer.c index 82ab3c536f..642dfa9d91 100644 --- a/sequencer.c +++ b/sequencer.c @@ -1880,18 +1880,38 @@ static int is_pick_or_similar(enum todo_command command) } } -enum todo_item_flags { - TODO_EDIT_MERGE_MSG = (1 << 0), - TODO_REPLACE_FIXUP_MSG = (1 << 1), - TODO_EDIT_FIXUP_MSG = (1 << 2), -}; - static const char first_commit_msg_str[] = N_("This is the 1st commit message:"); static const char nth_commit_msg_fmt[] = N_("This is the commit message #%d:"); static const char skip_first_commit_msg_str[] = N_("The 1st commit message will be skipped:"); static const char skip_nth_commit_msg_fmt[] = N_("The commit message #%d will be skipped:"); static const char combined_commit_msg_fmt[] = N_("This is a combination of %d commits."); +void add_squash_combination_header(struct strbuf *buf, int n) +{ + strbuf_addf(buf, "%s ", comment_line_str); + strbuf_addf(buf, _(combined_commit_msg_fmt), n); +} + +void add_squash_message_header(struct strbuf *buf, int n, int skip) +{ + strbuf_addf(buf, "%s ", comment_line_str); + if (n == 1) + strbuf_addstr(buf, skip ? _(skip_first_commit_msg_str) : + _(first_commit_msg_str)); + else + strbuf_addf(buf, skip ? _(skip_nth_commit_msg_fmt) : + _(nth_commit_msg_fmt), n); +} + +size_t squash_subject_comment_len(const char *body, int squashing) +{ + if (starts_with(body, "amend!") || + (squashing && (starts_with(body, "squash!") || + starts_with(body, "fixup!")))) + return commit_subject_length(body); + return 0; +} + static int is_fixup_flag(enum todo_command command, unsigned flag) { return command == TODO_FIXUP && ((flag & TODO_REPLACE_FIXUP_MSG) || @@ -2005,20 +2025,13 @@ static int append_squash_message(struct strbuf *buf, const char *body, { struct replay_ctx *ctx = opts->ctx; const char *fixup_msg; - size_t commented_len = 0, fixup_off; - /* - * amend is non-interactive and not normally used with fixup! - * or squash! commits, so only comment out those subjects when - * squashing commit messages. - */ - if (starts_with(body, "amend!") || - ((command == TODO_SQUASH || seen_squash(ctx)) && - (starts_with(body, "squash!") || starts_with(body, "fixup!")))) - commented_len = commit_subject_length(body); + size_t commented_len, fixup_off; - strbuf_addf(buf, "\n%s ", comment_line_str); - strbuf_addf(buf, _(nth_commit_msg_fmt), - ++ctx->current_fixup_count + 1); + commented_len = squash_subject_comment_len(body, + command == TODO_SQUASH || seen_squash(ctx)); + + strbuf_addch(buf, '\n'); + add_squash_message_header(buf, ++ctx->current_fixup_count + 1, 0); strbuf_addstr(buf, "\n\n"); strbuf_add_commented_lines(buf, body, commented_len, comment_line_str); /* buf->buf may be reallocated so store an offset into the buffer */ @@ -2083,9 +2096,8 @@ static int update_squash_messages(struct repository *r, eol = !starts_with(buf.buf, comment_line_str) ? buf.buf : strchrnul(buf.buf, '\n'); - strbuf_addf(&header, "%s ", comment_line_str); - strbuf_addf(&header, _(combined_commit_msg_fmt), - ctx->current_fixup_count + 2); + add_squash_combination_header(&header, + ctx->current_fixup_count + 2); strbuf_splice(&buf, 0, eol - buf.buf, header.buf, header.len); strbuf_release(&header); if (is_fixup_flag(command, flag) && !seen_squash(ctx)) @@ -2109,12 +2121,9 @@ static int update_squash_messages(struct repository *r, repo_unuse_commit_buffer(r, head_commit, head_message); return error(_("cannot write '%s'"), rebase_path_fixup_msg()); } - strbuf_addf(&buf, "%s ", comment_line_str); - strbuf_addf(&buf, _(combined_commit_msg_fmt), 2); - strbuf_addf(&buf, "\n%s ", comment_line_str); - strbuf_addstr(&buf, is_fixup_flag(command, flag) ? - _(skip_first_commit_msg_str) : - _(first_commit_msg_str)); + add_squash_combination_header(&buf, 2); + strbuf_addch(&buf, '\n'); + add_squash_message_header(&buf, 1, is_fixup_flag(command, flag)); strbuf_addstr(&buf, "\n\n"); if (is_fixup_flag(command, flag)) strbuf_add_commented_lines(&buf, body, strlen(body), @@ -2133,9 +2142,8 @@ static int update_squash_messages(struct repository *r, if (command == TODO_SQUASH || is_fixup_flag(command, flag)) { res = append_squash_message(&buf, body, command, opts, flag); } else if (command == TODO_FIXUP) { - strbuf_addf(&buf, "\n%s ", comment_line_str); - strbuf_addf(&buf, _(skip_nth_commit_msg_fmt), - ++ctx->current_fixup_count + 1); + strbuf_addch(&buf, '\n'); + add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1); strbuf_addstr(&buf, "\n\n"); strbuf_add_commented_lines(&buf, body, strlen(body), comment_line_str); diff --git a/sequencer.h b/sequencer.h index 64a9c7fb1b..b01f897020 100644 --- a/sequencer.h +++ b/sequencer.h @@ -119,6 +119,13 @@ enum todo_command { TODO_COMMENT }; +/* Bits for the "flags" member of struct todo_item */ +enum todo_item_flags { + TODO_EDIT_MERGE_MSG = (1 << 0), + TODO_REPLACE_FIXUP_MSG = (1 << 1), + TODO_EDIT_FIXUP_MSG = (1 << 2), +}; + struct todo_item { enum todo_command command; struct commit *commit; @@ -208,6 +215,29 @@ int todo_list_rearrange_squash(struct todo_list *todo_list); */ void append_signoff(struct strbuf *msgbuf, size_t ignore_footer, unsigned flag); +/* + * Append the "This is a combination of N commits." banner that "git rebase + * -i" writes at the top of a squashed commit's message, commented out with + * the comment character. + */ +void add_squash_combination_header(struct strbuf *buf, int n); + +/* + * Append the header (1-based N) that "git rebase -i" writes above each message + * when squashing, commented out with the comment character. With SKIP it reads + * "The ... commit message will be skipped" for a message that is dropped (a + * fixup), otherwise "This is the ... commit message". + */ +void add_squash_message_header(struct strbuf *buf, int n, int skip); + +/* + * Return the length of the leading subject of BODY when it should be commented + * out in a squash message, or 0 otherwise. An "amend!" subject always + * qualifies; "squash!" and "fixup!" subjects only when SQUASHING, since a + * plain fixup chain keeps them. + */ +size_t squash_subject_comment_len(const char *body, int squashing); + void append_conflicts_hint(struct index_state *istate, struct strbuf *msgbuf, enum commit_msg_cleanup_mode cleanup_mode); enum commit_msg_cleanup_mode get_cleanup_mode(const char *cleanup_arg, diff --git a/t/meson.build b/t/meson.build index d8161c368b..797c5a75d1 100644 --- a/t/meson.build +++ b/t/meson.build @@ -405,6 +405,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', diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh new file mode 100755 index 0000000000..6fce924327 --- /dev/null +++ b/t/t3455-history-squash.sh @@ -0,0 +1,770 @@ +#!/bin/sh + +test_description='tests for git-history squash subcommand' + +. ./test-lib.sh + +stage_file () { + printf "%s\n" "$1" >file && + git add file +} + +commit_with_message () { + printf "%b" "$1" >msg && + git commit --allow-empty -qF msg +} + +check_commit_count () { + git rev-list --count "$1" >actual && + echo "$2" >expect && + test_cmp expect actual +} + +check_log_subjects () { + git log --format="%s" "$1" >actual && + cat >expect && + test_cmp expect actual +} + +check_log_messages () { + git log --format="%B" "$1" >actual && + cat >expect && + test_cmp expect actual +} + +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 "expects a revision range" err +' + +test_expect_success 'errors on an empty range' ' + test_must_fail git history squash HEAD..HEAD 2>err && + test_grep "the revision 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 "not a .*range" err && + test_must_fail git history squash HEAD~1 2>err && + test_grep "not a .*range" err +' + +test_expect_success 'errors on a range holding a single commit' ' + git reset --hard three && + head_before=$(git rev-parse HEAD) && + + test_must_fail git history squash "HEAD^!" 2>err && + test_grep "single commit; nothing to squash" err && + test_cmp_rev "$head_before" HEAD +' + +test_expect_success 'accepts multiple revision arguments with an exclusion' ' + git reset --hard three && + git branch -f keep HEAD~2 && + tip_tree=$(git rev-parse HEAD^{tree}) && + + git history squash start..HEAD ^keep && + + git reflog -1 --format=%gs >actual && + echo "squash: updating start..HEAD ^keep" >expect && + test_cmp expect actual && + + check_log_subjects start..HEAD <<-\EOF && + two + one + EOF + test_cmp_rev keep HEAD~1 && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" && + + git branch -D keep +' + +test_expect_success 'squashes a branch the current branch is not on' ' + git reset --hard three && + main=$(git symbolic-ref --short HEAD) && + head_before=$(git rev-parse HEAD) && + git checkout -b off-history start && + test_commit --no-tag off-one off a && + test_commit --no-tag off-two off b && + git checkout "$main" && + + git history squash start..off-history && + + check_commit_count start..off-history 1 && + test_cmp_rev "$head_before" HEAD && + + git branch -D off-history +' + +test_expect_success 'squashes a range into a single commit without changing the tree' ' + 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)" && + check_commit_count start..HEAD 1 && + test_cmp_rev start HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" && + check_log_subjects -1 <<-\EOF && + one + EOF + git reflog >reflog && + test_grep "squash: updating" reflog +' + +test_expect_success 'sanitizes rev-list walk options, before and after --' ' + git reset --hard three && + tip_tree=$(git rev-parse HEAD^{tree}) && + + git history squash --date-order start.. 2>err && + test_grep "ignoring rev-list options" err && + test_cmp_rev start HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" && + + git reset --hard three && + git history squash -- --reverse start.. 2>err && + test_grep "ignoring rev-list options" err && + test_cmp_rev start HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" +' + +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 && + + check_log_subjects start..HEAD <<-\EOF && + three + one + EOF + + 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.." && + + check_commit_count "$root..HEAD" 1 && + test_cmp_rev "$root" HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" +' + + +test_expect_success 'folds fixups whose target is in the range' ' + git reset --hard start && + test_commit --no-tag target file b && + git commit --allow-empty -m "fixup! target" && + git commit --allow-empty -m "fixup! target" && + test_commit --no-tag later file c && + + git history squash start.. && + + check_commit_count start..HEAD 1 && + check_log_subjects -1 <<-\EOF + target + EOF +' + +test_expect_success 'refuses a below-range fixup! after an in-range commit' ' + git reset --hard start && + test_commit --no-tag inside file b && + test_commit --no-tag "fixup! outside" file c && + head_before=$(git rev-parse HEAD) && + + test_must_fail git history squash start.. 2>err && + test_grep "target is not in the range" err && + test_cmp_rev "$head_before" HEAD +' + +test_expect_success 'combines a run of fixups for one commit below the range' ' + git reset --hard start && + stage_file b && git commit -m "fixup! base" && + stage_file c && git commit -m "fixup! base" && + + git history squash start.. && + + check_commit_count start..HEAD 1 && + check_log_subjects -1 <<-\EOF + fixup! base + EOF +' + +test_expect_success 'combining below-range fixups keeps the last amend! message' ' + git reset --hard start && + stage_file b && git commit -m "fixup! base" && + stage_file c && + commit_with_message "amend! base\n\namended body\n" && + + git history squash start.. && + + check_commit_count start..HEAD 1 && + check_log_messages -1 <<-\EOF + amend! base + + amended body + + EOF +' + +test_expect_success 'refuses fixups for two different commits below the range' ' + git reset --hard start && + stage_file b && git commit -m "fixup! aaa" && + stage_file c && git commit -m "fixup! bbb" && + head_before=$(git rev-parse HEAD) && + + test_must_fail git history squash start.. 2>err && + test_grep "target is not in the range" err && + test_cmp_rev "$head_before" HEAD +' + +test_expect_success 'the last amend! for the oldest commit replaces its message' ' + git reset --hard start && + test_commit --no-tag marker-oldest file b && + git commit --allow-empty -m "squash! marker-oldest" && + commit_with_message "amend! marker-oldest\n\nearlier message\n" && + commit_with_message \ + "amend! marker-oldest\n\namended subject\n\namended body\n" && + test_commit --no-tag marker-later file c && + commit_with_message "amend! marker-later\n\nwrong message\n" && + + git history squash start.. && + + check_commit_count start..HEAD 1 && + check_log_messages -1 <<-\EOF + amended subject + + amended body + + EOF +' + +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 " >expect && + test_cmp expect actual +' + +test_expect_success '--reedit-message offers every folded-in message' ' + git reset --hard start && + stage_file b && + 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 && + cat "$1" >edited && + echo combined >"$1" + EOF + test_set_editor "$(pwd)/editor" && + git history squash --reedit-message start.. && + + cat >expect <<-EOF && + # This is a combination of 3 commits. + # This is the 1st commit message: + + re-one subject + + re-one body line + + # This is the commit message #2: + + re-two + + # This is the commit message #3: + + re-three + + # Please enter the commit message for the squash changes. Lines starting + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit. + # Changes to be committed: + # modified: file + # + EOF + test_cmp expect edited && + check_log_subjects -1 <<-\EOF + combined + EOF +' + +test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' ' + git reset --hard start && + test_commit --no-tag mark-base file b && + stage_file c && + commit_with_message "fixup! mark-base\n\nfixup body\n" && + stage_file d && + commit_with_message "squash! mark-base\n\nsquash remark\n" && + stage_file e && + commit_with_message "amend! mark-base\n\namended message\n" && + + write_script editor <<-\EOF && + cat "$1" >edited + EOF + test_set_editor "$(pwd)/editor" && + git history squash --reedit-message start.. && + + cat >expect <<-EOF && + # This is a combination of 4 commits. + # This is the 1st commit message: + + mark-base + + # The commit message #2 will be skipped: + + # fixup! mark-base + # + # fixup body + + # This is the commit message #3: + + # squash! mark-base + + squash remark + + # This is the commit message #4: + + # amend! mark-base + + amended message + + # Please enter the commit message for the squash changes. Lines starting + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit. + # Changes to be committed: + # modified: file + # + EOF + test_cmp expect edited && + check_log_messages -1 <<-\EOF + mark-base + + squash remark + + amended message + + EOF +' + +test_expect_success '--reedit-message groups fixups under their targets' ' + git reset --hard start && + test_commit --no-tag alpha file a1 && + test_commit --no-tag beta file b1 && + stage_file a2 && + commit_with_message "fixup! alpha\n" && + stage_file b2 && + commit_with_message "fixup! beta\n" && + + write_script editor <<-\EOF && + cat "$1" >edited + EOF + test_set_editor "$(pwd)/editor" && + git history squash --reedit-message start.. && + + cat >expect <<-EOF && + # This is a combination of 4 commits. + # This is the 1st commit message: + + alpha + + # The commit message #2 will be skipped: + + # fixup! alpha + + # This is the commit message #3: + + beta + + # The commit message #4 will be skipped: + + # fixup! beta + + # Please enter the commit message for the squash changes. Lines starting + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit. + # Changes to be committed: + # modified: file + # + EOF + test_cmp expect edited +' + +test_expect_success '--reedit-message lets amend! replace its target message' ' + git reset --hard start && + test_commit --no-tag mark-base file b && + stage_file c && + commit_with_message "amend! mark-base\n\namended message\n" && + stage_file d && + commit_with_message "squash! mark-base\n\nsquash remark\n" && + + write_script editor <<-\EOF && + cat "$1" >edited + EOF + test_set_editor "$(pwd)/editor" && + git history squash --reedit-message start.. && + + cat >expect <<-EOF && + # This is a combination of 3 commits. + # The 1st commit message will be skipped: + + # mark-base + + # This is the commit message #2: + + # amend! mark-base + + amended message + + # This is the commit message #3: + + # squash! mark-base + + squash remark + + # Please enter the commit message for the squash changes. Lines starting + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit. + # Changes to be committed: + # modified: file + # + EOF + test_cmp expect edited && + check_log_messages -1 <<-\EOF + amended message + + squash remark + + EOF +' + +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 '--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.. && + + check_commit_count start..HEAD 1 && + 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 && + head_before=$(git rev-parse HEAD) && + + 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 && + test_cmp_rev "$head_before" HEAD && + + 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.. && + + check_commit_count start..HEAD 1 && + 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 && + main=$(git symbolic-ref --short HEAD) && + test_commit --no-tag before-side file b && + git checkout -b inner-side && + test_commit --no-tag on-inner-side inner x && + git checkout "$main" && + 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.. && + + check_commit_count start..HEAD 1 && + check_log_subjects -1 <<-\EOF && + before-side + EOF + 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 && + main=$(git symbolic-ref --short HEAD) && + git checkout -b base-fork-side && + test_commit --no-tag base-fork-side side x && + git checkout "$main" && + 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.. && + + check_commit_count start..HEAD 1 && + test_cmp_rev start HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" && + test_path_is_file side +' + +test_expect_success 'refuses a merge whose other parent is outside the range' ' + git reset --hard start && + main=$(git symbolic-ref --short HEAD) && + git checkout -b outside-parent && + test_commit --no-tag outside-parent outside x && + git checkout "$main" && + test_commit --no-tag outside-main file b && + base=$(git rev-parse HEAD) && + test_commit --no-tag outside-mid file c && + git merge --no-ff -m "merge outside-parent" outside-parent && + git branch -D outside-parent && + merged=$(git rev-parse HEAD) && + + test_must_fail git history squash "$base.." 2>err && + test_grep "more than one base" err && + test_cmp_rev "$merged" HEAD +' + +test_expect_success 'folds a range whose tip is a merge commit' ' + git reset --hard start && + main=$(git symbolic-ref --short HEAD) && + test_commit --no-tag tipmerge-base file b && + git checkout -b tipmerge-side && + test_commit --no-tag tipmerge-side side x && + git checkout "$main" && + 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.. && + + check_commit_count start..HEAD 1 && + 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 && + main=$(git symbolic-ref --short HEAD) && + git checkout -b basemerge-side && + test_commit --no-tag basemerge-side side x && + git checkout "$main" && + 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.." && + + check_commit_count "$base..HEAD" 1 && + test_cmp_rev "$base" HEAD^ && + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" +' + +test_expect_success 'folds a range with two interior merges' ' + git reset --hard start && + main=$(git symbolic-ref --short HEAD) && + 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 "$main" && + 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 "$main" && + 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.. && + + check_commit_count start..HEAD 1 && + 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.. && + + check_commit_count start..HEAD 1 && + 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.. && + + check_commit_count start..HEAD 1 && + 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-one file b && + test_commit --no-tag desc-two file c && + 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 d && + 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