history: add squash subcommand to fold a range

Folding a series of commits into one required either an interactive rebase where each commit after the first was hand-edited to "fixup", or a "git reset --soft" to the merge base followed by "git commit --amend". Add "git history squash <revision-range>" to do this directly. It folds every commit in the range into one commit, preserving the authorship of the oldest commit and taking the tree of the tip, then replays commits above the range. The editor opens with every folded message in the same template used by "git rebase -i --autosquash". With --no-edit, the oldest message is kept, or the last amend! replacement that targets it. A fixup!, squash! or amend! commit is refused unless its target is also in the range, so the fold does not silently absorb a marker intended for another commit. As an exception, a range made up entirely of markers for one target is combined, allowing a batch of related fixups to be consolidated. The range is read like the arguments to "git rev-list", so several revisions such as "HEAD~3..HEAD ^topic" may be given, and rev-list options are accepted too. Walk-order options needed by the fold are restored after setup_revisions(), with a warning when an option changed them. The selected graph must have one actual boundary and one actual tip and must not reach a root. A merge inside the range is folded when all selected history reaches the same boundary; otherwise the range has more than one base and is rejected. By default the command refuses when a local branch points inside the range, because that branch cannot be replayed as a descendant of the result. Tags and remote-tracking refs are left unchanged. Use --update-refs=head to leave interior branches unchanged as well. Inspired-by: Sergey Chernov <serega.morph@gmail.com> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Aug 1, 2026 at 06:53 UTC bc6976b356412831496060145332df2135df5946
7 files changed +1324 -2
Documentation/config/advice.adoc
+4
@@ -59,6 +59,10 @@ all advice messages.
59 forceDeleteBranch::
60 Shown when the user tries to delete a not fully merged
61 branch without the force option set.
62 + historyUpdateRefs::
63 + Shown when `git history squash` refuses because a ref points
64 + into the range being folded, to tell the user about
65 + `--update-refs=head`.
66 ignoredHook::
67 Shown when a hook is ignored because the hook is not
68 set as executable.
Documentation/git-history.adoc
+55 -2
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
12 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
13 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
14 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
15 +git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
16
17 DESCRIPTION
18 -----------
@@ -43,8 +44,11 @@ at once.
44 LIMITATIONS
45 -----------
46
46 -This command does not (yet) work with histories that contain merges. You
47 -should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
47 +This command does not (yet) replay merge commits onto the rewritten
48 +history: if a commit that would be replayed is a merge, the operation is
49 +rejected, and you should use linkgit:git-rebase[1] with the
50 +`--rebase-merges` flag instead. The `squash` subcommand can still fold a
51 +merge that lies inside the range, as long as the range has a single base.
52
53 Furthermore, the command does not support operations that can result in merge
54 conflicts. This limitation is by design as history rewrites are not intended to
@@ -113,6 +117,49 @@ linkgit:gitglossary[7].
117 It is invalid to select either all or no hunks, as that would lead to
118 one of the commits becoming empty.
119
120 +`squash <revision-range>`::
121 + Fold all commits in _<revision-range>_ into the oldest commit of that
122 + range. The resulting commit keeps the oldest commit's authorship and
123 + takes the tree of the range's newest commit, so the whole range
124 + collapses into a single commit. Commits above the range are replayed
125 + on top of the result.
126 ++
127 +The range is given in the usual `<base>..<tip>` form, where _<base>_ is
128 +the commit just below the oldest commit to squash. For example, `git
129 +history squash HEAD~3..HEAD` folds the three most recent commits into
130 +one, and `git history squash HEAD~5..HEAD~2` squashes an interior range
131 +while leaving the two newest commits in place. Several revisions may be
132 +given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
133 +already on `topic`. Rev-list options may also be given, but any that would
134 +change how the range is walked are overridden with a warning.
135 ++
136 +An editor opens pre-filled with the messages of all the folded commits so
137 +you can combine them. With `--no-edit`, the oldest commit's message is
138 +preserved instead, except that an `amend!` commit targeting it replaces its
139 +message. A merge commit inside the range is folded like any other, but the
140 +range must have a single base and a single tip. A range that reaches more
141 +than one entry point (for example a side branch that forked before the range
142 +and was later merged into it), or that selects two unmerged tips, is rejected.
143 ++
144 +A `fixup!`, `squash!`, or `amend!` commit is refused unless the commit it
145 +targets is also in the range, so the fold does not silently absorb a
146 +marker meant for a commit outside it. As an exception, a range made up entirely
147 +of markers for one target is combined into a single commit. With `--no-edit`,
148 +the last `amend!` message is used if there is one.
149 ++
150 +The template mirrors `git rebase -i --autosquash`: each `fixup!`, `squash!`,
151 +or `amend!` is grouped under the commit it targets rather than shown in
152 +commit order. A `fixup!` message is dropped (commented out in full), a
153 +`squash!` keeps its body with only the marker subject commented, and an
154 +`amend!` replaces its target's message, unless a `squash!` folded into that
155 +target first, in which case it keeps its body like a `squash!`.
156 ++
157 +A local branch that points at a commit inside the range cannot be rewritten
158 +as a descendant of the result, so with the default `--update-refs=branches`
159 +the command refuses. Rerun with `--update-refs=head` to rewrite only the
160 +current branch and leave such branches pointing at the old commits. Tags and
161 +remote-tracking refs are always left unchanged.
162 +
163 OPTIONS
164 -------
165
@@ -122,6 +169,12 @@ OPTIONS
169 objects will be written into the repository, so applying these printed
170 ref updates is generally safe.
171
172 +`--edit`::
173 +`--no-edit`::
174 + For `squash`, open an editor to combine the messages of the folded commits.
175 + This is the default; use `--no-edit` to keep the selected message
176 + without opening an editor.
177 +
178 `--reedit-message`::
179 Open an editor to modify the target commit's message.
180
advice.c
+1
@@ -58,6 +58,7 @@ static struct {
58 [ADVICE_FETCH_SHOW_FORCED_UPDATES] = { "fetchShowForcedUpdates" },
59 [ADVICE_FORCE_DELETE_BRANCH] = { "forceDeleteBranch" },
60 [ADVICE_GRAFT_FILE_DEPRECATED] = { "graftFileDeprecated" },
61 + [ADVICE_HISTORY_UPDATE_REFS] = { "historyUpdateRefs" },
62 [ADVICE_IGNORED_HOOK] = { "ignoredHook" },
63 [ADVICE_IMPLICIT_IDENTITY] = { "implicitIdentity" },
64 [ADVICE_MERGE_CONFLICT] = { "mergeConflict" },
advice.h
+1
@@ -25,6 +25,7 @@ enum advice_type {
25 ADVICE_FETCH_SHOW_FORCED_UPDATES,
26 ADVICE_FORCE_DELETE_BRANCH,
27 ADVICE_GRAFT_FILE_DEPRECATED,
28 + ADVICE_HISTORY_UPDATE_REFS,
29 ADVICE_IGNORED_HOOK,
30 ADVICE_IMPLICIT_IDENTITY,
31 ADVICE_MERGE_CONFLICT,
builtin/history.c
+504
@@ -1,6 +1,7 @@
1 #define USE_THE_REPOSITORY_VARIABLE
2
3 #include "builtin.h"
4 +#include "advice.h"
5 #include "cache-tree.h"
6 #include "commit.h"
7 #include "commit-reach.h"
@@ -34,6 +35,8 @@
35 N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
36 #define GIT_HISTORY_SPLIT_USAGE \
37 N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
38 +#define GIT_HISTORY_SQUASH_USAGE \
39 + N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>")
40
41 static void change_data_free(void *util, const char *str UNUSED)
42 {
@@ -1004,6 +1007,505 @@ out:
1007 return ret;
1008 }
1009
1010 +/*
1011 + * Resolve a "<base>..<tip>" revision range into the base commit just outside
1012 + * the range (which becomes the parent of the squashed commit), the oldest
1013 + * commit contained in the range (whose message the squash reuses), and the
1014 + * range tip (whose tree becomes the result). A merge inside the range is fine,
1015 + * but the range must have a single base and must not reach a root commit.
1016 + */
1017 +static int resolve_squash_range(struct repository *repo,
1018 + const char **argv,
1019 + struct commit **base_out,
1020 + struct commit **oldest_out,
1021 + struct commit **tip_out,
1022 + struct oidset *interior_out)
1023 +{
1024 + struct rev_info revs;
1025 + struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
1026 + struct commit_list *boundaries = NULL, *commits = NULL, *iter,
1027 + **commits_tail = &commits;
1028 + struct oidset selected = OIDSET_INIT, has_children = OIDSET_INIT;
1029 + struct strvec args = STRVEC_INIT;
1030 + bool reaches_root = false;
1031 + size_t i;
1032 + int ret;
1033 +
1034 + repo_init_revisions(repo, &revs, NULL);
1035 + revs.reverse = 1;
1036 + revs.topo_order = 1;
1037 + revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
1038 + revs.simplify_history = 0;
1039 + revs.boundary = 1;
1040 +
1041 + strvec_push(&args, "ignored");
1042 + strvec_push(&args, "--ancestry-path");
1043 + strvec_pushv(&args, argv);
1044 + setup_revisions_from_strvec(&args, &revs, NULL);
1045 + if (args.nr != 1) {
1046 + ret = error(_("unrecognized argument: %s"), args.v[1]);
1047 + goto out;
1048 + }
1049 +
1050 + if (revs.reverse != 1 || revs.topo_order != 1 ||
1051 + revs.sort_order != REV_SORT_IN_GRAPH_ORDER ||
1052 + revs.simplify_history != 0 || revs.boundary != 1) {
1053 + warning(_("ignoring rev-list options that would change how the "
1054 + "range is walked"));
1055 + revs.reverse = 1;
1056 + revs.topo_order = 1;
1057 + revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
1058 + revs.simplify_history = 0;
1059 + revs.boundary = 1;
1060 + }
1061 +
1062 + /* A squash range must name a bottom revision to reparent onto. */
1063 + for (i = 0; i < revs.cmdline.nr; i++)
1064 + if (revs.cmdline.rev[i].flags & BOTTOM)
1065 + break;
1066 + if (i == revs.cmdline.nr) {
1067 + ret = error(_("not a '<base>..<tip>' revision range"));
1068 + goto out;
1069 + }
1070 +
1071 + if (prepare_revision_walk(&revs) < 0) {
1072 + ret = error(_("error preparing revisions"));
1073 + goto out;
1074 + }
1075 +
1076 + /* Set boundary commits aside for the base check below. */
1077 + while ((commit = get_revision(&revs))) {
1078 + if (commit->object.flags & BOUNDARY) {
1079 + commit_list_insert(commit, &boundaries);
1080 + continue;
1081 + }
1082 + if (!oldest)
1083 + oldest = commit;
1084 + oidset_insert(&selected, &commit->object.oid);
1085 + commits_tail = commit_list_append(commit, commits_tail);
1086 + }
1087 +
1088 + if (!oldest) {
1089 + ret = error(_("the revision range is empty"));
1090 + goto out;
1091 + } else if (!commits->next) {
1092 + ret = error(_("the revision range holds a single commit; "
1093 + "nothing to squash"));
1094 + goto out;
1095 + }
1096 +
1097 + /*
1098 + * Find the selected commits that have selected children. The only
1099 + * remaining commit must be the tip whose tree becomes the result.
1100 + */
1101 + for (iter = commits; iter; iter = iter->next) {
1102 + struct commit_list *p;
1103 +
1104 + if (!iter->item->parents)
1105 + reaches_root = true;
1106 + for (p = iter->item->parents; p; p = p->next)
1107 + if (oidset_contains(&selected, &p->item->object.oid))
1108 + oidset_insert(&has_children,
1109 + &p->item->object.oid);
1110 + }
1111 + if (reaches_root) {
1112 + ret = error(_("the revision range reaches a root commit; "
1113 + "cannot squash"));
1114 + goto out;
1115 + }
1116 + for (iter = commits; iter; iter = iter->next) {
1117 + if (oidset_contains(&has_children, &iter->item->object.oid))
1118 + continue;
1119 + if (tip) {
1120 + ret = error(_("the revision range has more than one tip; "
1121 + "cannot squash"));
1122 + goto out;
1123 + }
1124 + tip = iter->item;
1125 + }
1126 +
1127 + /* The range must reach exactly one commit outside it. */
1128 + if (!boundaries) {
1129 + ret = error(_("the revision range has no base; cannot squash"));
1130 + goto out;
1131 + }
1132 + base = boundaries->item;
1133 + for (iter = boundaries; iter; iter = iter->next) {
1134 + if (iter->item != base) {
1135 + ret = error(_("the revision range has more than one base; "
1136 + "cannot squash"));
1137 + goto out;
1138 + }
1139 + }
1140 +
1141 + for (iter = commits; iter; iter = iter->next)
1142 + if (iter->item != tip)
1143 + oidset_insert(interior_out, &iter->item->object.oid);
1144 +
1145 + *base_out = base;
1146 + *oldest_out = oldest;
1147 + *tip_out = tip;
1148 + ret = 0;
1149 +
1150 +out:
1151 + commit_list_free(boundaries);
1152 + commit_list_free(commits);
1153 + oidset_clear(&selected);
1154 + oidset_clear(&has_children);
1155 + reset_revision_walk();
1156 + release_revisions(&revs);
1157 + strvec_clear(&args);
1158 + return ret;
1159 +}
1160 +
1161 +static const char *autosquash_target(const char *subject)
1162 +{
1163 + const char *rest;
1164 +
1165 + while (skip_prefix(subject, "fixup! ", &rest) ||
1166 + skip_prefix(subject, "squash! ", &rest) ||
1167 + skip_prefix(subject, "amend! ", &rest))
1168 + subject = rest;
1169 + return subject;
1170 +}
1171 +
1172 +static int reject_dangling_fixups(struct repository *repo,
1173 + struct commit *base,
1174 + struct commit *tip,
1175 + struct commit **amend_source)
1176 +{
1177 + struct todo_list todo = TODO_LIST_INIT;
1178 + struct replay_opts opts = REPLAY_OPTS_INIT;
1179 + struct rev_info revs;
1180 + struct commit *commit, *last_amend = NULL;
1181 + struct strvec args = STRVEC_INIT;
1182 + char *dangling_subject = NULL, *dangling_target = NULL;
1183 + bool mixed_target = false, all_fixups_one_target;
1184 + bool past_oldest_group = false;
1185 + int i, ret, nr_dangling = 0;
1186 +
1187 + *amend_source = NULL;
1188 +
1189 + repo_init_revisions(repo, &revs, NULL);
1190 + strvec_push(&args, "ignored");
1191 + strvec_push(&args, "--reverse");
1192 + strvec_push(&args, "--topo-order");
1193 + strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
1194 + oid_to_hex(&tip->object.oid));
1195 + setup_revisions_from_strvec(&args, &revs, NULL);
1196 +
1197 + if (prepare_revision_walk(&revs) < 0) {
1198 + ret = error(_("error preparing revisions"));
1199 + goto out;
1200 + }
1201 + while ((commit = get_revision(&revs)))
1202 + strbuf_addf(&todo.buf, "pick %s\n",
1203 + oid_to_hex(&commit->object.oid));
1204 +
1205 + if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
1206 + todo_list_rearrange_squash(&todo) < 0) {
1207 + ret = error(_("could not check the range for fixups"));
1208 + goto out;
1209 + }
1210 +
1211 + for (i = 0; i < todo.nr; i++) {
1212 + const char *message, *subject_start, *target;
1213 + char *subject;
1214 + size_t sublen;
1215 +
1216 + message = repo_logmsg_reencode(repo, todo.items[i].commit,
1217 + NULL, NULL);
1218 + sublen = find_commit_subject(message, &subject_start);
1219 +
1220 + if (todo.items[i].command != TODO_PICK) {
1221 + if (!past_oldest_group &&
1222 + starts_with(subject_start, "amend! "))
1223 + *amend_source = todo.items[i].commit;
1224 + repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
1225 + continue;
1226 + }
1227 + if (i)
1228 + past_oldest_group = true;
1229 +
1230 + subject = xmemdupz(subject_start, sublen);
1231 + target = autosquash_target(subject);
1232 + if (target != subject) {
1233 + nr_dangling++;
1234 + if (!dangling_target) {
1235 + dangling_target = xstrdup(target);
1236 + dangling_subject = xstrdup(subject);
1237 + } else if (strcmp(dangling_target, target)) {
1238 + mixed_target = true;
1239 + }
1240 + if (starts_with(subject, "amend! "))
1241 + last_amend = todo.items[i].commit;
1242 + }
1243 + free(subject);
1244 + repo_unuse_commit_buffer(repo, todo.items[i].commit, message);
1245 + }
1246 +
1247 + all_fixups_one_target = nr_dangling == todo.nr && !mixed_target;
1248 + if (nr_dangling && !all_fixups_one_target) {
1249 + ret = error(_("cannot squash '%s': its target is not in the "
1250 + "range"), dangling_subject);
1251 + } else {
1252 + if (last_amend)
1253 + *amend_source = last_amend;
1254 + ret = 0;
1255 + }
1256 +
1257 +out:
1258 + free(dangling_subject);
1259 + free(dangling_target);
1260 + todo_list_release(&todo);
1261 + replay_opts_release(&opts);
1262 + reset_revision_walk();
1263 + release_revisions(&revs);
1264 + strvec_clear(&args);
1265 + return ret;
1266 +}
1267 +
1268 +struct interior_ref_cb {
1269 + const struct oidset *interior;
1270 + const char *name;
1271 +};
1272 +
1273 +static int find_interior_ref(const struct reference *ref, void *cb_data)
1274 +{
1275 + struct interior_ref_cb *data = cb_data;
1276 +
1277 + if (oidset_contains(data->interior, ref->oid)) {
1278 + data->name = xstrdup(ref->name);
1279 + return 1;
1280 + }
1281 +
1282 + return 0;
1283 +}
1284 +
1285 +static bool amend_replaces_target(struct todo_list *todo, int target)
1286 +{
1287 + int i;
1288 +
1289 + for (i = target + 1; i < todo->nr &&
1290 + todo->items[i].command != TODO_PICK; i++) {
1291 + if (todo->items[i].command == TODO_SQUASH)
1292 + return false;
1293 + if (todo->items[i].flags & TODO_REPLACE_FIXUP_MSG)
1294 + return true;
1295 + }
1296 + return false;
1297 +}
1298 +
1299 +static int build_squash_message(struct repository *repo,
1300 + struct commit *base,
1301 + struct commit *tip,
1302 + struct strbuf *out)
1303 +{
1304 + struct rev_info revs;
1305 + struct commit *commit;
1306 + struct strvec args = STRVEC_INIT;
1307 + struct todo_list todo = TODO_LIST_INIT;
1308 + struct replay_opts opts = REPLAY_OPTS_INIT;
1309 + int i, nr_commits, ret;
1310 +
1311 + repo_init_revisions(repo, &revs, NULL);
1312 + strvec_push(&args, "ignored");
1313 + strvec_push(&args, "--reverse");
1314 + strvec_push(&args, "--topo-order");
1315 + strvec_pushf(&args, "%s..%s", oid_to_hex(&base->object.oid),
1316 + oid_to_hex(&tip->object.oid));
1317 + setup_revisions_from_strvec(&args, &revs, NULL);
1318 +
1319 + if (prepare_revision_walk(&revs) < 0) {
1320 + ret = error(_("error preparing revisions"));
1321 + goto out;
1322 + }
1323 +
1324 + while ((commit = get_revision(&revs)))
1325 + strbuf_addf(&todo.buf, "pick %s\n",
1326 + oid_to_hex(&commit->object.oid));
1327 +
1328 + if (todo_list_parse_insn_buffer(repo, &opts, todo.buf.buf, &todo) < 0 ||
1329 + todo_list_rearrange_squash(&todo) < 0) {
1330 + ret = error(_("could not prepare the squash message"));
1331 + goto out;
1332 + }
1333 +
1334 + nr_commits = todo.nr;
1335 + for (i = 0; i < nr_commits; i++) {
1336 + struct todo_item *item = &todo.items[i];
1337 + const char *message, *body;
1338 + size_t commented_len;
1339 + bool skip, squashing;
1340 +
1341 + squashing = item->command == TODO_SQUASH ||
1342 + (item->flags & TODO_REPLACE_FIXUP_MSG);
1343 + if (item->command == TODO_PICK)
1344 + skip = amend_replaces_target(&todo, i);
1345 + else
1346 + skip = !squashing;
1347 +
1348 + message = repo_logmsg_reencode(repo, item->commit, NULL, NULL);
1349 + find_commit_subject(message, &body);
1350 +
1351 + if (skip)
1352 + commented_len = strlen(body);
1353 + else if (squashing)
1354 + commented_len = squash_subject_comment_len(body, 1);
1355 + else
1356 + commented_len = 0;
1357 +
1358 + if (!i)
1359 + add_squash_combination_header(out, nr_commits);
1360 + strbuf_addch(out, '\n');
1361 + add_squash_message_header(out, i + 1, skip);
1362 + strbuf_addstr(out, "\n\n");
1363 + strbuf_add_commented_lines(out, body, commented_len, comment_line_str);
1364 + strbuf_addstr(out, body + commented_len);
1365 + strbuf_complete_line(out);
1366 +
1367 + repo_unuse_commit_buffer(repo, item->commit, message);
1368 + }
1369 +
1370 + ret = 0;
1371 +
1372 +out:
1373 + todo_list_release(&todo);
1374 + replay_opts_release(&opts);
1375 + reset_revision_walk();
1376 + release_revisions(&revs);
1377 + strvec_clear(&args);
1378 + return ret;
1379 +}
1380 +
1381 +static int cmd_history_squash(int argc,
1382 + const char **argv,
1383 + const char *prefix,
1384 + struct repository *repo)
1385 +{
1386 + const char * const usage[] = {
1387 + GIT_HISTORY_SQUASH_USAGE,
1388 + NULL,
1389 + };
1390 + enum ref_action action = REF_ACTION_DEFAULT;
1391 + enum commit_tree_flags flags = 0;
1392 + int dry_run = 0;
1393 + int edit = 1;
1394 + struct option options[] = {
1395 + OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
1396 + N_("control which refs should be updated"),
1397 + PARSE_OPT_NONEG, parse_ref_action),
1398 + OPT_BOOL('n', "dry-run", &dry_run,
1399 + N_("perform a dry-run without updating any refs")),
1400 + OPT_BOOL('e', "edit", &edit,
1401 + N_("edit the commit message")),
1402 + OPT_END(),
1403 + };
1404 + struct strbuf reflog_msg = STRBUF_INIT;
1405 + struct strbuf message = STRBUF_INIT;
1406 + struct oidset interior = OIDSET_INIT;
1407 + struct commit *base = NULL, *oldest = NULL, *tip = NULL, *rewritten,
1408 + *amend_source = NULL;
1409 + const struct object_id *base_tree_oid, *tip_tree_oid;
1410 + const char *message_template = NULL;
1411 + struct commit_list *parents = NULL;
1412 + struct rev_info revs = { 0 };
1413 + int ret;
1414 +
1415 + argc = parse_options(argc, argv, prefix, options, usage,
1416 + PARSE_OPT_KEEP_UNKNOWN_OPT);
1417 + if (!argc) {
1418 + ret = error(_("command expects a revision range"));
1419 + goto out;
1420 + }
1421 + repo_config(repo, git_default_config, NULL);
1422 +
1423 + if (action == REF_ACTION_DEFAULT)
1424 + action = REF_ACTION_BRANCHES;
1425 +
1426 + ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
1427 + &interior);
1428 + if (ret < 0)
1429 + goto out;
1430 +
1431 + ret = reject_dangling_fixups(repo, base, tip, &amend_source);
1432 + if (ret < 0)
1433 + goto out;
1434 + if (!edit && amend_source) {
1435 + const char *amend_message, *body;
1436 +
1437 + amend_message = repo_logmsg_reencode(repo, amend_source,
1438 + NULL, NULL);
1439 + find_commit_subject(amend_message, &body);
1440 + body = skip_blank_lines(body + commit_subject_length(body));
1441 + strbuf_addstr(&message, body);
1442 + message_template = message.buf;
1443 + repo_unuse_commit_buffer(repo, amend_source, amend_message);
1444 + }
1445 +
1446 + if (action == REF_ACTION_BRANCHES) {
1447 + struct interior_ref_cb cb = { .interior = &interior };
1448 +
1449 + refs_for_each_branch_ref(get_main_ref_store(repo),
1450 + find_interior_ref, &cb);
1451 + if (cb.name) {
1452 + ret = error(_("'%s' points into the squashed range"),
1453 + cb.name);
1454 + advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
1455 + _("Use --update-refs=head to rewrite only "
1456 + "the current branch and leave such refs "
1457 + "untouched."));
1458 + free((char *)cb.name);
1459 + goto out;
1460 + }
1461 + }
1462 +
1463 + if (edit) {
1464 + ret = build_squash_message(repo, base, tip, &message);
1465 + if (ret < 0)
1466 + goto out;
1467 + message_template = message.buf;
1468 + flags |= COMMIT_TREE_EDIT_MESSAGE;
1469 + }
1470 +
1471 + ret = setup_revwalk(repo, action, tip, &revs);
1472 + if (ret < 0)
1473 + goto out;
1474 +
1475 + base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
1476 + tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
1477 + commit_list_append(base, &parents);
1478 +
1479 + ret = commit_tree_ext(repo, "squash", oldest, message_template,
1480 + parents,
1481 + base_tree_oid, tip_tree_oid, &rewritten, flags);
1482 + if (ret < 0) {
1483 + ret = error(_("failed writing squashed commit"));
1484 + goto out;
1485 + }
1486 +
1487 + strbuf_addstr(&reflog_msg, "squash: updating ");
1488 + strbuf_join_argv(&reflog_msg, argc, argv, ' ');
1489 +
1490 + ret = handle_reference_updates(&revs, action, tip, rewritten,
1491 + reflog_msg.buf, dry_run,
1492 + REPLAY_EMPTY_COMMIT_ABORT);
1493 + if (ret < 0) {
1494 + ret = error(_("failed replaying descendants"));
1495 + goto out;
1496 + }
1497 +
1498 + ret = 0;
1499 +
1500 +out:
1501 + strbuf_release(&reflog_msg);
1502 + strbuf_release(&message);
1503 + oidset_clear(&interior);
1504 + commit_list_free(parents);
1505 + release_revisions(&revs);
1506 + return ret;
1507 +}
1508 +
1509 static int update_worktree(struct repository *repo,
1510 const struct commit *old_head,
1511 const struct commit *new_head,
@@ -1192,6 +1694,7 @@ int cmd_history(int argc,
1694 GIT_HISTORY_FIXUP_USAGE,
1695 GIT_HISTORY_REWORD_USAGE,
1696 GIT_HISTORY_SPLIT_USAGE,
1697 + GIT_HISTORY_SQUASH_USAGE,
1698 NULL,
1699 };
1700 parse_opt_subcommand_fn *fn = NULL;
@@ -1200,6 +1703,7 @@ int cmd_history(int argc,
1703 OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
1704 OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
1705 OPT_SUBCOMMAND("split", &fn, cmd_history_split),
1706 + OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
1707 OPT_END(),
1708 };
1709
t/meson.build
+1
@@ -405,6 +405,7 @@ integration_tests = [
405 't3452-history-split.sh',
406 't3453-history-fixup.sh',
407 't3454-history-drop.sh',
408 + 't3455-history-squash.sh',
409 't3500-cherry.sh',
410 't3501-revert-cherry-pick.sh',
411 't3502-cherry-pick-merge.sh',
t/t3455-history-squash.sh new
+758
@@ -0,0 +1,758 @@
1 +#!/bin/sh
2 +
3 +test_description='tests for git-history squash subcommand'
4 +
5 +. ./test-lib.sh
6 +
7 +stage_file () {
8 + printf "%s\n" "$1" >file &&
9 + git add file
10 +}
11 +
12 +commit_with_message () {
13 + printf "%b" "$1" >msg &&
14 + git commit --allow-empty -qF msg
15 +}
16 +
17 +check_commit_count () {
18 + git rev-list --count "$1" >actual &&
19 + echo "$2" >expect &&
20 + test_cmp expect actual
21 +}
22 +
23 +check_log_subjects () {
24 + git log --format="%s" "$1" >actual &&
25 + cat >expect &&
26 + test_cmp expect actual
27 +}
28 +
29 +check_log_messages () {
30 + git log --format="%B" "$1" >actual &&
31 + cat >expect &&
32 + test_cmp expect actual
33 +}
34 +
35 +test_expect_success 'setup linear history touching two files' '
36 + test_commit base file a &&
37 + git tag start &&
38 + GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
39 + test_commit --no-tag one other x &&
40 + test_commit --no-tag two file c &&
41 + test_commit three file d
42 +'
43 +
44 +test_expect_success 'errors on missing range argument' '
45 + test_must_fail git history squash 2>err &&
46 + test_grep "expects a revision range" err
47 +'
48 +
49 +test_expect_success 'errors on an empty range' '
50 + test_must_fail git history squash HEAD..HEAD 2>err &&
51 + test_grep "the revision range is empty" err
52 +'
53 +
54 +test_expect_success 'errors on a single revision that is not a range' '
55 + test_must_fail git history squash HEAD 2>err &&
56 + test_grep "not a .*range" err &&
57 + test_must_fail git history squash HEAD~1 2>err &&
58 + test_grep "not a .*range" err
59 +'
60 +
61 +test_expect_success 'errors on a range holding a single commit' '
62 + git reset --hard three &&
63 + head_before=$(git rev-parse HEAD) &&
64 +
65 + test_must_fail git history squash "HEAD^!" 2>err &&
66 + test_grep "single commit; nothing to squash" err &&
67 + test_cmp_rev "$head_before" HEAD
68 +'
69 +
70 +test_expect_success 'accepts multiple revision arguments with an exclusion' '
71 + git reset --hard three &&
72 + git branch -f keep HEAD~2 &&
73 + tip_tree=$(git rev-parse HEAD^{tree}) &&
74 +
75 + git history squash --no-edit start..HEAD ^keep &&
76 +
77 + git reflog -1 --format=%gs >actual &&
78 + echo "squash: updating start..HEAD ^keep" >expect &&
79 + test_cmp expect actual &&
80 +
81 + check_log_subjects start..HEAD <<-\EOF &&
82 + two
83 + one
84 + EOF
85 + test_cmp_rev keep HEAD~1 &&
86 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
87 +
88 + git branch -D keep
89 +'
90 +
91 +test_expect_success 'refuses a range with more than one tip' '
92 + git reset --hard start &&
93 + main=$(git symbolic-ref --short HEAD) &&
94 + test_commit --no-tag main-one file b &&
95 + test_commit --no-tag main-two file c &&
96 + git checkout -b other-tip start &&
97 + test_commit --no-tag other-tip other d &&
98 + git checkout "$main" &&
99 + main_before=$(git rev-parse HEAD) &&
100 + other_before=$(git rev-parse other-tip) &&
101 +
102 + test_must_fail git history squash ^start HEAD other-tip 2>err &&
103 + test_grep "more than one tip" err &&
104 + test_cmp_rev "$main_before" HEAD &&
105 + test_cmp_rev "$other_before" other-tip &&
106 +
107 + git branch -D other-tip
108 +'
109 +
110 +test_expect_success 'refuses a range that reaches a root commit' '
111 + git reset --hard three &&
112 + root=$(printf "unrelated root\n" |
113 + git commit-tree "$(git rev-parse HEAD^{tree})") &&
114 + head_before=$(git rev-parse HEAD) &&
115 +
116 + test_must_fail git history squash --ancestry-path="$root" \
117 + ^start HEAD "$root" 2>err &&
118 + test_grep "reaches a root commit" err &&
119 + test_cmp_rev "$head_before" HEAD
120 +'
121 +
122 +test_expect_success 'squashes a branch the current branch is not on' '
123 + git reset --hard three &&
124 + main=$(git symbolic-ref --short HEAD) &&
125 + head_before=$(git rev-parse HEAD) &&
126 + git checkout -b off-history start &&
127 + test_commit --no-tag off-one off a &&
128 + test_commit --no-tag off-two off b &&
129 + git checkout "$main" &&
130 +
131 + git history squash --no-edit start..off-history &&
132 +
133 + check_commit_count start..off-history 1 &&
134 + test_cmp_rev "$head_before" HEAD &&
135 +
136 + git branch -D off-history
137 +'
138 +
139 +test_expect_success 'squashes a range preserving its tree and oldest authorship' '
140 + git reset --hard three &&
141 + head_before=$(git rev-parse HEAD) &&
142 + tip_tree=$(git rev-parse HEAD^{tree}) &&
143 +
144 + git history squash --dry-run start.. >out &&
145 + predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
146 + test_cmp_rev "$head_before" HEAD &&
147 +
148 + git history squash start.. &&
149 +
150 + test "$predicted" = "$(git rev-parse HEAD)" &&
151 + check_commit_count start..HEAD 1 &&
152 + test_cmp_rev start HEAD^ &&
153 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
154 + check_log_subjects -1 <<-\EOF &&
155 + one
156 + EOF
157 + git log -1 --format="%an <%ae>" >actual &&
158 + echo "Squasher <squash@example.com>" >expect &&
159 + test_cmp expect actual &&
160 + git reflog >reflog &&
161 + test_grep "squash: updating" reflog
162 +'
163 +
164 +test_expect_success 'sanitizes rev-list walk options, before and after --' '
165 + git reset --hard three &&
166 + tip_tree=$(git rev-parse HEAD^{tree}) &&
167 +
168 + git history squash --no-edit --date-order start.. 2>err &&
169 + test_grep "ignoring rev-list options" err &&
170 + test_cmp_rev start HEAD^ &&
171 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
172 +
173 + git reset --hard three &&
174 + git history squash --no-edit -- --reverse start.. 2>err &&
175 + test_grep "ignoring rev-list options" err &&
176 + test_cmp_rev start HEAD^ &&
177 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
178 +'
179 +
180 +test_expect_success 'squashes an interior range and replays descendants verbatim' '
181 + git reset --hard three &&
182 + final_tree=$(git rev-parse HEAD^{tree}) &&
183 +
184 + git history squash --no-edit start..@~1 &&
185 +
186 + check_log_subjects start..HEAD <<-\EOF &&
187 + three
188 + one
189 + EOF
190 +
191 + test_cmp_rev start HEAD~2 &&
192 + test "$final_tree" = "$(git rev-parse HEAD^{tree})"
193 +'
194 +
195 +test_expect_success 'squashes when the base is the root commit' '
196 + git reset --hard three &&
197 + root=$(git rev-list --max-parents=0 HEAD) &&
198 + tip_tree=$(git rev-parse HEAD^{tree}) &&
199 +
200 + git history squash --no-edit "$root.." &&
201 +
202 + check_commit_count "$root..HEAD" 1 &&
203 + test_cmp_rev "$root" HEAD^ &&
204 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
205 +'
206 +
207 +
208 +test_expect_success 'folds fixups whose target is in the range' '
209 + git reset --hard start &&
210 + test_commit --no-tag target file b &&
211 + git commit --allow-empty -m "fixup! target" &&
212 + git commit --allow-empty -m "fixup! target" &&
213 + test_commit --no-tag later file c &&
214 +
215 + git history squash --no-edit start.. &&
216 +
217 + check_commit_count start..HEAD 1 &&
218 + check_log_subjects -1 <<-\EOF
219 + target
220 + EOF
221 +'
222 +
223 +test_expect_success 'refuses a below-range fixup! after an in-range commit' '
224 + git reset --hard start &&
225 + test_commit --no-tag inside file b &&
226 + test_commit --no-tag "fixup! outside" file c &&
227 + head_before=$(git rev-parse HEAD) &&
228 +
229 + test_must_fail git history squash start.. 2>err &&
230 + test_grep "target is not in the range" err &&
231 + test_cmp_rev "$head_before" HEAD
232 +'
233 +
234 +test_expect_success 'combines a run of fixups for one commit below the range' '
235 + git reset --hard start &&
236 + stage_file b && git commit -m "fixup! base" &&
237 + stage_file c && git commit -m "fixup! base" &&
238 +
239 + git history squash --no-edit start.. &&
240 +
241 + check_commit_count start..HEAD 1 &&
242 + check_log_subjects -1 <<-\EOF
243 + fixup! base
244 + EOF
245 +'
246 +
247 +test_expect_success 'combining below-range markers offers every message' '
248 + git reset --hard start &&
249 + stage_file b && git commit -m "fixup! base" &&
250 + stage_file c &&
251 + commit_with_message "amend! base\n\namended body\n" &&
252 +
253 + git history squash start.. &&
254 +
255 + check_commit_count start..HEAD 1 &&
256 + check_log_messages -1 <<-\EOF
257 + fixup! base
258 +
259 + amend! base
260 +
261 + amended body
262 +
263 + EOF
264 +'
265 +
266 +test_expect_success 'refuses fixups for two different commits below the range' '
267 + git reset --hard start &&
268 + stage_file b && git commit -m "fixup! aaa" &&
269 + stage_file c && git commit -m "fixup! bbb" &&
270 + head_before=$(git rev-parse HEAD) &&
271 +
272 + test_must_fail git history squash start.. 2>err &&
273 + test_grep "target is not in the range" err &&
274 + test_cmp_rev "$head_before" HEAD
275 +'
276 +
277 +test_expect_success 'does not discard squash! or amend! message bodies' '
278 + git reset --hard start &&
279 + test_commit --no-tag marker-oldest file b &&
280 + git commit --allow-empty -m "squash! marker-oldest" &&
281 + commit_with_message "amend! marker-oldest\n\nearlier message\n" &&
282 + commit_with_message \
283 + "amend! marker-oldest\n\namended subject\n\namended body\n" &&
284 + test_commit --no-tag marker-later file c &&
285 + commit_with_message "amend! marker-later\n\nwrong message\n" &&
286 +
287 + git history squash start.. &&
288 +
289 + check_commit_count start..HEAD 1 &&
290 + check_log_messages -1 <<-\EOF
291 + marker-oldest
292 +
293 + earlier message
294 +
295 + amended subject
296 +
297 + amended body
298 +
299 + wrong message
300 +
301 + EOF
302 +'
303 +
304 +test_expect_success '--no-edit keeps the selected message without an editor' '
305 + git reset --hard start &&
306 + test_commit --no-tag no-edit-target file b &&
307 + git commit --allow-empty -m "squash! no-edit-target" &&
308 + commit_with_message "amend! no-edit-target\n\namended subject\n\namended body\n" &&
309 +
310 + write_script editor <<-\EOF &&
311 + exit 1
312 + EOF
313 + test_set_editor "$(pwd)/editor" &&
314 + git history squash --no-edit start.. &&
315 +
316 + check_log_messages -1 <<-\EOF &&
317 + amended subject
318 +
319 + amended body
320 +
321 + EOF
322 + test_set_editor :
323 +'
324 +
325 +test_expect_success 'edits every message and aborts on an empty result' '
326 + git reset --hard start &&
327 + stage_file b &&
328 + git commit -m "re-one subject" -m "re-one body line" &&
329 + test_commit --no-tag re-two file c &&
330 + test_commit re-three file d &&
331 + head_before=$(git rev-parse HEAD) &&
332 +
333 + write_script empty-editor <<-\EOF &&
334 + >"$1"
335 + EOF
336 + test_set_editor "$(pwd)/empty-editor" &&
337 + test_must_fail git history squash start.. 2>err &&
338 + test_grep "Aborting commit due to empty commit message" err &&
339 + test_cmp_rev "$head_before" HEAD &&
340 +
341 + write_script editor <<-\EOF &&
342 + cat "$1" >edited &&
343 + echo combined >"$1"
344 + EOF
345 + test_set_editor "$(pwd)/editor" &&
346 + git history squash start.. &&
347 +
348 + cat >expect <<-EOF &&
349 + # This is a combination of 3 commits.
350 + # This is the 1st commit message:
351 +
352 + re-one subject
353 +
354 + re-one body line
355 +
356 + # This is the commit message #2:
357 +
358 + re-two
359 +
360 + # This is the commit message #3:
361 +
362 + re-three
363 +
364 + # Please enter the commit message for the squash changes. Lines starting
365 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
366 + # Changes to be committed:
367 + # modified: file
368 + #
369 + EOF
370 + test_cmp expect edited &&
371 + check_log_subjects -1 <<-\EOF
372 + combined
373 + EOF
374 +'
375 +
376 +test_expect_success 'handles fixup!, squash! and amend! messages like rebase' '
377 + git reset --hard start &&
378 + test_commit --no-tag mark-base file b &&
379 + stage_file c &&
380 + commit_with_message "fixup! mark-base\n\nfixup body\n" &&
381 + stage_file d &&
382 + commit_with_message "squash! mark-base\n\nsquash remark\n" &&
383 + stage_file e &&
384 + commit_with_message "amend! mark-base\n\namended message\n" &&
385 +
386 + write_script editor <<-\EOF &&
387 + cat "$1" >edited
388 + EOF
389 + test_set_editor "$(pwd)/editor" &&
390 + git history squash start.. &&
391 +
392 + cat >expect <<-EOF &&
393 + # This is a combination of 4 commits.
394 + # This is the 1st commit message:
395 +
396 + mark-base
397 +
398 + # The commit message #2 will be skipped:
399 +
400 + # fixup! mark-base
401 + #
402 + # fixup body
403 +
404 + # This is the commit message #3:
405 +
406 + # squash! mark-base
407 +
408 + squash remark
409 +
410 + # This is the commit message #4:
411 +
412 + # amend! mark-base
413 +
414 + amended message
415 +
416 + # Please enter the commit message for the squash changes. Lines starting
417 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
418 + # Changes to be committed:
419 + # modified: file
420 + #
421 + EOF
422 + test_cmp expect edited &&
423 + check_log_messages -1 <<-\EOF
424 + mark-base
425 +
426 + squash remark
427 +
428 + amended message
429 +
430 + EOF
431 +'
432 +
433 +test_expect_success 'groups fixups under their targets in the editor' '
434 + git reset --hard start &&
435 + test_commit --no-tag alpha file a1 &&
436 + test_commit --no-tag beta file b1 &&
437 + stage_file a2 &&
438 + commit_with_message "fixup! alpha\n" &&
439 + stage_file b2 &&
440 + commit_with_message "fixup! beta\n" &&
441 +
442 + write_script editor <<-\EOF &&
443 + cat "$1" >edited
444 + EOF
445 + test_set_editor "$(pwd)/editor" &&
446 + git history squash start.. &&
447 +
448 + cat >expect <<-EOF &&
449 + # This is a combination of 4 commits.
450 + # This is the 1st commit message:
451 +
452 + alpha
453 +
454 + # The commit message #2 will be skipped:
455 +
456 + # fixup! alpha
457 +
458 + # This is the commit message #3:
459 +
460 + beta
461 +
462 + # The commit message #4 will be skipped:
463 +
464 + # fixup! beta
465 +
466 + # Please enter the commit message for the squash changes. Lines starting
467 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
468 + # Changes to be committed:
469 + # modified: file
470 + #
471 + EOF
472 + test_cmp expect edited
473 +'
474 +
475 +test_expect_success 'lets amend! replace its target message in the editor' '
476 + git reset --hard start &&
477 + test_commit --no-tag mark-base file b &&
478 + stage_file c &&
479 + commit_with_message "amend! mark-base\n\namended message\n" &&
480 + stage_file d &&
481 + commit_with_message "squash! mark-base\n\nsquash remark\n" &&
482 +
483 + write_script editor <<-\EOF &&
484 + cat "$1" >edited
485 + EOF
486 + test_set_editor "$(pwd)/editor" &&
487 + git history squash start.. &&
488 +
489 + cat >expect <<-EOF &&
490 + # This is a combination of 3 commits.
491 + # The 1st commit message will be skipped:
492 +
493 + # mark-base
494 +
495 + # This is the commit message #2:
496 +
497 + # amend! mark-base
498 +
499 + amended message
500 +
501 + # This is the commit message #3:
502 +
503 + # squash! mark-base
504 +
505 + squash remark
506 +
507 + # Please enter the commit message for the squash changes. Lines starting
508 + # with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
509 + # Changes to be committed:
510 + # modified: file
511 + #
512 + EOF
513 + test_cmp expect edited &&
514 + check_log_messages -1 <<-\EOF
515 + amended message
516 +
517 + squash remark
518 +
519 + EOF
520 +'
521 +
522 +test_expect_success 'handles branches pointing at the squashed range' '
523 + git reset --hard three &&
524 + git branch -f other HEAD &&
525 + git branch -f mid HEAD~1 &&
526 + other_before=$(git rev-parse other) &&
527 + mid_before=$(git rev-parse mid) &&
528 + head_before=$(git rev-parse HEAD) &&
529 +
530 + test_must_fail git history squash start.. 2>err &&
531 + test_grep "error: .* points into the squashed range" err &&
532 + test_grep "hint: .*--update-refs=head" err &&
533 + test_cmp_rev "$head_before" HEAD &&
534 +
535 + test_must_fail git -c advice.historyUpdateRefs=false \
536 + history squash start.. 2>err &&
537 + test_grep "points into the squashed range" err &&
538 + test_grep ! "hint:" err &&
539 + test_cmp_rev "$head_before" HEAD &&
540 +
541 + git history squash --no-edit --update-refs=head start.. &&
542 +
543 + check_commit_count start..HEAD 1 &&
544 + test_cmp_rev "$other_before" other &&
545 + test_cmp_rev "$mid_before" mid &&
546 +
547 + git branch -D other mid
548 +'
549 +
550 +test_expect_success 'leaves tags and remote-tracking refs unchanged' '
551 + git reset --hard three &&
552 + git tag -f mark HEAD~1 &&
553 + git update-ref refs/remotes/origin/mark HEAD~1 &&
554 + mark_before=$(git rev-parse mark) &&
555 +
556 + git history squash --no-edit start.. &&
557 +
558 + test_cmp_rev "$mark_before" mark &&
559 + test_cmp_rev "$mark_before" refs/remotes/origin/mark &&
560 +
561 + git tag -d mark &&
562 + git update-ref -d refs/remotes/origin/mark
563 +'
564 +
565 +test_expect_success 'handles a branch pointing at an internal merge' '
566 + git reset --hard start &&
567 + main=$(git symbolic-ref --short HEAD) &&
568 + test_commit --no-tag before-side file b &&
569 + git checkout -b inner-side &&
570 + test_commit --no-tag on-inner-side inner x &&
571 + git checkout "$main" &&
572 + test_commit --no-tag after-side file c &&
573 + git merge --no-ff -m merge inner-side &&
574 + git branch -D inner-side &&
575 + git branch at-merge HEAD &&
576 + test_commit --no-tag after-merge file d &&
577 + head_before=$(git rev-parse HEAD) &&
578 + tip_tree=$(git rev-parse HEAD^{tree}) &&
579 +
580 + test_must_fail git history squash start.. 2>err &&
581 + test_grep "at-merge" err &&
582 + test_grep "points into the squashed range" err &&
583 + test_cmp_rev "$head_before" HEAD &&
584 + git branch -D at-merge &&
585 +
586 + git history squash --no-edit start.. &&
587 +
588 + check_commit_count start..HEAD 1 &&
589 + check_log_subjects -1 <<-\EOF &&
590 + before-side
591 + EOF
592 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
593 + test_path_is_file inner
594 +'
595 +
596 +test_expect_success 'folds a merge of a branch that forked at the base' '
597 + git reset --hard start &&
598 + main=$(git symbolic-ref --short HEAD) &&
599 + git checkout -b base-fork-side &&
600 + test_commit --no-tag base-fork-side side x &&
601 + git checkout "$main" &&
602 + test_commit --no-tag base-fork-main file b &&
603 + git merge --no-ff -m "merge base-fork-side" base-fork-side &&
604 + git branch -D base-fork-side &&
605 + test_commit --no-tag base-fork-tail file c &&
606 + tip_tree=$(git rev-parse HEAD^{tree}) &&
607 +
608 + git history squash --no-edit start.. &&
609 +
610 + check_commit_count start..HEAD 1 &&
611 + test_cmp_rev start HEAD^ &&
612 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
613 + test_path_is_file side
614 +'
615 +
616 +test_expect_success 'refuses a merge whose other parent is outside the range' '
617 + git reset --hard start &&
618 + main=$(git symbolic-ref --short HEAD) &&
619 + git checkout -b outside-parent &&
620 + test_commit --no-tag outside-parent outside x &&
621 + git checkout "$main" &&
622 + test_commit --no-tag outside-main file b &&
623 + base=$(git rev-parse HEAD) &&
624 + test_commit --no-tag outside-mid file c &&
625 + git merge --no-ff -m "merge outside-parent" outside-parent &&
626 + git branch -D outside-parent &&
627 + merged=$(git rev-parse HEAD) &&
628 +
629 + test_must_fail git history squash "$base.." 2>err &&
630 + test_grep "more than one base" err &&
631 + test_cmp_rev "$merged" HEAD
632 +'
633 +
634 +test_expect_success 'folds a range whose tip is a merge commit' '
635 + git reset --hard start &&
636 + main=$(git symbolic-ref --short HEAD) &&
637 + test_commit --no-tag tipmerge-base file b &&
638 + git checkout -b tipmerge-side &&
639 + test_commit --no-tag tipmerge-side side x &&
640 + git checkout "$main" &&
641 + test_commit --no-tag tipmerge-main file c &&
642 + git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
643 + git branch -D tipmerge-side &&
644 + tip_tree=$(git rev-parse HEAD^{tree}) &&
645 +
646 + git history squash --no-edit start.. &&
647 +
648 + check_commit_count start..HEAD 1 &&
649 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
650 + test_path_is_file side
651 +'
652 +
653 +test_expect_success 'folds a range whose base is a merge commit' '
654 + git reset --hard start &&
655 + main=$(git symbolic-ref --short HEAD) &&
656 + git checkout -b basemerge-side &&
657 + test_commit --no-tag basemerge-side side x &&
658 + git checkout "$main" &&
659 + test_commit --no-tag basemerge-main file b &&
660 + git merge --no-ff -m "merge basemerge-side" basemerge-side &&
661 + git branch -D basemerge-side &&
662 + base=$(git rev-parse HEAD) &&
663 + test_commit --no-tag basemerge-one file c &&
664 + test_commit --no-tag basemerge-two file d &&
665 + tip_tree=$(git rev-parse HEAD^{tree}) &&
666 +
667 + git history squash --no-edit "$base.." &&
668 +
669 + check_commit_count "$base..HEAD" 1 &&
670 + test_cmp_rev "$base" HEAD^ &&
671 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
672 +'
673 +
674 +test_expect_success 'folds a range with a nested merge' '
675 + git reset --hard start &&
676 + main=$(git symbolic-ref --short HEAD) &&
677 + git checkout -b nested-outer &&
678 + test_commit --no-tag nested-outer outer x &&
679 + git checkout -b nested-inner &&
680 + test_commit --no-tag nested-inner inner y &&
681 + git checkout nested-outer &&
682 + git merge --no-ff -m "merge inner" nested-inner &&
683 + git checkout "$main" &&
684 + test_commit --no-tag nested-main file b1 &&
685 + git merge --no-ff -m "merge outer" nested-outer &&
686 + git branch -D nested-outer nested-inner &&
687 + tip_tree=$(git rev-parse HEAD^{tree}) &&
688 +
689 + git history squash --no-edit start.. &&
690 +
691 + check_commit_count start..HEAD 1 &&
692 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
693 + test_path_is_file outer &&
694 + test_path_is_file inner
695 +'
696 +
697 +test_expect_success 'folds a range with an octopus merge' '
698 + git reset --hard start &&
699 + main=$(git symbolic-ref --short HEAD) &&
700 + test_commit --no-tag octo-base file a1 &&
701 + git checkout -b octo-1 &&
702 + test_commit --no-tag octo-1 o1 x &&
703 + git checkout "$main" &&
704 + git checkout -b octo-2 &&
705 + test_commit --no-tag octo-2 o2 y &&
706 + git checkout "$main" &&
707 + git merge --no-ff -m octopus octo-1 octo-2 &&
708 + git branch -D octo-1 octo-2 &&
709 + tip_tree=$(git rev-parse HEAD^{tree}) &&
710 +
711 + git history squash --no-edit start.. &&
712 +
713 + check_commit_count start..HEAD 1 &&
714 + test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
715 + test_path_is_file o1 &&
716 + test_path_is_file o2
717 +'
718 +
719 +test_expect_success 'refuses an octopus merge with an arm forked before the base' '
720 + git reset --hard start &&
721 + main=$(git symbolic-ref --short HEAD) &&
722 + git checkout -b octo-pre &&
723 + test_commit octo-pre-side pside x &&
724 + git checkout "$main" &&
725 + test_commit octo-pre-main file b1 &&
726 + octo_base=$(git rev-parse HEAD) &&
727 + git checkout -b octo-within &&
728 + test_commit --no-tag octo-within wside y &&
729 + git checkout "$main" &&
730 + git merge --no-ff -m octopus octo-pre octo-within &&
731 + merged=$(git rev-parse HEAD) &&
732 + git branch -D octo-pre octo-within &&
733 +
734 + test_must_fail git history squash "$octo_base.." 2>err &&
735 + test_grep "more than one base" err &&
736 + test_cmp_rev "$merged" HEAD
737 +'
738 +
739 +test_expect_success 'refuses when a descendant above the range is a merge' '
740 + git reset --hard start &&
741 + main=$(git symbolic-ref --short HEAD) &&
742 + test_commit --no-tag desc-one file b &&
743 + test_commit --no-tag desc-two file c &&
744 + git tag desc-tip &&
745 + git checkout -b desc-above &&
746 + test_commit --no-tag desc-above above x &&
747 + git checkout "$main" &&
748 + test_commit --no-tag desc-main file d &&
749 + git merge --no-ff -m "merge desc-above" desc-above &&
750 + git branch -D desc-above &&
751 + head_before=$(git rev-parse HEAD) &&
752 +
753 + test_must_fail git history squash --no-edit start..desc-tip 2>err &&
754 + test_grep "merge commits is not supported" err &&
755 + test_cmp_rev "$head_before" HEAD
756 +'
757 +
758 +test_done