diff --git a/builtin/history.c b/builtin/history.c
index b050246281..a7bf583862 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1006,6 +1006,9 @@ out:
return ret;
}
+#define SQUASH_SEEN (1u << 11)
+#define SQUASH_TIP (1u << 12)
+
static int setup_squash_revisions(struct repository *repo,
int argc, const char **argv,
struct rev_info *revs)
@@ -1052,6 +1055,104 @@ static int setup_squash_revisions(struct repository *repo,
return error(_("not a '..' revision range"));
}
+/*
+ * Resolve a revision range into its oldest commit and single tip. Every
+ * parent after the oldest commit must either be selected or also be a parent
+ * of the oldest commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+ int argc, const char **argv,
+ struct commit **oldest_out,
+ struct commit **tip_out)
+{
+ struct rev_info revs;
+ struct commit *commit, *oldest = NULL, *tip = NULL;
+ int ret, tip_count = 0;
+ bool walk_started = false;
+
+ ret = setup_squash_revisions(repo, argc, argv, &revs);
+ if (ret < 0)
+ goto out;
+
+ if (prepare_revision_walk(&revs) < 0) {
+ ret = error(_("error preparing revisions"));
+ goto out;
+ }
+ walk_started = true;
+ while ((commit = get_revision(&revs))) {
+ struct commit_list *p;
+
+ if (!commit->parents) {
+ ret = error(_("cannot squash down to root commit"));
+ goto out;
+ }
+ for (p = commit->parents; oldest && p; p = p->next) {
+ struct commit_list *q;
+ struct object *o;
+ bool seen;
+
+ if (repo_parse_commit(repo, p->item)) {
+ ret = error(_("cannot parse commit"));
+ goto out;
+ }
+ o = &p->item->object;
+ seen = o->flags & SQUASH_SEEN;
+ /*
+ * Allow parents that match the parents of the
+ * squashed commit.
+ */
+ for (q = oldest->parents; !seen && q; q = q->next)
+ if (p->item == q->item)
+ seen = true;
+ if (!seen) {
+ ret = error(_("parent %s of commit %s is "
+ "outside the revision range"),
+ repo_find_unique_abbrev(repo, &o->oid,
+ DEFAULT_ABBREV),
+ repo_find_unique_abbrev(repo,
+ &commit->object.oid,
+ DEFAULT_ABBREV));
+ goto out;
+ }
+ if (o->flags & SQUASH_TIP) {
+ tip_count--;
+ o->flags &= ~SQUASH_TIP;
+ }
+ }
+ if (!oldest)
+ oldest = commit;
+ tip = commit;
+ tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
+ tip_count++;
+ }
+
+ if (!tip_count) {
+ ret = error(_("the revision range is empty"));
+ goto out;
+ } else if (tip_count != 1) {
+ ret = error(_("the revision range contains more than one tip "
+ "commit"));
+ 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");
+ }
+
+ *oldest_out = oldest;
+ *tip_out = tip;
+ ret = 0;
+
+out:
+ clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);
+ if (walk_started)
+ reset_revision_walk();
+ release_revisions(&revs);
+ return ret;
+}
+
static int cmd_history_squash(int argc,
const char **argv,
const char *prefix,
@@ -1074,26 +1175,20 @@ static int cmd_history_squash(int argc,
N_("edit the commit message")),
OPT_END(),
};
- struct rev_info revs = { 0 };
+ struct commit *oldest, *tip;
int ret;
argc = parse_options(argc, argv, prefix, options, usage,
PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
- if (argc < 2) {
- ret = error(_("command expects a revision range"));
- goto out;
- }
+ if (argc < 2)
+ return error(_("command expects a revision range"));
repo_config(repo, git_default_config, NULL);
- ret = setup_squash_revisions(repo, argc, argv, &revs);
+ ret = resolve_squash_range(repo, argc, argv, &oldest, &tip);
if (ret < 0)
- goto out;
+ return ret;
- ret = error(_("squashing commits is not implemented yet"));
-
-out:
- release_revisions(&revs);
- return ret;
+ return error(_("squashing commits is not implemented yet"));
}
static int update_worktree(struct repository *repo,
diff --git a/object.h b/object.h
index 8fb03ff90a..dcf30156ca 100644
--- a/object.h
+++ b/object.h
@@ -74,6 +74,7 @@ void object_array_init(struct object_array *array);
* bisect.c: 16
* bundle.c: 16
* http-push.c: 11-----14
+ * builtin/history.c: 1112
* commit-graph.c: 15
* commit-reach.c: 16-------20
* builtin/last-modified.c: 1617
diff --git a/t/meson.build b/t/meson.build
index 8ae6ab6c5f..89cff16405 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..df92aa4f6c
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,65 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history' '
+ test_commit base file a start &&
+ test_commit one file b &&
+ test_commit 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' '
+ test_must_fail git history squash "HEAD^!" 2>err &&
+ test_grep "single commit; nothing to squash" err
+'
+
+test_expect_success 'rejects a root commit' '
+ oid=$(git commit-tree -m root three^{tree}) &&
+ test_must_fail git history squash \
+ --ancestry-path=start "$oid..three" 2>err &&
+ test_grep "cannot squash down to root commit" err
+'
+
+test_expect_success 'rejects multiple tips' '
+ oid=$(git commit-tree -m tip -p start^0 three^{tree}) &&
+ test_must_fail git history squash ^start "$oid" three~1 2>err &&
+ test_grep "revision range contains more than one tip" err
+'
+
+test_expect_success 'rejects a merge parent 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 &&
+
+ test_must_fail git history squash "$base.." 2>err &&
+ test_grep "parent .* of commit .* is outside the revision range" err
+'
+
+test_done