+504
index b592b98393..43714fcb56 100644
--- a/builtin/history.c
+++ b/builtin/history.c
#define USE_THE_REPOSITORY_VARIABLE
#include "builtin.h"
+#include "advice.h"
#include "cache-tree.h"
#include "commit.h"
#include "commit-reach.h"
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 [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>")
static void change_data_free(void *util, const char *str UNUSED)
{
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 **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, *commits = NULL, *iter,
+ **commits_tail = &commits;
+ struct oidset selected = OIDSET_INIT, has_children = OIDSET_INIT;
+ struct strvec args = STRVEC_INIT;
+ bool reaches_root = false;
+ 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 range must name a bottom revision to reparent onto. */
+ for (i = 0; i < revs.cmdline.nr; i++)
+ if (revs.cmdline.rev[i].flags & BOTTOM)
+ break;
+ if (i == revs.cmdline.nr) {
+ ret = error(_("not a '<base>..<tip>' 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. */
+ while ((commit = get_revision(&revs))) {
+ if (commit->object.flags & BOUNDARY) {
+ commit_list_insert(commit, &boundaries);
+ continue;
+ }
+ if (!oldest)
+ oldest = commit;
+ oidset_insert(&selected, &commit->object.oid);
+ commits_tail = commit_list_append(commit, commits_tail);
+ }
+
+ if (!oldest) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ } else if (!commits->next) {
+ ret = error(_("the revision range holds a single commit; "
+ "nothing to squash"));
+ goto out;
+ }
+
+ /*
+ * Find the selected commits that have selected children. The only
+ * remaining commit must be the tip whose tree becomes the result.
+ */
+ for (iter = commits; iter; iter = iter->next) {
+ struct commit_list *p;
+
+ if (!iter->item->parents)
+ reaches_root = true;
+ for (p = iter->item->parents; p; p = p->next)
+ if (oidset_contains(&selected, &p->item->object.oid))
+ oidset_insert(&has_children,
+ &p->item->object.oid);
+ }
+ if (reaches_root) {
+ ret = error(_("the revision range reaches a root commit; "
+ "cannot squash"));
+ goto out;
+ }
+ for (iter = commits; iter; iter = iter->next) {
+ if (oidset_contains(&has_children, &iter->item->object.oid))
+ continue;
+ if (tip) {
+ ret = error(_("the revision range has more than one tip; "
+ "cannot squash"));
+ goto out;
+ }
+ tip = iter->item;
+ }
+
+ /* The range must reach exactly one commit outside it. */
+ if (!boundaries) {
+ ret = error(_("the revision range has no base; cannot squash"));
+ goto out;
+ }
+ base = boundaries->item;
+ for (iter = boundaries; iter; iter = iter->next) {
+ if (iter->item != base) {
+ ret = error(_("the revision range has more than one base; "
+ "cannot squash"));
+ goto out;
+ }
+ }
+
+ for (iter = commits; iter; iter = iter->next)
+ if (iter->item != tip)
+ oidset_insert(interior_out, &iter->item->object.oid);
+
+ *base_out = base;
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ commit_list_free(boundaries);
+ commit_list_free(commits);
+ oidset_clear(&selected);
+ oidset_clear(&has_children);
+ 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 **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;
+
+ *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)
+ *amend_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;
+ int edit = 1;
+ 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_BOOL('e', "edit", &edit,
+ N_("edit the commit message")),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct strbuf message = STRBUF_INIT;
+ struct oidset interior = OIDSET_INIT;
+ struct commit *base = NULL, *oldest = NULL, *tip = NULL, *rewritten,
+ *amend_source = NULL;
+ 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, &amend_source);
+ if (ret < 0)
+ goto out;
+ if (!edit && 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_branch_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 (edit) {
+ ret = build_squash_message(repo, base, tip, &message);
+ if (ret < 0)
+ goto out;
+ message_template = message.buf;
+ flags |= COMMIT_TREE_EDIT_MESSAGE;
+ }
+
+ 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_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,
GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
+ GIT_HISTORY_SQUASH_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
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(),
};