Skip to contentSkip to conversation

rebase: add --fixup to fold a range into its oldest commit

PATCH Superseded Harald Nordgren via GitGitGadget 9 participants 185 messages Jun 14 – Sep 8 Page 2 of 2

Subsystems (2)
lore
Share & tools
lore
#133 lore raw

v11 cover history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

Adds git history squash <revision-range> to fold a range of commits.

Changes in v11:

* Make message editing the default with the autosquash-style template, add --no-edit instead of squash-specific --reedit-message.
* Validate one actual boundary and tip, rejecting root-reaching and multi-tip ranges.
* Protect only interior local branches, leaving tags and remote-tracking refs unchanged.
* Move sequencer preparation before squash and fold the later message-editing patch into the feature commit.

Changes in v10:

* Record the full revision expression in squash reflog.
* Preserve the boundary-walk invariant when sanitizing rev-list options.
* Clarify amend! and --reedit-message documentation.

Changes in v9:

* Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
* Improve tests.

Changes in v8:

* --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
* A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
* Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
* Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
* Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

* --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
* Reuse git rebase -i's squash-message code: a preparatory sequencer:
commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
* Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
* Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
* Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

Changes in v6:

* git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works.
* The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently).
* Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

* The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.
* Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit.

Changes in v4:

* git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected.

Changes in v3:

* Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.
* Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point).
* A merge inside the range is folded fine, only a range with more than one base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just the oldest.

Harald Nordgren (4):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
sequencer: share the squash message marker helpers and flags
history: add squash subcommand to fold a range

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  57 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 578 +++++++++++++++++++++--
 sequencer.c                      |  70 +--
 sequencer.h                      |  30 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 758 +++++++++++++++++++++++++++++++
 9 files changed, 1429 insertions(+), 71 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

base-commit: 13c7afec212fc97ce257d15601659314c6673d6c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v11
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v11
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v10:

 1:  f848103497 = 1:  dd1deb5e5b history: extract helper for a commit's parent tree
 2:  49dadc3410 = 2:  7309f64485 history: give commit_tree_ext a message template
 4:  41156c9afb ! 3:  0762589e22 sequencer: share the squash message marker helpers and flags

@@ Commit message
todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git
- history squash --reedit-message" the same template.
+ history squash" the same template.

No change in behavior.

3: 6b5b2c93f2 ! 4: 7abda3643c history: add squash subcommand to fold a range

     @@ Commit message
          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 the oldest one, keeping that commit's
     -    authorship and taking the tree of the newest commit, then replays the
     -    commits above the range on top. The squashed message comes from the
     -    oldest commit by default, or from the body of the last amend! commit
     -    targeting it. An editor opens with the selected message when
     -    --reedit-message is given. 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. The check runs
     -    the range through todo_list_rearrange_squash(), which leaves such a
     -    marker as a plain pick. Markers whose target is in the range fold in as
     -    usual. As an exception, a range made up entirely of markers for one
     -    target is combined anyway, taking its message from the last amend! if
     -    there is one, so a batch of fixups for the same commit can be collapsed.
     +    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. As "git replay" does, the walk options the fold
     -    relies on are forced after setup_revisions() and a warning is printed if
     -    an option changed them, so the first commit returned is the range's
     -    oldest and its parent is the base regardless of what the user passed
     -    (including after a "--"). A merge inside the range is folded when its
     -    other parent is reachable from the base, otherwise the range has more
     -    than one base and is rejected. By default the command also refuses when a
     -    ref points at a commit that the fold would discard. Use --update-refs=head
     -    to rewrite only the current branch instead.
     +    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 <[email protected]>
          Signed-off-by: Harald Nordgren <[email protected]>
     @@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-
       git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
       git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
       git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
     -+git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
     ++git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
       
       DESCRIPTION
       -----------
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +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. Specify `--reedit-message` to edit
     -+the resulting message. 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
     ++An editor opens pre-filled with the messages of all the folded commits so
     ++you can combine them. With `--no-edit`, the oldest commit's message is
     ++preserved instead, except that an `amend!` commit targeting it replaces its
     ++message. A merge commit inside the range is folded like any other, but the
     ++range must have a single base and a single tip. 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.
     ++and was later merged into it), or that selects two unmerged tips, 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.
     ++of markers for one target is combined into a single commit. With `--no-edit`,
     ++the last `amend!` message is used if there is one.
      ++
     -+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.
     ++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 local branch that points at a commit inside the range cannot be rewritten
     ++as a descendant of the result, so with the default `--update-refs=branches`
     ++the command refuses. Rerun with `--update-refs=head` to rewrite only the
     ++current branch and leave such branches pointing at the old commits. Tags and
     ++remote-tracking refs are always left unchanged.
      +
       OPTIONS
       -------
       
      @@ Documentation/git-history.adoc: OPTIONS
     + 	objects will be written into the repository, so applying these printed
       	ref updates is generally safe.
       
     ++`--edit`::
     ++`--no-edit`::
     ++	For `squash`, open an editor to combine the messages of the folded commits.
     ++	This is the default; use `--no-edit` to keep the selected message
     ++	without opening an editor.
     ++
       `--reedit-message`::
     --	Open an editor to modify the target commit's message.
     -+	Open an editor to modify the rewritten commit's message.
     + 	Open an editor to modify the target commit's message.
       
     - `--empty=(drop|keep|abort)`::
     - 	Control what happens when a commit becomes empty as a result of the
      
       ## advice.c ##
      @@ advice.c: static struct {
     @@ builtin/history.c
       #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)] [--reedit-message] <revision-range>")
     ++	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)
       {
     @@ builtin/history.c: out:
      +{
      +	struct rev_info revs;
      +	struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
     -+	struct commit_list *boundaries = NULL, *b;
     ++	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;
      +
     @@ builtin/history.c: out:
      +		revs.boundary = 1;
      +	}
      +
     -+	/*
     -+	 * A squash needs a base to reparent onto, so the range has to exclude
     -+	 * something, as in "<base>..<tip>". A revision range with no such
     -+	 * bottom commit cannot be squashed.
     -+	 */
     ++	/* 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 & UNINTERESTING)
     ++		if (revs.cmdline.rev[i].flags & BOTTOM)
      +			break;
      +	if (i == revs.cmdline.nr) {
      +		ret = error(_("not a '<base>..<tip>' revision range"));
     @@ builtin/history.c: out:
      +		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.
     -+	 */
     ++	/* Set boundary commits aside for the base check below. */
      +	while ((commit = get_revision(&revs))) {
      +		if (commit->object.flags & BOUNDARY) {
      +			commit_list_insert(commit, &boundaries);
     @@ builtin/history.c: out:
      +		}
      +		if (!oldest)
      +			oldest = commit;
     -+		if (tip)
     -+			oidset_insert(interior_out, &tip->object.oid);
     -+		tip = 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 (oldest == tip) {
     ++	} else if (!commits->next) {
      +		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.
     ++	 * Find the selected commits that have selected children. The only
     ++	 * remaining commit must be the tip whose tree becomes the result.
      +	 */
     -+	for (b = boundaries; b; b = b->next) {
     -+		if (b->item != base) {
     ++	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;
     @@ builtin/history.c: out:
      +
      +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);
     @@ builtin/history.c: out:
      +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;
     @@ builtin/history.c: out:
      +	bool past_oldest_group = false;
      +	int i, ret, nr_dangling = 0;
      +
     -+	*msg_source = oldest;
      +	*amend_source = NULL;
      +
      +	repo_init_revisions(repo, &revs, NULL);
     @@ builtin/history.c: out:
      +			      "range"), dangling_subject);
      +	} else {
      +		if (last_amend)
     -+			*msg_source = last_amend;
     ++			*amend_source = last_amend;
      +		ret = 0;
      +	}
      +
     @@ builtin/history.c: out:
      +	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,
     @@ builtin/history.c: out:
      +	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_BIT(0, "reedit-message", &flags,
     -+			N_("open an editor to modify the commit message"),
     -+			COMMIT_TREE_EDIT_MESSAGE),
     ++		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, *oldest, *tip, *rewritten, *msg_source,
     -+		*amend_source;
     ++	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;
     @@ builtin/history.c: out:
      +	if (ret < 0)
      +		goto out;
      +
     -+	ret = reject_dangling_fixups(repo, base, tip, oldest, &msg_source,
     -+				     &amend_source);
     ++	ret = reject_dangling_fixups(repo, base, tip, &amend_source);
      +	if (ret < 0)
      +		goto out;
     -+	if (amend_source) {
     ++	if (!edit && amend_source) {
      +		const char *amend_message, *body;
      +
      +		amend_message = repo_logmsg_reencode(repo, amend_source,
     @@ builtin/history.c: out:
      +	if (action == REF_ACTION_BRANCHES) {
      +		struct interior_ref_cb cb = { .interior = &interior };
      +
     -+		refs_for_each_ref(get_main_ref_store(repo),
     -+				  find_interior_ref, &cb);
     ++		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);
     @@ builtin/history.c: 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;
     @@ builtin/history.c: out:
      +	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,
     ++	ret = commit_tree_ext(repo, "squash", oldest, message_template,
      +			      parents,
      +			      base_tree_oid, tip_tree_oid, &rewritten, flags);
      +	if (ret < 0) {
     @@ t/t3455-history-squash.sh (new)
      +test_expect_success 'setup linear history touching two files' '
      +	test_commit base file a &&
      +	git tag start &&
     -+	test_commit --no-tag one other x &&
     ++	GIT_AUTHOR_NAME=Squasher [email protected] \
     ++		test_commit --no-tag one other x &&
      +	test_commit --no-tag two file c &&
      +	test_commit three file d
      +'
     @@ t/t3455-history-squash.sh (new)
      +	git branch -f keep HEAD~2 &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start..HEAD ^keep &&
     ++	git history squash --no-edit start..HEAD ^keep &&
      +
      +	git reflog -1 --format=%gs >actual &&
      +	echo "squash: updating start..HEAD ^keep" >expect &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D keep
      +'
      +
     ++test_expect_success 'refuses a range with more than one tip' '
     ++	git reset --hard start &&
     ++	main=$(git symbolic-ref --short HEAD) &&
     ++	test_commit --no-tag main-one file b &&
     ++	test_commit --no-tag main-two file c &&
     ++	git checkout -b other-tip start &&
     ++	test_commit --no-tag other-tip other d &&
     ++	git checkout "$main" &&
     ++	main_before=$(git rev-parse HEAD) &&
     ++	other_before=$(git rev-parse other-tip) &&
     ++
     ++	test_must_fail git history squash ^start HEAD other-tip 2>err &&
     ++	test_grep "more than one tip" err &&
     ++	test_cmp_rev "$main_before" HEAD &&
     ++	test_cmp_rev "$other_before" other-tip &&
     ++
     ++	git branch -D other-tip
     ++'
     ++
     ++test_expect_success 'refuses a range that reaches a root commit' '
     ++	git reset --hard three &&
     ++	root=$(printf "unrelated root\n" |
     ++		git commit-tree "$(git rev-parse HEAD^{tree})") &&
     ++	head_before=$(git rev-parse HEAD) &&
     ++
     ++	test_must_fail git history squash --ancestry-path="$root" \
     ++		^start HEAD "$root" 2>err &&
     ++	test_grep "reaches a root commit" err &&
     ++	test_cmp_rev "$head_before" HEAD
     ++'
     ++
      +test_expect_success 'squashes a branch the current branch is not on' '
      +	git reset --hard three &&
      +	main=$(git symbolic-ref --short HEAD) &&
     @@ t/t3455-history-squash.sh (new)
      +	test_commit --no-tag off-two off b &&
      +	git checkout "$main" &&
      +
     -+	git history squash start..off-history &&
     ++	git history squash --no-edit start..off-history &&
      +
      +	check_commit_count start..off-history 1 &&
      +	test_cmp_rev "$head_before" HEAD &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D off-history
      +'
      +
     -+test_expect_success 'squashes a range into a single commit without changing the tree' '
     ++test_expect_success 'squashes a range preserving its tree and oldest authorship' '
      +	git reset --hard three &&
      +	head_before=$(git rev-parse HEAD) &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
     @@ t/t3455-history-squash.sh (new)
      +	check_log_subjects -1 <<-\EOF &&
      +	one
      +	EOF
     ++	git log -1 --format="%an <%ae>" >actual &&
     ++	echo "Squasher <[email protected]>" >expect &&
     ++	test_cmp expect actual &&
      +	git reflog >reflog &&
      +	test_grep "squash: updating" reflog
      +'
     @@ t/t3455-history-squash.sh (new)
      +	git reset --hard three &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash --date-order start.. 2>err &&
     ++	git history squash --no-edit --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 &&
     ++	git history squash --no-edit -- --reverse start.. 2>err &&
      +	test_grep "ignoring rev-list options" err &&
      +	test_cmp_rev start HEAD^ &&
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
     @@ t/t3455-history-squash.sh (new)
      +	git reset --hard three &&
      +	final_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start..@~1 &&
     ++	git history squash --no-edit start..@~1 &&
      +
      +	check_log_subjects start..HEAD <<-\EOF &&
      +	three
     @@ t/t3455-history-squash.sh (new)
      +	root=$(git rev-list --max-parents=0 HEAD) &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash "$root.." &&
     ++	git history squash --no-edit "$root.." &&
      +
      +	check_commit_count "$root..HEAD" 1 &&
      +	test_cmp_rev "$root" HEAD^ &&
     @@ t/t3455-history-squash.sh (new)
      +	git commit --allow-empty -m "fixup! target" &&
      +	test_commit --no-tag later file c &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	check_log_subjects -1 <<-\EOF
     @@ t/t3455-history-squash.sh (new)
      +	stage_file b && git commit -m "fixup! base" &&
      +	stage_file c && git commit -m "fixup! base" &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	check_log_subjects -1 <<-\EOF
     @@ t/t3455-history-squash.sh (new)
      +	EOF
      +'
      +
     -+test_expect_success 'combining below-range fixups keeps the last amend! message' '
     ++test_expect_success 'combining below-range markers offers every message' '
      +	git reset --hard start &&
      +	stage_file b && git commit -m "fixup! base" &&
      +	stage_file c &&
     @@ t/t3455-history-squash.sh (new)
      +
      +	check_commit_count start..HEAD 1 &&
      +	check_log_messages -1 <<-\EOF
     ++	fixup! base
     ++
      +	amend! base
      +
      +	amended body
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp_rev "$head_before" HEAD
      +'
      +
     -+test_expect_success 'the last amend! for the oldest commit replaces its message' '
     ++test_expect_success 'does not discard squash! or amend! message bodies' '
      +	git reset --hard start &&
      +	test_commit --no-tag marker-oldest file b &&
      +	git commit --allow-empty -m "squash! marker-oldest" &&
     @@ t/t3455-history-squash.sh (new)
      +
      +	check_commit_count start..HEAD 1 &&
      +	check_log_messages -1 <<-\EOF
     ++	marker-oldest
     ++
     ++	earlier message
     ++
      +	amended subject
      +
      +	amended body
      +
     ++	wrong message
     ++
      +	EOF
      +'
      +
     -+test_expect_success 'preserves authorship of the oldest commit' '
     ++test_expect_success '--no-edit keeps the selected message without an editor' '
      +	git reset --hard start &&
     -+	GIT_AUTHOR_NAME=Squasher [email protected] \
     -+		test_commit --no-tag oldest file b &&
     -+	test_commit newest file c &&
     ++	test_commit --no-tag no-edit-target file b &&
     ++	git commit --allow-empty -m "squash! no-edit-target" &&
     ++	commit_with_message "amend! no-edit-target\n\namended subject\n\namended body\n" &&
     ++
     ++	write_script editor <<-\EOF &&
     ++	exit 1
     ++	EOF
     ++	test_set_editor "$(pwd)/editor" &&
     ++	git history squash --no-edit start.. &&
     ++
     ++	check_log_messages -1 <<-\EOF &&
     ++	amended subject
     ++
     ++	amended body
     ++
     ++	EOF
     ++	test_set_editor :
     ++'
     ++
     ++test_expect_success 'edits every message and aborts on an empty result' '
     ++	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 &&
     ++	head_before=$(git rev-parse HEAD) &&
     ++
     ++	write_script empty-editor <<-\EOF &&
     ++	>"$1"
     ++	EOF
     ++	test_set_editor "$(pwd)/empty-editor" &&
     ++	test_must_fail git history squash start.. 2>err &&
     ++	test_grep "Aborting commit due to empty commit message" err &&
     ++	test_cmp_rev "$head_before" HEAD &&
      +
     ++	write_script editor <<-\EOF &&
     ++	cat "$1" >edited &&
     ++	echo combined >"$1"
     ++	EOF
     ++	test_set_editor "$(pwd)/editor" &&
      +	git history squash start.. &&
      +
     -+	git log -1 --format="%an <%ae>" >actual &&
     -+	echo "Squasher <[email protected]>" >expect &&
     -+	test_cmp expect actual
     ++	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 '--update-refs=head only moves HEAD' '
     -+	git reset --hard three &&
     -+	git branch -f other HEAD &&
     -+	other_before=$(git rev-parse other) &&
     ++test_expect_success 'handles fixup!, squash! and amend! messages 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 start.. &&
      +
     -+	git history squash --update-refs=head start.. &&
     ++	cat >expect <<-EOF &&
     ++	# This is a combination of 4 commits.
     ++	# This is the 1st commit message:
      +
     -+	check_commit_count start..HEAD 1 &&
     -+	test_cmp_rev "$other_before" other
     ++	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 'groups fixups under their targets in the editor' '
     ++	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 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 'lets amend! replace its target message in the editor' '
     ++	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 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 'refuses to fold a range a ref points into' '
     ++test_expect_success 'handles branches pointing at the squashed range' '
      +	git reset --hard three &&
     ++	git branch -f other HEAD &&
      +	git branch -f mid HEAD~1 &&
     ++	other_before=$(git rev-parse other) &&
     ++	mid_before=$(git rev-parse mid) &&
      +	head_before=$(git rev-parse HEAD) &&
      +
      +	test_must_fail git history squash start.. 2>err &&
     @@ t/t3455-history-squash.sh (new)
      +	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.. &&
     ++	git history squash --no-edit --update-refs=head start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
     ++	test_cmp_rev "$other_before" other &&
      +	test_cmp_rev "$mid_before" mid &&
      +
     -+	git branch -D mid
     ++	git branch -D other mid
      +'
      +
     -+test_expect_success 'refuses to fold a range a tag points into' '
     ++test_expect_success 'leaves tags and remote-tracking refs unchanged' '
      +	git reset --hard three &&
      +	git tag -f mark HEAD~1 &&
     -+	head_before=$(git rev-parse HEAD) &&
     ++	git update-ref refs/remotes/origin/mark HEAD~1 &&
     ++	mark_before=$(git rev-parse mark) &&
      +
     -+	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 history squash --no-edit start.. &&
     ++
     ++	test_cmp_rev "$mark_before" mark &&
     ++	test_cmp_rev "$mark_before" refs/remotes/origin/mark &&
      +
     -+	git tag -d mark
     ++	git tag -d mark &&
     ++	git update-ref -d refs/remotes/origin/mark
      +'
      +
     -+test_expect_success 'squashes a range whose internal merge has a single base' '
     ++test_expect_success 'handles a branch pointing at an internal merge' '
      +	git reset --hard start &&
      +	main=$(git symbolic-ref --short HEAD) &&
      +	test_commit --no-tag before-side file b &&
     @@ t/t3455-history-squash.sh (new)
      +	test_commit --no-tag after-side file c &&
      +	git merge --no-ff -m merge inner-side &&
      +	git branch -D inner-side &&
     ++	git branch at-merge HEAD &&
      +	test_commit --no-tag after-merge file d &&
     ++	head_before=$(git rev-parse HEAD) &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start.. &&
     ++	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 &&
     ++
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	check_log_subjects -1 <<-\EOF &&
     @@ t/t3455-history-squash.sh (new)
      +	test_commit --no-tag base-fork-tail file c &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	test_cmp_rev start HEAD^ &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D tipmerge-side &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
     @@ t/t3455-history-squash.sh (new)
      +	test_commit --no-tag basemerge-two file d &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash "$base.." &&
     ++	git history squash --no-edit "$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) &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D nested-outer nested-inner &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D octo-1 octo-2 &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D desc-above &&
      +	head_before=$(git rev-parse HEAD) &&
      +
     -+	test_must_fail git history squash start..desc-tip 2>err &&
     ++	test_must_fail git history squash --no-edit 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

5: cdbc183428 < -: ---------- history: re-edit a squash with every message

Signature · 1 line
gitgitgadget
#134 lore raw

PATCH v11 1/4 history: extract helper for a commit's parent tree

From: Harald Nordgren <[email protected]>

Three places resolve the tree of a commit's first parent, falling back to the empty tree for a root commit, each repeating the same parse and oidcpy dance. Extract a first_parent_tree_oid() helper and route the existing callers through it.

No change in behavior.

---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)
builtin/history.c +26 -32
index d28c1f08bb..673744a55a 100644
@@ -164,6 +164,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,21 +190,11 @@ 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));
-		}
-
-		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 commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,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);
 }
@@ -830,16 +831,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);
 
Signature · 1 line
gitgitgadget
#135 lore raw

PATCH v11 2/4 history: give commit_tree_ext a message template

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

commit_tree_ext() reuses the message of the commit it is handed. A caller that folds several commits together wants to seed the message from more than that single commit, so add an optional message_template parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)
builtin/history.c +10 -6
index 673744a55a..b592b98393 100644
@@ -108,6 +108,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	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);
 }
 
@@ -675,7 +679,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) {
@@ -886,7 +890,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"));
@@ -903,7 +907,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"));
Signature · 1 line
gitgitgadget
#136 lore raw

PATCH v11 3/4 sequencer: share the squash message marker helpers and flags

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

When "git rebase -i" squashes commits it builds an editor template with a "This is a combination of N commits." banner, a "This is the 1st/Nth commit message:" header above each kept message (or a "will be skipped" header for a dropped one), and a commented-out subject for any fixup!, squash! or amend! commit. The banner, the headers and the subject-commenting all live in static helpers in sequencer.c wired to the rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(), add_squash_message_header() (which takes a flag for the "will be skipped" variant) and squash_subject_comment_len(), and use them from update_squash_messages() and append_squash_message(). Also move the todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git history squash" the same template.

No change in behavior.

---
 sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
 sequencer.h | 30 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 31 deletions(-)
sequencer.c +39 -31
index 82ab3c536f..642dfa9d91 100644
@@ -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;
+
+	commented_len = squash_subject_comment_len(body,
+				command == TODO_SQUASH || seen_squash(ctx));
 
-	strbuf_addf(buf, "\n%s ", comment_line_str);
-	strbuf_addf(buf, _(nth_commit_msg_fmt),
-		    ++ctx->current_fixup_count + 1);
+	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);
sequencer.h +30 -0
index 64a9c7fb1b..b01f897020 100644
@@ -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,
Signature · 1 line
gitgitgadget
#137 lore raw

PATCH v11 4/4 history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

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.

---
 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  57 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 504 ++++++++++++++++++++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 758 +++++++++++++++++++++++++++++++
 7 files changed, 1324 insertions(+), 2 deletions(-)
 create mode 100755 t/t3455-history-squash.sh
Documentation/config/advice.adoc +4 -0
index 81f80a9274..e2a3487778 100644
@@ -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.
Documentation/git-history.adoc +55 -2

81 lines not on this page · raw message

advice.c +1 -0
index 63bf8b0c5f..401d047391 100644
@@ -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" },
advice.h +1 -0
index 66f6cd6a77..3f0b4f0485 100644
@@ -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,
builtin/history.c +504 -0

534 lines not on this page · raw message

t/meson.build +1 -0
index d8161c368b..797c5a75d1 100644
@@ -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',
t/t3455-history-squash.sh new file +758 -0

758 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#138 lore raw

v12 cover history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

Adds git history squash <revision-range> to fold a range of commits.

Changes in v12:

* Incorporated fixups from Phillip:
* Reworks range validation into a single walk that rejects roots and multiple tips while preserving every parent when squashing into a merge.
* Resolves fixup!, squash! and amend! targets directly, preserving message intent and safely consolidating related markers.
* Builds the editor template from the exact selected revisions, including exclusions, while retaining --no-edit behavior.
* Protects descendant local branches while leaving tags and remote-tracking refs unchanged.

Changes in v11:

* Make message editing the default with the autosquash-style template, add --no-edit instead of squash-specific --reedit-message.
* Validate one actual boundary and tip, rejecting root-reaching and multi-tip ranges.
* Protect only interior local branches, leaving tags and remote-tracking refs unchanged.
* Move sequencer preparation before squash and fold the later message-editing patch into the feature commit.

Changes in v10:

* Record the full revision expression in squash reflog.
* Preserve the boundary-walk invariant when sanitizing rev-list options.
* Clarify amend! and --reedit-message documentation.

Changes in v9:

* Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
* Improve tests.

Changes in v8:

* --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
* A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
* Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
* Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
* Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

* --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
* Reuse git rebase -i's squash-message code: a preparatory sequencer:
commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
* Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
* Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
* Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

Changes in v6:

* git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works.
* The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently).
* Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

* The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.
* Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit.

Changes in v4:

* git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected.

Changes in v3:

* Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.
* Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point).
* A merge inside the range is folded fine, only a range with more than one base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just the oldest.

Harald Nordgren (4):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
sequencer: share the squash message marker helpers and flags
history: add squash subcommand to fold a range

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  59 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 685 ++++++++++++++++++++++--
 object.h                         |   1 +
 sequencer.c                      |  70 +--
 sequencer.h                      |  30 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 884 +++++++++++++++++++++++++++++++
 10 files changed, 1665 insertions(+), 71 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

base-commit: 5b2471720c93ee30e5764a19f3d3b3ae9ec9712a
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v12
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v12
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v11:

 1:  dd1deb5e5b = 1:  20e050a0ad history: extract helper for a commit's parent tree
 2:  7309f64485 = 2:  b99867a0d4 history: give commit_tree_ext a message template
 3:  0762589e22 = 3:  352db997e9 sequencer: share the squash message marker helpers and flags
 4:  7abda3643c ! 4:  33d3eca6db history: add squash subcommand to fold a range
     @@ Commit message
          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
     +    every selected commit into one, preserving the authorship and parents 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.
     +    above the range. By default an editor opens with every folded message in
     +    the same template used by "git rebase -i --autosquash". With --no-edit,
     +    the selected message is used without opening an editor.
      
     -    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.
     +    Resolve fixup!, squash! and amend! subjects directly while walking the
     +    selected commits. A marker is rejected unless its fixed target is also
     +    selected, except that a range made up entirely of related markers can be
     +    consolidated. Without editing, refuse any squash! or amend! whose message
     +    would be discarded.
      
     -    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.
     +    Read the range like arguments to "git rev-list" and accept multiple
     +    revisions and rev-list options. Restore the walk settings required by the
     +    fold after setup_revisions(), warning when an option changed them. The
     +    range must name a bottom commit, must not reach a root, and must have one
     +    tip. Every parent after the oldest commit must be selected or also be a
     +    parent of the oldest commit. This permits internal merges and preserves
     +    all parents when the oldest commit is itself a merge.
      
     -    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.
     +    By default, refuse when a local branch descends from the selected commits
     +    but cannot remain a descendant of the result. Tags and remote-tracking
     +    refs are left unchanged. Use --update-refs=head to leave such local
     +    branches unchanged as well.
      
          Inspired-by: Sergey Chernov <[email protected]>
     +    Helped-by: Phillip Wood <[email protected]>
          Signed-off-by: Harald Nordgren <[email protected]>
      
       ## Documentation/config/advice.adoc ##
     @@ Documentation/git-history.adoc: at once.
      +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.
     ++`--rebase-merges` flag instead. The `squash` subcommand can still fold merges
     ++that lie inside the selected range, subject to the restrictions below.
       
       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
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +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.
      ++
     -+An editor opens pre-filled with the messages of all the folded commits so
     -+you can combine them. With `--no-edit`, the oldest commit's message is
     -+preserved instead, except that an `amend!` commit targeting it replaces its
     -+message. A merge commit inside the range is folded like any other, but the
     -+range must have a single base and a single tip. 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), or that selects two unmerged tips, is rejected.
     ++An editor opens pre-filled with the messages of all the folded commits so you
     ++can combine them. With `--no-edit`, the oldest commit's message is preserved
     ++instead, except that an `amend!` commit targeting it replaces its message.
     +++
     ++The selected commits must form a connected graph with a single tip and must
     ++not include a root commit. Every parent of a commit after the oldest one must
     ++either be selected or also be a parent of the oldest commit. When the oldest
     ++commit is a merge, all of its parents are preserved in the squashed commit.
      ++
      +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. With `--no-edit`,
     -+the last `amend!` message is used if there is one.
     ++the last `amend!` message is used if there is one; a `squash!` or `amend!` is
     ++otherwise refused if folding it would discard its 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
     ++The editor 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 local branch that points at a commit inside the range cannot be rewritten
     -+as a descendant of the result, so with the default `--update-refs=branches`
     -+the command refuses. Rerun with `--update-refs=head` to rewrite only the
     -+current branch and leave such branches pointing at the old commits. Tags and
     -+remote-tracking refs are always left unchanged.
     ++A local branch descended from a selected commit but not from the range tip
     ++cannot be rewritten as a descendant of the result, so with the default
     ++`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
     ++to rewrite only the current branch and leave such branches unchanged. Tags
     ++and remote-tracking refs are always left unchanged.
      +
       OPTIONS
       -------
     @@ Documentation/git-history.adoc: OPTIONS
      +`--edit`::
      +`--no-edit`::
      +	For `squash`, open an editor to combine the messages of the folded commits.
     -+	This is the default; use `--no-edit` to keep the selected message
     -+	without opening an editor.
     ++	This is the default; use `--no-edit` to keep the selected message without
     ++	opening an editor.
      +
       `--reedit-message`::
       	Open an editor to modify the target commit's message.
     @@ builtin/history.c
       #include "cache-tree.h"
       #include "commit.h"
       #include "commit-reach.h"
     +@@
     + #include "path.h"
     + #include "read-cache.h"
     + #include "refs.h"
     ++#include "ref-filter.h"
     + #include "replay.h"
     + #include "reset.h"
     + #include "revision.h"
      @@
       	N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
       #define GIT_HISTORY_SPLIT_USAGE \
     @@ builtin/history.c: out:
       	return ret;
       }
       
     ++/*Remember to update object flag allocation in object.h */
     ++#define SQUASH_SEEN (1u << 11)
     ++#define SQUASH_TIP (1u << 12)
     ++#define SQUASH_AMEND_TARGET (1u << 13)
     ++
     ++static bool is_autosquash_subject(const char *s)
     ++{
     ++	return starts_with(s, "amend!") || starts_with(s, "fixup!") ||
     ++		starts_with(s, "squash!");
     ++}
     ++
     ++static bool skip_one_autosquash_prefix(const char *s, const char **out)
     ++{
     ++	if (skip_prefix(s, "amend!", out) || skip_prefix(s, "fixup!", out) ||
     ++	    skip_prefix(s, "squash!", out)) {
     ++		while (**out == ' ')
     ++			(*out)++;
     ++		return true;
     ++	}
     ++	return false;
     ++}
     ++
     ++static void truncate_message_to_subject(struct strbuf *msg)
     ++{
     ++	const char *eos = strstr(msg->buf, "\n\n");
     ++
     ++	if (eos)
     ++		strbuf_setlen(msg, eos - msg->buf + 1);
     ++}
     ++
     ++struct subject_data {
     ++	struct strintmap subjects;
     ++	struct strbuf subject;
     ++	struct strbuf squash_message;
     ++	const char *message;
     ++	bool edit_message;
     ++};
     ++
     ++#define SUBJECT_DATA_INIT {		\
     ++	.subjects = STRINTMAP_INIT,	\
     ++	.subject = STRBUF_INIT,		\
     ++	.squash_message = STRBUF_INIT,	\
     ++}
     ++
     ++static void subject_data_clear(struct subject_data *data)
     ++{
     ++	strintmap_clear(&data->subjects);
     ++	strbuf_release(&data->subject);
     ++	strbuf_release(&data->squash_message);
     ++}
     ++
     ++static int squash_amend_message(struct repository *repo,
     ++				struct commit *commit,
     ++				struct subject_data *data,
     ++				unsigned flags)
     ++{
     ++	const char *body = data->message + data->subject.len;
     ++
     ++	while (isspace(*body))
     ++		body++;
     ++
     ++	if (!*body) {
     ++		warning(_("ignoring %s (%s): message body is empty"),
     ++			repo_find_unique_abbrev(repo, &commit->object.oid,
     ++						DEFAULT_ABBREV),
     ++			data->subject.buf);
     ++		return 0;
     ++	}
     ++
     ++	if (data->edit_message) {
     ++		return 0;
     ++	} else if (flags & SQUASH_AMEND_TARGET) {
     ++		if (starts_with(data->squash_message.buf, "squash!"))
     ++			return error(_("squashing %s (%s) would overwrite "
     ++				       "'squash!' message, please combine them "
     ++				       "using '--edit'"),
     ++				     repo_find_unique_abbrev(repo,
     ++							     &commit->object.oid,
     ++							     DEFAULT_ABBREV),
     ++				     data->subject.buf);
     ++		if (starts_with(data->squash_message.buf, "fixup!"))
     ++			strbuf_splice(&data->squash_message, 0, 5, "amend!", 5);
     ++		if (starts_with(data->squash_message.buf, "amend!")) {
     ++			truncate_message_to_subject(&data->squash_message);
     ++			strbuf_addch(&data->squash_message, '\n');
     ++		} else {
     ++			strbuf_reset(&data->squash_message);
     ++		}
     ++		strbuf_addstr(&data->squash_message, body);
     ++		strbuf_complete_line(&data->squash_message);
     ++	} else {
     ++		return error(_("cannot squash %s (%s) that does not target "
     ++			       "base commit without '--edit'"),
     ++			     repo_find_unique_abbrev(repo, &commit->object.oid,
     ++						     DEFAULT_ABBREV),
     ++			     data->subject.buf);
     ++	}
     ++	return 0;
     ++}
     ++
     ++static int squash_squash_message(struct repository *repo,
     ++				struct commit *commit,
     ++				struct subject_data *data,
     ++				unsigned flags)
     ++{
     ++	const char *body = data->message + data->subject.len;
     ++
     ++	while (isspace(*body))
     ++		body++;
     ++
     ++	if (data->edit_message) {
     ++		return 0;
     ++	} else if (flags & SQUASH_AMEND_TARGET) {
     ++		if (starts_with(data->squash_message.buf, "fixup!")) {
     ++			truncate_message_to_subject(&data->squash_message);
     ++			strbuf_splice(&data->squash_message, 0, 5, "squash", 6);
     ++		}
     ++		if (starts_with(data->squash_message.buf, "squash!")) {
     ++			strbuf_addch(&data->squash_message, '\n');
     ++			strbuf_addstr(&data->squash_message, body);
     ++			strbuf_complete_line(&data->squash_message);
     ++		} else {
     ++			return error(_("squashing %s (%s) would discard its "
     ++				       "message, please combine them using "
     ++				       "'--edit'"),
     ++				     repo_find_unique_abbrev(repo,
     ++							     &commit->object.oid,
     ++							     DEFAULT_ABBREV),
     ++				     data->subject.buf);
     ++		}
     ++	} else {
     ++		return error(_("cannot squash %s (%s) that does not target "
     ++			       "base commit without '--edit'"),
     ++			      repo_find_unique_abbrev(repo, &commit->object.oid,
     ++						      DEFAULT_ABBREV),
     ++			      data->subject.buf);
     ++	}
     ++	return 0;
     ++}
     ++
     ++static int squash_check_can_autosquash(struct repository *repo,
     ++				       struct commit *commit,
     ++				       struct subject_data *data,
     ++				       unsigned flags)
     ++{
     ++	commit->object.flags |= flags & SQUASH_AMEND_TARGET;
     ++	if (starts_with(data->subject.buf, "amend!"))
     ++		return squash_amend_message(repo, commit, data, flags);
     ++	else if (starts_with(data->subject.buf, "squash!"))
     ++		return squash_squash_message(repo, commit, data, flags);
     ++
     ++	return 0;
     ++}
     ++
     ++static int squash_check_autosquash_subject(struct repository *repo,
     ++					   struct commit *commit,
     ++					   struct subject_data *data)
     ++{
     ++	const char* s = data->subject.buf;
     ++	struct commit *target;
     ++	struct hashmap_iter iter;
     ++	struct strmap_entry *entry;
     ++	/* Try skipping autosquash prefixes one at a time to allow
     ++	 * squashing
     ++	 *     a commit
     ++	 *     fixup! fixup! a commit
     ++	 *
     ++	 * where we may have started with
     ++	 *     a commit
     ++	 *     fixup! a commit
     ++	 *     fixup! fixup! a commit
     ++	 *
     ++	 * and squashed the first fixup separately from the second
     ++	 */
     ++	while (skip_one_autosquash_prefix(s, &s)) {
     ++		unsigned flags = strintmap_get(&data->subjects, s);
     ++		if (flags)
     ++			return squash_check_can_autosquash(repo, commit, data, flags);
     ++	}
     ++	/*
     ++	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
     ++	 * "fixup! main". If the target is not being squshed check the subject
     ++	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
     ++	 * squashed together.
     ++	 */
     ++	target = lookup_commit_reference_by_name(s);
     ++	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {
     ++		unsigned flags =
     ++			target->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);
     ++		if (!flags) {
     ++			const char *subject_start;
     ++			const char *buffer = repo_logmsg_reencode(repo, target,
     ++								  NULL, NULL);
     ++			size_t subject_len = find_commit_subject(buffer,
     ++								 &subject_start);
     ++			char *subject = xmemdupz(subject_start, subject_len);
     ++
     ++			flags = strintmap_get(&data->subjects, subject);
     ++			free(subject);
     ++			repo_unuse_commit_buffer(repo, target, buffer);
     ++		}
     ++		if (flags)
     ++			return squash_check_can_autosquash(repo, commit,
     ++							   data, flags);
     ++	}
     ++	/* Try subject prefix matches */
     ++	strintmap_for_each_entry(&data->subjects, &iter, entry) {
     ++		s = data->subject.buf;
     ++		while(skip_one_autosquash_prefix(s, &s)) {
     ++			if (starts_with(entry->key, s)) {
     ++				unsigned value = (intptr_t)entry->value;
     ++
     ++				return squash_check_can_autosquash(repo, commit,
     ++								   data, value);
     ++			}
     ++		}
     ++	}
     ++	return error(_("cannot squash %s (%s): its target is not being "
     ++		       "squashed"),
     ++		       repo_find_unique_abbrev(repo, &commit->object.oid,
     ++					       DEFAULT_ABBREV),
     ++		       data->subject.buf);
     ++}
     ++
     ++static int squash_check_subject(struct repository *repo,
     ++				struct commit *commit,
     ++				struct subject_data *data)
     ++{
     ++	int ret = 0;
     ++	const char *buf = repo_logmsg_reencode(repo, commit, NULL, NULL);
     ++	size_t subject_len = find_commit_subject(buf, &data->message);
     ++
     ++	strbuf_reset(&data->subject);
     ++	strbuf_add(&data->subject, data->message, subject_len);
     ++
     ++	if (!strintmap_get_size(&data->subjects)) {
     ++		const char *s;
     ++
     ++		strbuf_addstr(&data->squash_message, data->message);
     ++		strbuf_complete_line(&data->squash_message);
     ++		/*
     ++		 * Strip a single autosquash prefix to allow squashing
     ++		 *     fixup! base
     ++		 *     amend! base
     ++		 */
     ++		s = data->subject.buf;
     ++		skip_one_autosquash_prefix(s, &s);
     ++		strintmap_set(&data->subjects, s, SQUASH_AMEND_TARGET | SQUASH_SEEN);
     ++		commit->object.flags |= SQUASH_AMEND_TARGET;
     ++	} else if (is_autosquash_subject(data->subject.buf)) {
     ++		ret = squash_check_autosquash_subject(repo, commit, data);
     ++	} else {
     ++		strintmap_set(&data->subjects, data->subject.buf, SQUASH_SEEN);
     ++	}
     ++	repo_unuse_commit_buffer(repo, commit, buf);
     ++	return ret;
     ++}
     ++
     ++static int build_squash_message(struct repository *repo,
     ++				const struct strbuf *todo_buf,
     ++				struct strbuf *out);
     ++
      +/*
      + * Resolve a "<base>..<tip>" revision range into the base commit just outside
      + * the range (which becomes the parent of the squashed commit), the oldest
     @@ builtin/history.c: out:
      + * 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,
     ++				bool update_branches,
     ++				bool edit_message,
     ++				int argc, const char **argv,
      +				struct commit **oldest_out,
      +				struct commit **tip_out,
     -+				struct oidset *interior_out)
     ++				char **message_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;
     ++	struct subject_data subject_data = SUBJECT_DATA_INIT;
     ++	struct commit *commit, *oldest = NULL, *tip = NULL;
     ++	struct strbuf todo_buf = STRBUF_INIT;
      +	size_t i;
     -+	int ret;
     ++	int ret, tip_count = 0;
     ++	struct ref_filter filter = REF_FILTER_INIT;
     ++	struct ref_array refs = { 0 };
      +
      +	repo_init_revisions(repo, &revs, NULL);
     ++	subject_data.edit_message = edit_message;
      +	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]);
     ++	revs.ancestry_path = 1;
     ++	revs.limited = 1;
     ++	revs.ancestry_path_implicit_bottoms = 1;
     ++
     ++	argc = setup_revisions(argc, argv, &revs, NULL);
     ++	if (argc > 1) {
     ++		ret = error(_("unrecognized argument: %s"), argv[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) {
     ++	    revs.simplify_history != 0 || revs.boundary == 1 ||
     ++	    revs.ancestry_path != 1 || revs.limited != 1 ||
     ++	    revs.ancestry_path_implicit_bottoms != 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;
     ++		revs.boundary = 0;
     ++		revs.ancestry_path = 1;
     ++		revs.limited = 1;
     ++		revs.ancestry_path_implicit_bottoms = 1;
      +	}
      +
     -+	/* A squash range must name a bottom revision to reparent onto. */
     ++	/*
     ++	 * A squash needs a base to reparent onto, so the range has to exclude
     ++	 * something, as in "<base>..<tip>". 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 & BOTTOM)
      +			break;
     @@ builtin/history.c: out:
      +		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;
     ++		struct commit_list *p;
     ++
     ++		if (edit_message)
     ++			strbuf_addf(&todo_buf, "pick %s\n",
     ++				    oid_to_hex(&commit->object.oid));
     ++
     ++		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;
     ++					commit_list_insert(commit, &filter.with_commit);
     ++				}
     ++			}
     ++			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)
     ++		if (!oldest) {
     ++			commit_list_insert(commit, &filter.with_commit);
      +			oldest = commit;
     -+		oidset_insert(&selected, &commit->object.oid);
     -+		commits_tail = commit_list_append(commit, commits_tail);
     ++		}
     ++		if (squash_check_subject(repo, commit, &subject_data)) {
     ++			ret = -1;
     ++			goto out;
     ++		}
     ++		tip = commit;
     ++		tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
     ++		tip_count++;
      +	}
     -+
     -+	if (!oldest) {
     ++	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);
     ++	reset_revision_walk();
     ++	if (!tip_count) {
      +		ret = error(_("the revision range is empty"));
      +		goto out;
     -+	} else if (!commits->next) {
     ++	} 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");
      +	}
     -+
     -+	/*
     -+	 * 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"));
     ++	commit_list_insert(tip, &filter.no_commit);
     ++	filter.kind = FILTER_REFS_BRANCHES;
     ++	if (update_branches &&
     ++	    filter_refs(&refs, &filter, filter.kind)) {
     ++		ret = error(_("could not filter refs"));
      +		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"));
     ++	if (refs.nr) {
     ++		/*
     ++		 * TODO: list the branches and also check HEADS from other worktrees
     ++		 */
     ++		ret = error(_("a branch points to a commit that is being squashed"));
     ++		advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
     ++				  _("Use --update-refs=head to rewrite only "
     ++				    "the current branch and leave such refs "
     ++				    "untouched."));
      +		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"));
     ++	if (edit_message) {
     ++		strbuf_reset(&subject_data.squash_message);
     ++		ret = build_squash_message(repo, &todo_buf,
     ++					   &subject_data.squash_message);
     ++		if (ret < 0)
      +			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;
     ++	*message_out = strbuf_detach(&subject_data.squash_message, NULL);
      +	ret = 0;
      +
      +out:
     -+	commit_list_free(boundaries);
     -+	commit_list_free(commits);
     -+	oidset_clear(&selected);
     -+	oidset_clear(&has_children);
     -+	reset_revision_walk();
     ++	strbuf_release(&todo_buf);
     ++	subject_data_clear(&subject_data);
      +	release_revisions(&revs);
     -+	strvec_clear(&args);
     ++	ref_filter_clear(&filter);
     ++	ref_array_clear(&refs);
      +	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;
     @@ builtin/history.c: out:
      +}
      +
      +static int build_squash_message(struct repository *repo,
     -+				struct commit *base,
     -+				struct commit *tip,
     ++				const struct strbuf *todo_buf,
      +				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 ||
     ++	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;
     @@ builtin/history.c: out:
      +out:
      +	todo_list_release(&todo);
      +	replay_opts_release(&opts);
     -+	reset_revision_walk();
     -+	release_revisions(&revs);
     -+	strvec_clear(&args);
      +	return ret;
      +}
      +
     @@ builtin/history.c: out:
      +		NULL,
      +	};
      +	enum ref_action action = REF_ACTION_DEFAULT;
     -+	enum commit_tree_flags flags = 0;
      +	int dry_run = 0;
      +	int edit = 1;
      +	struct option options[] = {
     @@ builtin/history.c: out:
      +		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;
     ++	struct commit *oldest, *tip, *rewritten;
      +	const struct object_id *base_tree_oid, *tip_tree_oid;
     -+	const char *message_template = NULL;
     -+	struct commit_list *parents = NULL;
     ++	char *message_template = NULL;
      +	struct rev_info revs = { 0 };
      +	int ret;
      +
      +	argc = parse_options(argc, argv, prefix, options, usage,
     -+			     PARSE_OPT_KEEP_UNKNOWN_OPT);
     -+	if (!argc) {
     ++			     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
     ++	if (argc < 2) {
      +		ret = error(_("command expects a revision range"));
      +		goto out;
      +	}
     @@ builtin/history.c: out:
      +	if (action == REF_ACTION_DEFAULT)
      +		action = REF_ACTION_BRANCHES;
      +
     -+	ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
     -+				   &interior);
     -+	if (ret < 0)
     -+		goto out;
     ++	strbuf_addstr(&reflog_msg, "squash: updating ");
     ++	strbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');
      +
     -+	ret = reject_dangling_fixups(repo, base, tip, &amend_source);
     ++	ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
     ++				   edit,
     ++				   argc, argv, &oldest, &tip,
     ++				   &message_template);
      +	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;
     ++	base_tree_oid = &repo_get_commit_tree(repo,
     ++					oldest->parents->item)->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);
     ++			      oldest->parents, base_tree_oid, tip_tree_oid,
     ++			      &rewritten,
     ++			      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);
      +	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);
     @@ builtin/history.c: out:
      +
      +out:
      +	strbuf_release(&reflog_msg);
     -+	strbuf_release(&message);
     -+	oidset_clear(&interior);
     -+	commit_list_free(parents);
      +	release_revisions(&revs);
     ++	free(message_template);
      +	return ret;
      +}
      +
     @@ builtin/history.c: int cmd_history(int argc,
       	};
       
      
     + ## object.h ##
     +@@ object.h: void object_array_init(struct object_array *array);
     +  * bisect.c:                                        16
     +  * bundle.c:                                        16
     +  * http-push.c:                          11-----14
     ++ * builtin/history.c:                    11---13
     +  * commit-graph.c:                                15
     +  * commit-reach.c:                                  16-------20
     +  * builtin/last-modified.c:                         1617
     +
       ## t/meson.build ##
      @@ t/meson.build: integration_tests = [
         't3452-history-split.sh',
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp expect actual
      +}
      +
     ++# Checks that the author data of two commits matches
     ++# Usage: check_commit_author <rev1> <rev2>
     ++check_commit_author () {
     ++	git show -s --format="%an <%ae> %ad" "$1" >expect &&
     ++	git show -s --format="%an <%ae> %ad" "$2" >actual &&
     ++	test_cmp expect actual
     ++}
     ++
      +test_expect_success 'setup linear history touching two files' '
     -+	test_commit base file a &&
     -+	git tag start &&
     -+	GIT_AUTHOR_NAME=Squasher [email protected] \
     -+		test_commit --no-tag one other x &&
     -+	test_commit --no-tag two file c &&
     -+	test_commit three file d
     ++	test_commit base file a start &&
     ++	GIT_AUTHOR_NAME=One [email protected] \
     ++		test_commit one other x &&
     ++	GIT_AUTHOR_NAME=Two [email protected] \
     ++		test_commit two file c &&
     ++	GIT_AUTHOR_NAME=Three [email protected] \
     ++		test_commit three file d
      +'
      +
      +test_expect_success 'errors on missing range argument' '
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp_rev "$head_before" HEAD
      +'
      +
     ++test_expect_success 'rejects root commit' '
     ++	# create a disconnected root commit
     ++	oid=$(git commit-tree -m root three^{tree}) &&
     ++	# because we pass --ancestry-path when calling setup_revs() it the
     ++	# revision walk will only include commits decended from $oid so
     ++	# we need to give it another --ancestry-path commit to actually walk
     ++	# any commits.
     ++	test_must_fail git history squash --ancestry-path=start $oid..three 2>err &&
     ++	echo "error: cannot squash down to root commit" >expect &&
     ++	test_cmp expect 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 &&
     ++	echo "error: the revision range contains more than one tip commit" >expect &&
     ++	test_cmp expect err &&
     ++
     ++	git reset --hard three &&
     ++	git history squash --no-edit ^start three~1 three &&
     ++	test_cmp_rev HEAD~1 start^0 &&
     ++	test_cmp_rev HEAD^{tree} three^{tree}
     ++'
     ++
      +test_expect_success 'accepts multiple revision arguments with an exclusion' '
      +	git reset --hard three &&
      +	git branch -f keep HEAD~2 &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D keep
      +'
      +
     -+test_expect_success 'refuses a range with more than one tip' '
     -+	git reset --hard start &&
     -+	main=$(git symbolic-ref --short HEAD) &&
     -+	test_commit --no-tag main-one file b &&
     -+	test_commit --no-tag main-two file c &&
     -+	git checkout -b other-tip start &&
     -+	test_commit --no-tag other-tip other d &&
     -+	git checkout "$main" &&
     -+	main_before=$(git rev-parse HEAD) &&
     -+	other_before=$(git rev-parse other-tip) &&
     -+
     -+	test_must_fail git history squash ^start HEAD other-tip 2>err &&
     -+	test_grep "more than one tip" err &&
     -+	test_cmp_rev "$main_before" HEAD &&
     -+	test_cmp_rev "$other_before" other-tip &&
     -+
     -+	git branch -D other-tip
     -+'
     -+
     -+test_expect_success 'refuses a range that reaches a root commit' '
     -+	git reset --hard three &&
     -+	root=$(printf "unrelated root\n" |
     -+		git commit-tree "$(git rev-parse HEAD^{tree})") &&
     -+	head_before=$(git rev-parse HEAD) &&
     -+
     -+	test_must_fail git history squash --ancestry-path="$root" \
     -+		^start HEAD "$root" 2>err &&
     -+	test_grep "reaches a root commit" err &&
     -+	test_cmp_rev "$head_before" HEAD
     -+'
     -+
      +test_expect_success 'squashes a branch the current branch is not on' '
      +	git reset --hard three &&
      +	main=$(git symbolic-ref --short HEAD) &&
     @@ t/t3455-history-squash.sh (new)
      +	git branch -D off-history
      +'
      +
     -+test_expect_success 'squashes a range preserving its tree and oldest authorship' '
     ++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 &&
     ++	git history squash --no-edit --dry-run start.. >out &&
      +	predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
      +	test_cmp_rev "$head_before" HEAD &&
      +
     -+	git history squash start.. &&
     ++	git history squash --no-edit start.. &&
      +
      +	test "$predicted" = "$(git rev-parse HEAD)" &&
      +	check_commit_count start..HEAD 1 &&
     @@ t/t3455-history-squash.sh (new)
      +	check_log_subjects -1 <<-\EOF &&
      +	one
      +	EOF
     -+	git log -1 --format="%an <%ae>" >actual &&
     -+	echo "Squasher <[email protected]>" >expect &&
     -+	test_cmp expect actual &&
      +	git reflog >reflog &&
      +	test_grep "squash: updating" reflog
      +'
     @@ t/t3455-history-squash.sh (new)
      +	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
      +'
      +
     ++test_expect_success 'squashing a mix of fixups' '
     ++	git reset --hard three &&
     ++	echo fix >file &&
     ++	git commit --fixup=two -a &&
     ++	echo really fix >file &&
     ++	git commit --fixup=one -a &&
     ++	echo really really fix >file &&
     ++	git commit --fixup=HEAD~1 -a && # fixup! two
     ++	echo really really really fix >file &&
     ++	git commit --fixup=HEAD~1 -a && # fixup! one
     ++
     ++	# squashing fixup! with a target that is not being squashed fails
     ++	test_must_fail git history squash one.. 2>err &&
     ++	test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
     ++
     ++	# squashing fixup! into fixup! with a different target fails
     ++	test_must_fail git history squash HEAD~4.. 2>err && # HEAD~4 is fixup! two
     ++	test_grep "^error: cannot squash .* (fixup! one): its target is not being squashed" err &&
     ++
     ++	# squashing a sequence of fixup! commits into their targets
     ++	git history squash --no-edit start..HEAD~1 &&
     ++	test_cmp_rev start HEAD~2 &&
     ++	check_commit_author one HEAD~1 &&
     ++	test_commit_message HEAD~1 -m one &&
      +
     -+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 &&
     -+
     ++	# squashing "fixup! fixup! <target>" into "<target>"
      +	git history squash --no-edit start.. &&
     -+
     -+	check_commit_count start..HEAD 1 &&
     -+	check_log_subjects -1 <<-\EOF
     -+	target
     -+	EOF
     ++	test_cmp_rev start HEAD~1 &&
     ++	check_commit_author one HEAD &&
     ++	test_commit_message HEAD -m one
      +'
      +
     -+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 'squashing "squash!" messages' '
     ++	git reset --hard two &&
     ++	echo fix >file &&
     ++	git commit --fixup=HEAD -a &&
     ++	oldest=$(git rev-parse HEAD) &&
     ++	echo better fix >file &&
     ++	git commit -a -F - <<-EOF &&
     ++	squash! $(git rev-parse two)
      +
     -+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" &&
     ++	Append this
     ++	EOF
      +
     -+	git history squash --no-edit start.. &&
     ++	echo an even better fix >file &&
     ++	git commit -a -F - <<-EOF &&
     ++	squash! squash! two
      +
     -+	check_commit_count start..HEAD 1 &&
     -+	check_log_subjects -1 <<-\EOF
     -+	fixup! base
     ++	Append this as well
      +	EOF
     -+'
      +
     -+test_expect_success 'combining below-range markers offers every 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" &&
     ++	# must edit when squashing "squash!" into its target
     ++	test_must_fail git history squash --no-edit two^.. 2>err &&
     ++	test_grep "^error: squashing .* (squash! [a-f0-9]*) would discard its message" err &&
      +
     -+	git history squash start.. &&
     ++	# squashing "squash!" into "fixup!" appends messages and changes
     ++	# subject prefix
     ++	git history squash --no-edit two.. &&
     ++	test_cmp_rev HEAD^ two &&
     ++	test_commit_message HEAD <<-\EOF &&
     ++	squash! two
      +
     -+	check_commit_count start..HEAD 1 &&
     -+	check_log_messages -1 <<-\EOF
     -+	fixup! base
     ++	Append this
      +
     -+	amend! base
     ++	Append this as well
     ++	EOF
     ++	check_commit_author "$oldest" HEAD &&
      +
     -+	amended body
     ++	git commit --allow-empty -F - <<-\EOF &&
     ++	amend! two
      +
     ++	A new message
      +	EOF
     ++
     ++	# "amend!" does not replace "squash!"
     ++	test_must_fail git history squash --no-edit HEAD~2.. 2>err &&
     ++	test_grep "^error: squashing .* (amend! two) would overwrite .squash!. message" err
      +'
      +
     -+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_expect_success '--no-edit uses last "amend!" message without an editor' '
     ++	git reset --hard three &&
     ++	write_script editor <<-\EOF &&
     ++	exit 1
     ++	EOF
     ++	test_set_editor "$(pwd)/editor" &&
     ++	echo fix >file &&
     ++	git commit --author="Fix Me <[email protected]>" --fixup=HEAD -a &&
     ++	git commit --allow-empty -F - <<-EOF &&
     ++	amend! $(git rev-parse --short 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
     -+'
     ++	The first reword
      +
     -+test_expect_success 'does not discard squash! or amend! message bodies' '
     -+	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" &&
     ++	More detail
     ++	EOF
      +
     -+	git history squash start.. &&
     ++	git commit --allow-empty -F - <<-\EOF &&
     ++	amend! three
      +
     -+	check_commit_count start..HEAD 1 &&
     -+	check_log_messages -1 <<-\EOF
     -+	marker-oldest
     ++	The second reword
      +
     -+	earlier message
     ++	Extra detail
     ++	EOF
      +
     -+	amended subject
     ++	test_commit WIP &&
      +
     -+	amended body
     ++	cat >msg <<-EOF &&
     ++	amend! $(git rev-parse HEAD^ | tr a-f A-F)
      +
     -+	wrong message
     ++	The third reword
      +
     ++	Excruciating detail
      +	EOF
     -+'
     -+
     -+test_expect_success '--no-edit keeps the selected message without an editor' '
     -+	git reset --hard start &&
     -+	test_commit --no-tag no-edit-target file b &&
     -+	git commit --allow-empty -m "squash! no-edit-target" &&
     -+	commit_with_message "amend! no-edit-target\n\namended subject\n\namended body\n" &&
      +
     -+	write_script editor <<-\EOF &&
     -+	exit 1
     ++	git commit --author="Someone Else <[email protected]>" --allow-empty \
     ++		-F msg &&
     ++
     ++	# squashing amend! updates the commit message
     ++	git history squash --no-edit three^.. &&
     ++	sed -e 1,2d msg | test_commit_message HEAD &&
     ++	check_commit_author three HEAD &&
     ++	test_cmp_rev HEAD^ three^ &&
     ++
     ++	# squashing amend! into fixup! updates subject prefix
     ++	git reset --hard HEAD@{1} &&
     ++	git history squash --no-edit three.. &&
     ++	sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
     ++	check_commit_author HEAD@{1}~4 HEAD &&
     ++	test_cmp_rev HEAD^ three &&
     ++
     ++	# squashing amend! into amend! keeps original subject line
     ++	git reset --hard HEAD@{1} &&
     ++	git history squash --no-edit HEAD~3.. &&
     ++	sed "1s/.*/amend! three/" msg | test_commit_message HEAD &&
     ++	test_cmp_rev HEAD~3 three &&
     ++
     ++	# all amend! messages must target the first commit
     ++	git reset --hard HEAD@{1} &&
     ++	git commit --allow-empty -F - <<-\EOF &&
     ++	amend! WIP
     ++
     ++	The real message
      +	EOF
     -+	test_set_editor "$(pwd)/editor" &&
     -+	git history squash --no-edit start.. &&
      +
     -+	check_log_messages -1 <<-\EOF &&
     -+	amended subject
     ++	test_must_fail git history squash --no-edit HEAD~4.. 2>err &&
     ++	test_grep "^error: cannot squash .* that does not target" err &&
      +
     -+	amended body
     ++	# amend! message that targets commit that is not in range is rejected
     ++	test_must_fail git history squash --no-edit HEAD~3.. 2>err &&
     ++	test_grep "^error: cannot squash .* target is not being squashed" err &&
     ++	test_set_editor :
     ++'
      +
     ++test_expect_success 'squashing fixups into a merge' '
     ++	test_when_finished \
     ++		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
     ++		 git branch -D feature" &&
     ++	git checkout -f start &&
     ++	test_commit F1 &&
     ++	git checkout -b feature start &&
     ++	test_commit F2 &&
     ++	git merge F1 &&
     ++	echo fixed >F1.t &&
     ++	cat >msg <<-EOF &&
     ++	amend! $(git rev-parse HEAD)
     ++
     ++	merge F1 and F2
     ++
     ++	reworded
      +	EOF
     -+	test_set_editor :
     ++
     ++	git commit -a -F msg &&
     ++	git history squash --no-edit HEAD^^! HEAD &&
     ++	test_cmp_rev HEAD^1 F2 &&
     ++	test_cmp_rev HEAD^2 F1 &&
     ++	test_cmp_rev HEAD@{1}^{tree} HEAD^{tree} &&
     ++	sed 1,2d msg | test_commit_message HEAD
      +'
      +
      +test_expect_success 'edits every message and aborts on an empty result' '
     @@ t/t3455-history-squash.sh (new)
      +	EOF
      +'
      +
     -+test_expect_success 'handles fixup!, squash! and amend! messages like rebase' '
     ++test_expect_success 'handles fixup!, squash! and amend! like rebase' '
      +	git reset --hard start &&
      +	test_commit --no-tag mark-base file b &&
      +	stage_file c &&
     @@ t/t3455-history-squash.sh (new)
      +	EOF
      +'
      +
     -+test_expect_success 'handles branches pointing at the squashed range' '
     ++test_expect_success '--update-refs=head only moves HEAD' '
      +	git reset --hard three &&
      +	git branch -f other HEAD &&
     -+	git branch -f mid HEAD~1 &&
      +	other_before=$(git rev-parse other) &&
     -+	mid_before=$(git rev-parse mid) &&
     -+	head_before=$(git rev-parse HEAD) &&
     ++
     ++	git history squash --no-edit --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 branch points into' '
     ++	test_when_finished \
     ++		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
     ++		 git branch -D feature" &&
     ++	git checkout -f -b feature start &&
     ++	test_commit C1 &&
     ++	test_commit C2 &&
     ++	git checkout -b topic-1 start &&
     ++	test_commit C3 &&
     ++	test_commit C4 &&
     ++	git checkout C3 &&
     ++	test_commit C5 &&
     ++	git checkout feature &&
     ++	git merge C5 &&
     ++	test_commit C6 &&
     ++	git checkout -b topic-2 C2 &&
     ++	test_commit C7 &&
     ++	git checkout feature &&
      +
      +	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 &&
     ++	# TODO: check the branch names when we print them (topic-1 & topic-2)
     ++	test_grep "^error: a branch points to" err &&
     ++	test_grep "^hint: .* --update-refs=head" err &&
     ++	test_cmp_rev C6 HEAD &&
     ++
     ++	# squash succeeds with --update-refs=head
     ++	git history squash --no-edit --update-refs=head start.. &&
     ++	test_cmp_rev start HEAD^ &&
     ++	test_cmp_rev C6^{tree} HEAD^{tree} &&
     ++	test_cmp_rev C6 HEAD@{1}
     ++'
     ++
     ++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 "^error: a branch points to" err &&
      +	test_grep ! "hint:" err &&
      +	test_cmp_rev "$head_before" HEAD &&
      +
     -+	git history squash --no-edit --update-refs=head start.. &&
     -+
     -+	check_commit_count start..HEAD 1 &&
     -+	test_cmp_rev "$other_before" other &&
     -+	test_cmp_rev "$mid_before" mid &&
     -+
     -+	git branch -D other mid
     ++	git branch -D mid
      +'
      +
      +test_expect_success 'leaves tags and remote-tracking refs unchanged' '
     @@ t/t3455-history-squash.sh (new)
      +	git update-ref -d refs/remotes/origin/mark
      +'
      +
     -+test_expect_success 'handles a branch pointing at an internal merge' '
     ++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 &&
     @@ t/t3455-history-squash.sh (new)
      +	test_commit --no-tag after-side file c &&
      +	git merge --no-ff -m merge inner-side &&
      +	git branch -D inner-side &&
     -+	git branch at-merge HEAD &&
      +	test_commit --no-tag after-merge file d &&
     -+	head_before=$(git rev-parse HEAD) &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     -+	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 &&
     -+
      +	git history squash --no-edit start.. &&
      +
      +	check_commit_count start..HEAD 1 &&
     @@ t/t3455-history-squash.sh (new)
      +	merged=$(git rev-parse HEAD) &&
      +
      +	test_must_fail git history squash "$base.." 2>err &&
     -+	test_grep "more than one base" err &&
     ++	test_grep "parent .* of commit .* is outside the revision range" err &&
      +	test_cmp_rev "$merged" HEAD
      +'
      +
     @@ t/t3455-history-squash.sh (new)
      +	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 --no-edit 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) &&
     @@ t/t3455-history-squash.sh (new)
      +	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_grep "parent .* of commit .* is outside the revision range" err &&
      +	test_cmp_rev "$merged" HEAD
      +'
      +
     @@ t/t3455-history-squash.sh (new)
      +	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 &&
     ++	# TODO: test for branch nome "at-merge"
     ++	test_grep "a branch points to a commit" err &&
     ++	test_cmp_rev "$head_before" HEAD &&
     ++
     ++	git branch -D at-merge
     ++'
     ++
      +test_done
Signature · 1 line
gitgitgadget
#139 lore raw

PATCH v12 1/4 history: extract helper for a commit's parent tree

From: Harald Nordgren <[email protected]>

Three places resolve the tree of a commit's first parent, falling back to the empty tree for a root commit, each repeating the same parse and oidcpy dance. Extract a first_parent_tree_oid() helper and route the existing callers through it.

No change in behavior.

---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)
builtin/history.c +26 -32
index 000155ad9c..c22715d848 100644
@@ -164,6 +164,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,21 +190,11 @@ 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));
-		}
-
-		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 commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,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);
 }
@@ -830,16 +831,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);
 
Signature · 1 line
gitgitgadget
#140 lore raw

PATCH v12 2/4 history: give commit_tree_ext a message template

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

commit_tree_ext() reuses the message of the commit it is handed. A caller that folds several commits together wants to seed the message from more than that single commit, so add an optional message_template parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)
builtin/history.c +10 -6
index c22715d848..a60e8dbcd0 100644
@@ -108,6 +108,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	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);
 }
 
@@ -675,7 +679,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) {
@@ -886,7 +890,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"));
@@ -903,7 +907,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"));
Signature · 1 line
gitgitgadget
#141 lore raw

PATCH v12 3/4 sequencer: share the squash message marker helpers and flags

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

When "git rebase -i" squashes commits it builds an editor template with a "This is a combination of N commits." banner, a "This is the 1st/Nth commit message:" header above each kept message (or a "will be skipped" header for a dropped one), and a commented-out subject for any fixup!, squash! or amend! commit. The banner, the headers and the subject-commenting all live in static helpers in sequencer.c wired to the rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(), add_squash_message_header() (which takes a flag for the "will be skipped" variant) and squash_subject_comment_len(), and use them from update_squash_messages() and append_squash_message(). Also move the todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git history squash" the same template.

No change in behavior.

---
 sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
 sequencer.h | 30 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 31 deletions(-)
sequencer.c +39 -31
index 82ab3c536f..642dfa9d91 100644
@@ -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;
+
+	commented_len = squash_subject_comment_len(body,
+				command == TODO_SQUASH || seen_squash(ctx));
 
-	strbuf_addf(buf, "\n%s ", comment_line_str);
-	strbuf_addf(buf, _(nth_commit_msg_fmt),
-		    ++ctx->current_fixup_count + 1);
+	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);
sequencer.h +30 -0
index 64a9c7fb1b..b01f897020 100644
@@ -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,
Signature · 1 line
gitgitgadget
#142 lore raw

PATCH v12 4/4 history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

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 selected commit into one, preserving the authorship and parents of the oldest commit and taking the tree of the tip, then replays commits above the range. By default an editor opens with every folded message in the same template used by "git rebase -i --autosquash". With --no-edit, the selected message is used without opening an editor.

Resolve fixup!, squash! and amend! subjects directly while walking the selected commits. A marker is rejected unless its fixed target is also selected, except that a range made up entirely of related markers can be consolidated. Without editing, refuse any squash! or amend! whose message would be discarded.

Read the range like arguments to "git rev-list" and accept multiple revisions and rev-list options. Restore the walk settings required by the fold after setup_revisions(), warning when an option changed them. The range must name a bottom commit, must not reach a root, and must have one tip. Every parent after the oldest commit must be selected or also be a parent of the oldest commit. This permits internal merges and preserves all parents when the oldest commit is itself a merge.

By default, refuse when a local branch descends from the selected commits but cannot remain a descendant of the result. Tags and remote-tracking refs are left unchanged. Use --update-refs=head to leave such local branches unchanged as well.

---
 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  59 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 611 +++++++++++++++++++++
 object.h                         |   1 +
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 884 +++++++++++++++++++++++++++++++
 8 files changed, 1560 insertions(+), 2 deletions(-)
 create mode 100755 t/t3455-history-squash.sh
Documentation/config/advice.adoc +4 -0
index 81f80a9274..e2a3487778 100644
@@ -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.
Documentation/git-history.adoc +57 -2

83 lines not on this page · raw message

advice.c +1 -0
index 63bf8b0c5f..401d047391 100644
@@ -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" },
advice.h +1 -0
index 66f6cd6a77..3f0b4f0485 100644
@@ -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,
builtin/history.c +611 -0

647 lines not on this page · raw message

object.h +1 -0
index 8fb03ff90a..46cade33fb 100644
@@ -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:                    11---13
  * commit-graph.c:                                15
  * commit-reach.c:                                  16-------20
  * builtin/last-modified.c:                         1617
t/meson.build +1 -0
index a25f37d2f5..78b8ea54ad 100644
@@ -406,6 +406,7 @@ integration_tests = [
   't3452-history-split.sh',
   't3453-history-fixup.sh',
   't3454-history-drop.sh',
+  't3455-history-squash.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
t/t3455-history-squash.sh new file +884 -0

884 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#143 lore raw

v13 cover history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

Adds git history squash <revision-range> to fold a range of commits.

Changes in v13:

* Split the squash implementation into five patches covering parsing, range validation, branch protection, commit creation, and message editing.
* Print the sorted names of local branches that prevent a squash, with clearer advice for --update-refs=head.
* Isolate --no-edit behavior from the final patch that enables default editor-based message combining.

Changes in v12:

* Incorporated fixups from Phillip:
* Reworks range validation into a single walk that rejects roots and multiple tips while preserving every parent when squashing into a merge.
* Resolves fixup!, squash! and amend! targets directly, preserving message intent and safely consolidating related markers.
* Builds the editor template from the exact selected revisions, including exclusions, while retaining --no-edit behavior.
* Protects descendant local branches while leaving tags and remote-tracking refs unchanged.

Changes in v11:

* Make message editing the default with the autosquash-style template, add --no-edit instead of squash-specific --reedit-message.
* Validate one actual boundary and tip, rejecting root-reaching and multi-tip ranges.
* Protect only interior local branches, leaving tags and remote-tracking refs unchanged.
* Move sequencer preparation before squash and fold the later message-editing patch into the feature commit.

Changes in v10:

* Record the full revision expression in squash reflog.
* Preserve the boundary-walk invariant when sanitizing rev-list options.
* Clarify amend! and --reedit-message documentation.

Changes in v9:

* Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
* Improve tests.

Changes in v8:

* --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
* A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
* Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
* Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
* Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

* --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
* Reuse git rebase -i's squash-message code: a preparatory sequencer:
commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
* Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
* Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
* Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

Changes in v6:

* git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works.
* The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently).
* Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

* The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.
* Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit.

Changes in v4:

* git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected.

Changes in v3:

* Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.
* Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point).
* A merge inside the range is folded fine, only a range with more than one base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just the oldest.

Harald Nordgren (8):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
sequencer: share the squash message marker helpers and flags
history: add skeleton for squash subcommand
history: validate squash revision ranges
history: protect branches when squashing a range
history: create squashed commits without editing
history: support editing squashed commit messages

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  59 +-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 720 +++++++++++++++++++++++--
 object.h                         |   1 +
 sequencer.c                      |  70 +--
 sequencer.h                      |  30 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++
 10 files changed, 1702 insertions(+), 71 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

base-commit: 2c78326f810173a4f3aefd8021f1e07575412481
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v13
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v13
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v12:

 1:  20e050a0ad = 1:  b175bdca54 history: extract helper for a commit's parent tree
 2:  b99867a0d4 = 2:  965074f9e8 history: give commit_tree_ext a message template
 3:  352db997e9 = 3:  cdbdd766b2 sequencer: share the squash message marker helpers and flags
 -:  ---------- > 4:  f7d953b081 history: add skeleton for squash subcommand
 -:  ---------- > 5:  dbbf66ba02 history: validate squash revision ranges
 -:  ---------- > 6:  e71a8adfbe history: protect branches when squashing a range
 4:  33d3eca6db ! 7:  8b3551d0d4 history: add squash subcommand to fold a range
     @@ Metadata
      Author: Harald Nordgren <[email protected]>
      
       ## Commit message ##
     -    history: add squash subcommand to fold a range
     +    history: create squashed commits without editing
      
     -    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".
     +    Create one replacement commit from the resolved range when --no-edit is
     +    selected. Preserve the authorship and all parents of the oldest commit,
     +    use the tip tree, and replay descendants through the existing history
     +    rewrite machinery. Record the complete revision expression in the
     +    reflog and retain dry-run and update-refs behavior.
      
     -    Add "git history squash <revision-range>" to do this directly. It folds
     -    every selected commit into one, preserving the authorship and parents of
     -    the oldest commit and taking the tree of the tip, then replays commits
     -    above the range. By default an editor opens with every folded message in
     -    the same template used by "git rebase -i --autosquash". With --no-edit,
     -    the selected message is used without opening an editor.
     -
     -    Resolve fixup!, squash! and amend! subjects directly while walking the
     -    selected commits. A marker is rejected unless its fixed target is also
     -    selected, except that a range made up entirely of related markers can be
     -    consolidated. Without editing, refuse any squash! or amend! whose message
     -    would be discarded.
     -
     -    Read the range like arguments to "git rev-list" and accept multiple
     -    revisions and rev-list options. Restore the walk settings required by the
     -    fold after setup_revisions(), warning when an option changed them. The
     -    range must name a bottom commit, must not reach a root, and must have one
     -    tip. Every parent after the oldest commit must be selected or also be a
     -    parent of the oldest commit. This permits internal merges and preserves
     -    all parents when the oldest commit is itself a merge.
     -
     -    By default, refuse when a local branch descends from the selected commits
     -    but cannot remain a descendant of the result. Tags and remote-tracking
     -    refs are left unchanged. Use --update-refs=head to leave such local
     -    branches unchanged as well.
     +    Resolve fixup!, squash! and amend! subjects while walking the range.
     +    Reject markers whose targets are not selected and refuse any no-edit
     +    fold that would discard a squash! or amend! message. A range made
     +    entirely from related markers can still be consolidated, with the last
     +    applicable amend! body supplying the message.
      
          Inspired-by: Sergey Chernov <[email protected]>
          Helped-by: Phillip Wood <[email protected]>
          Signed-off-by: Harald Nordgren <[email protected]>
      
     - ## Documentation/config/advice.adoc ##
     -@@ Documentation/config/advice.adoc: 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.
     -
       ## Documentation/git-history.adoc ##
     -@@ Documentation/git-history.adoc: git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
     - git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
     - git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
     - git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
     -+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
     - 
     - DESCRIPTION
     - -----------
      @@ Documentation/git-history.adoc: at once.
       LIMITATIONS
       -----------
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +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.
      ++
     -+An editor opens pre-filled with the messages of all the folded commits so you
     -+can combine them. With `--no-edit`, the oldest commit's message is preserved
     -+instead, except that an `amend!` commit targeting it replaces its message.
     ++With `--no-edit`, the oldest commit's message is preserved, except that an
     ++`amend!` commit targeting it replaces its message.
      ++
      +The selected commits must form a connected graph with a single tip and must
      +not include a root commit. Every parent of a commit after the oldest one must
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +the last `amend!` message is used if there is one; a `squash!` or `amend!` is
      +otherwise refused if folding it would discard its message.
      ++
     -+The editor 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 local branch descended from a selected commit but not from the range tip
      +cannot be rewritten as a descendant of the result, so with the default
      +`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
       OPTIONS
       -------
       
     -@@ Documentation/git-history.adoc: OPTIONS
     - 	objects will be written into the repository, so applying these printed
     - 	ref updates is generally safe.
     - 
     -+`--edit`::
     -+`--no-edit`::
     -+	For `squash`, open an editor to combine the messages of the folded commits.
     -+	This is the default; use `--no-edit` to keep the selected message without
     -+	opening an editor.
     -+
     - `--reedit-message`::
     - 	Open an editor to modify the target commit's message.
     - 
     -
     - ## advice.c ##
     -@@ advice.c: 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" },
     -
     - ## advice.h ##
     -@@ advice.h: 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,
      
       ## 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"
     -@@
     - #include "path.h"
     - #include "read-cache.h"
     - #include "refs.h"
     -+#include "ref-filter.h"
     - #include "replay.h"
     - #include "reset.h"
     - #include "revision.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)
     - {
      @@ builtin/history.c: out:
     - 	return ret;
     - }
       
     -+/*Remember to update object flag allocation in object.h */
     -+#define SQUASH_SEEN (1u << 11)
     -+#define SQUASH_TIP (1u << 12)
     + #define SQUASH_SEEN (1u << 11)
     + #define SQUASH_TIP (1u << 12)
      +#define SQUASH_AMEND_TARGET (1u << 13)
      +
      +static bool is_autosquash_subject(const char *s)
     @@ builtin/history.c: out:
      +	repo_unuse_commit_buffer(repo, commit, buf);
      +	return ret;
      +}
     -+
     -+static int build_squash_message(struct repository *repo,
     -+				const struct strbuf *todo_buf,
     -+				struct strbuf *out);
     -+
     -+/*
     -+ * 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,
     -+				bool update_branches,
     -+				bool edit_message,
     -+				int argc, const char **argv,
     -+				struct commit **oldest_out,
     + 
     + static int setup_squash_revisions(struct repository *repo,
     + 				  int argc, const char **argv,
     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,
     + 				bool update_branches,
     + 				int argc, const char **argv,
     + 				struct commit **oldest_out,
     +-				struct commit **tip_out)
      +				struct commit **tip_out,
      +				char **message_out)
     -+{
     -+	struct rev_info revs;
     + {
     + 	struct rev_info revs;
      +	struct subject_data subject_data = SUBJECT_DATA_INIT;
     -+	struct commit *commit, *oldest = NULL, *tip = NULL;
     -+	struct strbuf todo_buf = STRBUF_INIT;
     -+	size_t i;
     -+	int ret, tip_count = 0;
     -+	struct ref_filter filter = REF_FILTER_INIT;
     -+	struct ref_array refs = { 0 };
     -+
     -+	repo_init_revisions(repo, &revs, NULL);
     -+	subject_data.edit_message = edit_message;
     -+	revs.reverse = 1;
     -+	revs.topo_order = 1;
     -+	revs.sort_order = REV_SORT_IN_GRAPH_ORDER;
     -+	revs.simplify_history = 0;
     -+	revs.ancestry_path = 1;
     -+	revs.limited = 1;
     -+	revs.ancestry_path_implicit_bottoms = 1;
     -+
     -+	argc = setup_revisions(argc, argv, &revs, NULL);
     -+	if (argc > 1) {
     -+		ret = error(_("unrecognized argument: %s"), argv[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 ||
     -+	    revs.ancestry_path != 1 || revs.limited != 1 ||
     -+	    revs.ancestry_path_implicit_bottoms != 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 = 0;
     -+		revs.ancestry_path = 1;
     -+		revs.limited = 1;
     -+		revs.ancestry_path_implicit_bottoms = 1;
     -+	}
     -+
     -+	/*
     -+	 * A squash needs a base to reparent onto, so the range has to exclude
     -+	 * something, as in "<base>..<tip>". 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 & 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;
     -+	}
     -+	while ((commit = get_revision(&revs))) {
     -+		struct commit_list *p;
     -+
     -+		if (edit_message)
     -+			strbuf_addf(&todo_buf, "pick %s\n",
     -+				    oid_to_hex(&commit->object.oid));
     -+
     -+		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;
     -+					commit_list_insert(commit, &filter.with_commit);
     -+				}
     -+			}
     -+			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) {
     -+			commit_list_insert(commit, &filter.with_commit);
     -+			oldest = commit;
     -+		}
     + 	struct commit *commit, *oldest = NULL, *tip = NULL;
     + 	int ret, tip_count = 0;
     + 	bool walk_started = false;
     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,
     + 			commit_list_insert(commit, &filter.with_commit);
     + 			oldest = commit;
     + 		}
      +		if (squash_check_subject(repo, commit, &subject_data)) {
      +			ret = -1;
      +			goto out;
      +		}
     -+		tip = commit;
     -+		tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
     -+		tip_count++;
     -+	}
     -+	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP | SQUASH_AMEND_TARGET);
     -+	reset_revision_walk();
     -+	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");
     -+	}
     -+	commit_list_insert(tip, &filter.no_commit);
     -+	filter.kind = FILTER_REFS_BRANCHES;
     -+	if (update_branches &&
     -+	    filter_refs(&refs, &filter, filter.kind)) {
     -+		ret = error(_("could not filter refs"));
     -+		goto out;
     -+	}
     -+	if (refs.nr) {
     -+		/*
     -+		 * TODO: list the branches and also check HEADS from other worktrees
     -+		 */
     -+		ret = error(_("a branch points to a commit that is being squashed"));
     -+		advise_if_enabled(ADVICE_HISTORY_UPDATE_REFS,
     -+				  _("Use --update-refs=head to rewrite only "
     -+				    "the current branch and leave such refs "
     -+				    "untouched."));
     -+		goto out;
     -+	}
     -+	if (edit_message) {
     -+		strbuf_reset(&subject_data.squash_message);
     -+		ret = build_squash_message(repo, &todo_buf,
     -+					   &subject_data.squash_message);
     -+		if (ret < 0)
     -+			goto out;
     -+	}
     -+	*oldest_out = oldest;
     -+	*tip_out = tip;
     + 		tip = commit;
     + 		tip->object.flags |= SQUASH_SEEN | SQUASH_TIP;
     + 		tip_count++;
     +@@ builtin/history.c: static int resolve_squash_range(struct repository *repo,
     + 
     + 	*oldest_out = oldest;
     + 	*tip_out = tip;
      +	*message_out = strbuf_detach(&subject_data.squash_message, NULL);
     -+	ret = 0;
     -+
     -+out:
     -+	strbuf_release(&todo_buf);
     + 	ret = 0;
     + 
     + out:
     +-	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP);
     ++	clear_object_flags(repo, SQUASH_SEEN | SQUASH_TIP |
     ++			   SQUASH_AMEND_TARGET);
     + 	if (walk_started)
     + 		reset_revision_walk();
      +	subject_data_clear(&subject_data);
     -+	release_revisions(&revs);
     -+	ref_filter_clear(&filter);
     -+	ref_array_clear(&refs);
     -+	return ret;
     -+}
     -+
     -+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,
     -+				const struct strbuf *todo_buf,
     -+				struct strbuf *out)
     -+{
     -+	struct todo_list todo = TODO_LIST_INIT;
     -+	struct replay_opts opts = REPLAY_OPTS_INIT;
     -+	int i, nr_commits, ret;
     -+
     -+	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);
     -+	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;
     -+	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(),
     -+	};
     + 	release_revisions(&revs);
     + 	ref_filter_clear(&filter);
     + 	ref_array_clear(&refs);
     +@@ builtin/history.c: static int cmd_history_squash(int argc,
     + 			 N_("edit the commit message")),
     + 		OPT_END(),
     + 	};
     +-	struct commit *oldest, *tip;
      +	struct strbuf reflog_msg = STRBUF_INIT;
      +	struct commit *oldest, *tip, *rewritten;
      +	const struct object_id *base_tree_oid, *tip_tree_oid;
      +	char *message_template = NULL;
      +	struct rev_info revs = { 0 };
     -+	int ret;
     -+
     -+	argc = parse_options(argc, argv, prefix, options, usage,
     -+			     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
     + 	int ret;
     + 
     + 	argc = parse_options(argc, argv, prefix, options, usage,
     + 			     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
     +-	if (argc < 2)
     +-		return error(_("command expects a revision range"));
      +	if (argc < 2) {
      +		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;
     + 	repo_config(repo, git_default_config, NULL);
      +
     + 	if (action == REF_ACTION_DEFAULT)
     + 		action = REF_ACTION_BRANCHES;
     + 
      +	strbuf_addstr(&reflog_msg, "squash: updating ");
      +	strbuf_join_argv(&reflog_msg, argc - 1, argv + 1, ' ');
      +
     -+	ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
     -+				   edit,
     + 	ret = resolve_squash_range(repo, action == REF_ACTION_BRANCHES,
     +-				   argc, argv, &oldest, &tip);
      +				   argc, argv, &oldest, &tip,
      +				   &message_template);
     -+	if (ret < 0)
     + 	if (ret < 0)
     +-		return ret;
     ++		goto out;
     ++	if (edit) {
     ++		ret = error(_("message editing is not supported yet; use '--no-edit'"));
      +		goto out;
     ++	}
      +
      +	ret = setup_revwalk(repo, action, tip, &revs);
      +	if (ret < 0)
     @@ builtin/history.c: out:
      +
      +	ret = commit_tree_ext(repo, "squash", oldest, message_template,
      +			      oldest->parents, base_tree_oid, tip_tree_oid,
     -+			      &rewritten,
     -+			      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);
     ++			      &rewritten, 0);
      +	if (ret < 0) {
      +		ret = error(_("failed writing squashed commit"));
      +		goto out;
     @@ builtin/history.c: out:
      +		ret = error(_("failed replaying descendants"));
      +		goto out;
      +	}
     -+
     + 
     +-	return error(_("squashing commits is not implemented yet"));
      +	ret = 0;
      +
      +out:
     @@ builtin/history.c: out:
      +	release_revisions(&revs);
      +	free(message_template);
      +	return ret;
     -+}
     -+
     - static int update_worktree(struct repository *repo,
     - 			   const struct commit *old_head,
     - 			   const struct commit *new_head,
     -@@ builtin/history.c: 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;
     -@@ builtin/history.c: 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(),
     - 	};
     + }
       
     + static int update_worktree(struct repository *repo,
      
       ## object.h ##
      @@ object.h: void object_array_init(struct object_array *array);
        * bisect.c:                                        16
        * bundle.c:                                        16
        * http-push.c:                          11-----14
     +- * builtin/history.c:                    1112
      + * builtin/history.c:                    11---13
        * commit-graph.c:                                15
        * commit-reach.c:                                  16-------20
        * builtin/last-modified.c:                         1617
      
     - ## t/meson.build ##
     -@@ t/meson.build: 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',
     -
     - ## t/t3455-history-squash.sh (new) ##
     -@@
     -+#!/bin/sh
     -+
     -+test_description='tests for git-history squash subcommand'
     -+
     -+. ./test-lib.sh
     -+
     + ## t/t3455-history-squash.sh ##
     +@@ t/t3455-history-squash.sh: test_description='tests for git-history squash subcommand'
     + 
     + . ./test-lib.sh
     + 
     +-test_expect_success 'setup linear history' '
      +stage_file () {
      +	printf "%s\n" "$1" >file &&
      +	git add file
     @@ t/t3455-history-squash.sh (new)
      +}
      +
      +test_expect_success 'setup linear history touching two files' '
     -+	test_commit base file a start &&
     + 	test_commit base file a start &&
     +-	test_commit one file b &&
     +-	test_commit two file c &&
     +-	test_commit three file d
      +	GIT_AUTHOR_NAME=One [email protected] \
      +		test_commit one other x &&
      +	GIT_AUTHOR_NAME=Two [email protected] \
      +		test_commit two file c &&
      +	GIT_AUTHOR_NAME=Three [email protected] \
      +		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_expect_success 'errors on missing range argument' '
     +@@ t/t3455-history-squash.sh: test_expect_success 'errors on a single revision that is not a range' '
     + '
     + 
     + 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_must_fail git history squash "HEAD^!" 2>err &&
     +-	test_grep "single commit; nothing to squash" err
      +	test_grep "single commit; nothing to squash" err &&
      +	test_cmp_rev "$head_before" HEAD
     -+'
     -+
     + '
     + 
     +-test_expect_success 'rejects a root commit' '
      +test_expect_success 'rejects root commit' '
      +	# create a disconnected root commit
     -+	oid=$(git commit-tree -m root three^{tree}) &&
     + 	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
      +	# because we pass --ancestry-path when calling setup_revs() it the
      +	# revision walk will only include commits decended from $oid so
      +	# we need to give it another --ancestry-path commit to actually walk
     @@ t/t3455-history-squash.sh (new)
      +	test_must_fail git history squash --ancestry-path=start $oid..three 2>err &&
      +	echo "error: cannot squash down to root commit" >expect &&
      +	test_cmp expect err
     -+'
     -+
     -+test_expect_success 'rejects multiple tips' '
     -+	oid=$(git commit-tree -m tip -p start^0 three^{tree}) &&
     + '
     + 
     + 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_must_fail git history squash ^start $oid three~1 2>err &&
      +	echo "error: the revision range contains more than one tip commit" >expect &&
      +	test_cmp expect err &&
     @@ t/t3455-history-squash.sh (new)
      +	git history squash --no-edit ^start three~1 three &&
      +	test_cmp_rev HEAD~1 start^0 &&
      +	test_cmp_rev HEAD^{tree} three^{tree}
     -+'
     -+
     + '
     + 
     +-test_expect_success 'rejects a merge parent outside the range' '
     +-	git reset --hard start &&
      +test_expect_success 'accepts multiple revision arguments with an exclusion' '
      +	git reset --hard three &&
      +	git branch -f keep HEAD~2 &&
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'squashes a branch the current branch is not on' '
      +	git reset --hard three &&
     -+	main=$(git symbolic-ref --short HEAD) &&
     + 	main=$(git symbolic-ref --short HEAD) &&
     +-	git checkout -b outside-parent &&
     +-	test_commit --no-tag outside-parent outside x &&
      +	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 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
      +	git history squash --no-edit start..off-history &&
      +
      +	check_commit_count start..off-history 1 &&
     @@ t/t3455-history-squash.sh (new)
      +	test_must_fail git history squash --no-edit HEAD~3.. 2>err &&
      +	test_grep "^error: cannot squash .* target is not being squashed" err &&
      +	test_set_editor :
     -+'
     -+
     + '
     + 
     +-test_expect_success 'prints branches that cannot follow the squash' '
      +test_expect_success 'squashing fixups into a merge' '
      +	test_when_finished \
      +		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
     @@ t/t3455-history-squash.sh (new)
      +	sed 1,2d msg | test_commit_message HEAD
      +'
      +
     -+test_expect_success 'edits every message and aborts on an empty result' '
     -+	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 &&
     -+	head_before=$(git rev-parse HEAD) &&
     -+
     -+	write_script empty-editor <<-\EOF &&
     -+	>"$1"
     -+	EOF
     -+	test_set_editor "$(pwd)/empty-editor" &&
     -+	test_must_fail git history squash start.. 2>err &&
     -+	test_grep "Aborting commit due to empty commit message" err &&
     -+	test_cmp_rev "$head_before" HEAD &&
     -+
     -+	write_script editor <<-\EOF &&
     -+	cat "$1" >edited &&
     -+	echo combined >"$1"
     -+	EOF
     -+	test_set_editor "$(pwd)/editor" &&
     -+	git history squash 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 '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 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 'groups fixups under their targets in the editor' '
     -+	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 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 'lets amend! replace its target message in the editor' '
     -+	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 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 '--update-refs=head only moves HEAD' '
      +	git reset --hard three &&
      +	git branch -f other HEAD &&
     @@ t/t3455-history-squash.sh (new)
      +'
      +
      +test_expect_success 'refuses to fold a range a branch points into' '
     -+	test_when_finished \
     -+		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
     -+		 git branch -D feature" &&
     -+	git checkout -f -b feature start &&
     -+	test_commit C1 &&
     -+	test_commit C2 &&
     -+	git checkout -b topic-1 start &&
     -+	test_commit C3 &&
     -+	test_commit C4 &&
     -+	git checkout C3 &&
     -+	test_commit C5 &&
     -+	git checkout feature &&
     -+	git merge C5 &&
     -+	test_commit C6 &&
     -+	git checkout -b topic-2 C2 &&
     -+	test_commit C7 &&
     -+	git checkout feature &&
     -+
     -+	test_must_fail git history squash start.. 2>err &&
     -+	# TODO: check the branch names when we print them (topic-1 & topic-2)
     -+	test_grep "^error: a branch points to" err &&
     + 	test_when_finished \
     + 		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
     + 		 git branch -D feature" &&
     +@@ t/t3455-history-squash.sh: test_expect_success 'prints branches that cannot follow the squash' '
     + 	test_grep "^error: the following branches cannot be rewritten" err &&
     + 	test_grep "^  topic-1$" err &&
     + 	test_grep "^  topic-2$" err &&
     +-	test_grep "^hint: .* --update-refs=head" err
      +	test_grep "^hint: .* --update-refs=head" err &&
      +	test_cmp_rev C6 HEAD &&
      +
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp_rev start HEAD^ &&
      +	test_cmp_rev C6^{tree} HEAD^{tree} &&
      +	test_cmp_rev C6 HEAD@{1}
     -+'
     -+
     -+test_expect_success 'advice.historyUpdateRefs silences the hint' '
     -+	git reset --hard three &&
     -+	git branch -f mid HEAD~1 &&
     + '
     + 
     + 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 "^error: a branch points to" err &&
     -+	test_grep ! "hint:" err &&
     + 
     + 	test_must_fail git -c advice.historyUpdateRefs=false \
     + 		history squash start.. 2>err &&
     + 	test_grep "^error: the following branches cannot be rewritten" err &&
     + 	test_grep "^  mid$" err &&
     + 	test_grep ! "hint:" err &&
      +	test_cmp_rev "$head_before" HEAD &&
     -+
     -+	git branch -D mid
     -+'
     -+
     + 
     + 	git branch -D mid
     + '
     + 
      +test_expect_success 'leaves tags and remote-tracking refs unchanged' '
      +	git reset --hard three &&
      +	git tag -f mark HEAD~1 &&
     @@ t/t3455-history-squash.sh (new)
      +	head_before=$(git rev-parse HEAD) &&
      +
      +	test_must_fail git history squash start.. 2>err &&
     -+	# TODO: test for branch nome "at-merge"
     -+	test_grep "a branch points to a commit" err &&
     ++	test_grep "^error: the following branches cannot be rewritten" err &&
     ++	test_grep "^  at-merge$" err &&
      +	test_cmp_rev "$head_before" HEAD &&
      +
      +	git branch -D at-merge
      +'
      +
     -+test_done
     + test_done

-: ---------- > 8: 821217ed7c history: support editing squashed commit messages

Signature · 1 line
gitgitgadget
#144 lore raw

PATCH v13 1/8 history: extract helper for a commit's parent tree

From: Harald Nordgren <[email protected]>

Three places resolve the tree of a commit's first parent, falling back to the empty tree for a root commit, each repeating the same parse and oidcpy dance. Extract a first_parent_tree_oid() helper and route the existing callers through it.

No change in behavior.

---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)
builtin/history.c +26 -32
index 000155ad9c..c22715d848 100644
@@ -164,6 +164,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,21 +190,11 @@ 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));
-		}
-
-		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 commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,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);
 }
@@ -830,16 +831,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);
 
Signature · 1 line
gitgitgadget
#145 lore raw

PATCH v13 2/8 history: give commit_tree_ext a message template

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

commit_tree_ext() reuses the message of the commit it is handed. A caller that folds several commits together wants to seed the message from more than that single commit, so add an optional message_template parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)
builtin/history.c +10 -6
index c22715d848..a60e8dbcd0 100644
@@ -108,6 +108,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	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);
 }
 
@@ -675,7 +679,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) {
@@ -886,7 +890,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"));
@@ -903,7 +907,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"));
Signature · 1 line
gitgitgadget
#146 lore raw

PATCH v13 3/8 sequencer: share the squash message marker helpers and flags

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

When "git rebase -i" squashes commits it builds an editor template with a "This is a combination of N commits." banner, a "This is the 1st/Nth commit message:" header above each kept message (or a "will be skipped" header for a dropped one), and a commented-out subject for any fixup!, squash! or amend! commit. The banner, the headers and the subject-commenting all live in static helpers in sequencer.c wired to the rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(), add_squash_message_header() (which takes a flag for the "will be skipped" variant) and squash_subject_comment_len(), and use them from update_squash_messages() and append_squash_message(). Also move the todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git history squash" the same template.

No change in behavior.

---
 sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
 sequencer.h | 30 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 31 deletions(-)
sequencer.c +39 -31
index 83c3849205..4d2d59ecbe 100644
@@ -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) ||
@@ -2012,20 +2032,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;
+
+	commented_len = squash_subject_comment_len(body,
+				command == TODO_SQUASH || seen_squash(ctx));
 
-	strbuf_addf(buf, "\n%s ", comment_line_str);
-	strbuf_addf(buf, _(nth_commit_msg_fmt),
-		    ++ctx->current_fixup_count + 1);
+	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 */
@@ -2090,9 +2103,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))
@@ -2116,12 +2128,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),
@@ -2140,9 +2149,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);
sequencer.h +30 -0
index 64a9c7fb1b..b01f897020 100644
@@ -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,
Signature · 1 line
gitgitgadget
#147 lore raw

PATCH v13 4/8 history: add skeleton for squash subcommand

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Add the entry point and option parsing for "git history squash". Pass the remaining arguments through setup_revisions() so the command accepts revision ranges and rev-list options, while restoring the ordering and simplification settings required by the fold if an option changes them.

Require at least one BOTTOM revision. The squashed commit needs a commit outside the selected range to serve as its base, so a single positive revision is not a sufficient range.

Keep this step limited to defining the revision input contract so graph validation and the rewrite can be added independently.

---
 Documentation/git-history.adoc |  1 +
 builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+)
Documentation/git-history.adoc +1 -0
index 28b477cd37..b660baf94d 100644
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
 
 DESCRIPTION
 -----------
builtin/history.c +94 -0

118 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#148 lore raw

PATCH v13 5/8 history: validate squash revision ranges

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Walk the selected commits in topological order from oldest to newest and mark each one as it is seen. Every parent after the oldest commit must already be selected or also be a parent of the oldest commit. This accepts merges contained by the range while rejecting a merge arm that entered it from elsewhere.

Track the remaining graph tips during the same walk and require exactly one. Also reject empty and single-commit ranges and any selection that reaches a root commit. These checks identify the oldest commit whose parents will be preserved and the single tip whose tree will be used by the rewrite.

---
 builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----
 object.h                  |   1 +
 t/meson.build             |   1 +
 t/t3455-history-squash.sh |  65 +++++++++++++++++++++
 4 files changed, 174 insertions(+), 12 deletions(-)
 create mode 100755 t/t3455-history-squash.sh
builtin/history.c +107 -12
index 4f5a7a46ce..6541a397e8 100644
@@ -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 '<base>..<tip>' 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;
-
-	ret = error(_("squashing commits is not implemented yet"));
+		return ret;
 
-out:
-	release_revisions(&revs);
-	return ret;
+	return error(_("squashing commits is not implemented yet"));
 }
 
 static int update_worktree(struct repository *repo,
object.h +1 -0
index 8fb03ff90a..dcf30156ca 100644
@@ -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
t/meson.build +1 -0
index a25f37d2f5..78b8ea54ad 100644
@@ -406,6 +406,7 @@ integration_tests = [
   't3452-history-split.sh',
   't3453-history-fixup.sh',
   't3454-history-drop.sh',
+  't3455-history-squash.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
t/t3455-history-squash.sh new file +65 -0

65 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#149 lore raw

PATCH v13 6/8 history: protect branches when squashing a range

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

A local branch that descends from the selected graph without containing its tip cannot be replayed as a descendant of the squashed commit. Find those branches with ref-filter before creating any replacement objects and refuse the operation unless --update-refs=head was requested.

Limit this protection to local branches, matching the refs that the default history rewrite mode updates; tags and remote-tracking refs remain untouched. Sort the blocking refs and print their short branch names so the user can decide whether to move them or leave them behind.

Add advice.historyUpdateRefs for the hint that points to --update-refs=head.

---
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/history.c                | 70 ++++++++++++++++++++++++++++++--
 t/t3455-history-squash.sh        | 39 ++++++++++++++++++
 5 files changed, 111 insertions(+), 4 deletions(-)
Documentation/config/advice.adoc +4 -0
index 81f80a9274..3d91c90eda 100644
@@ -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 local branch
+		cannot be rewritten as a descendant of the squashed commit, to
+		tell the user about `--update-refs=head`.
 	ignoredHook::
 		Shown when a hook is ignored because the hook is not
 		set as executable.
advice.c +1 -0
index 63bf8b0c5f..401d047391 100644
@@ -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" },
advice.h +1 -0
index 66f6cd6a77..3f0b4f0485 100644
@@ -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,
builtin/history.c +66 -4

135 lines not on this page · raw message

t/t3455-history-squash.sh +39 -0
index df92aa4f6c..b1f65de5f5 100755
@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '
 	test_grep "parent .* of commit .* is outside the revision range" err
 '
 
+test_expect_success 'prints branches that cannot follow the squash' '
+	test_when_finished \
+		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
+		 git branch -D feature" &&
+	git checkout -f -b feature start &&
+	test_commit C1 &&
+	test_commit C2 &&
+	git checkout -b topic-1 start &&
+	test_commit C3 &&
+	test_commit C4 &&
+	git checkout C3 &&
+	test_commit C5 &&
+	git checkout feature &&
+	git merge C5 &&
+	test_commit C6 &&
+	git checkout -b topic-2 C2 &&
+	test_commit C7 &&
+	git checkout feature &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  topic-1$" err &&
+	test_grep "^  topic-2$" err &&
+	test_grep "^hint: .* --update-refs=head" err
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+
+	test_must_fail git -c advice.historyUpdateRefs=false \
+		history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  mid$" err &&
+	test_grep ! "hint:" err &&
+
+	git branch -D mid
+'
+
 test_done
Signature · 1 line
gitgitgadget
#150 lore raw

PATCH v13 7/8 history: create squashed commits without editing

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Create one replacement commit from the resolved range when --no-edit is selected. Preserve the authorship and all parents of the oldest commit, use the tip tree, and replay descendants through the existing history rewrite machinery. Record the complete revision expression in the reflog and retain dry-run and update-refs behavior.

Resolve fixup!, squash! and amend! subjects while walking the range. Reject markers whose targets are not selected and refuse any no-edit fold that would discard a squash! or amend! message. A range made entirely from related markers can still be consolidated, with the last applicable amend! body supplying the message.

---
 Documentation/git-history.adoc |  44 ++-
 builtin/history.c              | 324 ++++++++++++++++-
 object.h                       |   2 +-
 t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--
 4 files changed, 968 insertions(+), 35 deletions(-)
Documentation/git-history.adoc +42 -2
index b660baf94d..fb04a67685 100644
@@ -44,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 merges
+that lie inside the selected range, subject to the restrictions below.
 
 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
@@ -114,6 +117,43 @@ linkgit:gitglossary[7].
 It is invalid to select either all or no hunks, as that would lead to
 one of the commits becoming empty.
 
+`squash <revision-range>`::
+	Fold all commits in _<revision-range>_ into the oldest commit of that
+	range. The resulting commit keeps the oldest commit's authorship and
+	takes the tree of the range's newest commit, so the whole range
+	collapses into a single commit. Commits above the range are replayed
+	on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash 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.
++
+With `--no-edit`, the oldest commit's message is preserved, except that an
+`amend!` commit targeting it replaces its message.
++
+The selected commits must form a connected graph with a single tip and must
+not include a root commit. Every parent of a commit after the oldest one must
+either be selected or also be a parent of the oldest commit. When the oldest
+commit is a merge, all of its parents are preserved in the squashed commit.
++
+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. With `--no-edit`,
+the last `amend!` message is used if there is one; a `squash!` or `amend!` is
+otherwise refused if folding it would discard its message.
++
+A local branch descended from a selected commit but not from the range tip
+cannot be rewritten as a descendant of the result, so with the default
+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
+to rewrite only the current branch and leave such branches unchanged. Tags
+and remote-tracking refs are always left unchanged.
+
 OPTIONS
 -------
 
builtin/history.c +316 -8

372 lines not on this page · raw message

object.h +1 -1
index dcf30156ca..46cade33fb 100644
@@ -74,7 +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
+ * builtin/history.c:                    11---13
  * commit-graph.c:                                15
  * commit-reach.c:                                  16-------20
  * builtin/last-modified.c:                         1617
t/t3455-history-squash.sh +609 -24

680 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#151 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

Quoted 23 lines · Harald Nordgren via GitGitGadget
t/t3455-history-squash.sh +23 -0
+test_expect_success '--no-edit uses last "amend!" message without an editor' '
+	git reset --hard three &&
+	write_script editor <<-\EOF &&
+	exit 1
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	echo fix >file &&
+	git commit --author="Fix Me <[email protected]>" --fixup=HEAD -a &&
+	git commit --allow-empty -F - <<-EOF &&
+	amend! $(git rev-parse --short HEAD)
+
+	The first reword
+
+	More detail
+	EOF
+
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! three
+
+	The second reword
+
+	Extra detail
+	EOF

t/t3455-history-squash.sh test_expect_success 'errors on a single revision that is not a range' ' · line 317

Quoted 10 lines
t/t3455-history-squash.sh +10 -0
+
+	test_commit WIP &&
+
+	cat >msg <<-EOF &&
+	amend! $(git rev-parse HEAD^ | tr a-f A-F)
+
+	The third reword
+
+	Excruciating detail
+	EOF

Care to explain why you need to (1) eat the exit status of the 'git rev-parse' command and (2) munge the commit object name by piping it into "tr"?

When another in-flight topic is in effect, this test will break due to "amend!" followed by a string that is *not* an object name, as the other topic declares that uppercase letters are not valid in a hexadecimal string.

Thanks.

#152 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

Quoted 40 lines · Junio C Hamano
Quoted 23 lines · Harald Nordgren via GitGitGadget
(quoted diff) +23 -0
+test_expect_success '--no-edit uses last "amend!" message without an editor' '
+	git reset --hard three &&
+	write_script editor <<-\EOF &&
+	exit 1
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	echo fix >file &&
+	git commit --author="Fix Me <[email protected]>" --fixup=HEAD -a &&
+	git commit --allow-empty -F - <<-EOF &&
+	amend! $(git rev-parse --short HEAD)
+
+	The first reword
+
+	More detail
+	EOF
+
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! three
+
+	The second reword
+
+	Extra detail
+	EOF

Review

Quoted 10 lines
(quoted diff) +10 -0
+
+	test_commit WIP &&
+
+	cat >msg <<-EOF &&
+	amend! $(git rev-parse HEAD^ | tr a-f A-F)
+
+	The third reword
+
+	Excruciating detail
+	EOF

Care to explain why you need to (1) eat the exit status of the 'git rev-parse' command and (2) munge the commit object name by piping it into "tr"?

Quoted 4 lines

When another in-flight topic is in effect, this test will break due to "amend!" followed by a string that is *not* an object name, as the other topic declares that uppercase letters are not valid in a hexadecimal string.

In the meantime, I've queued the following on top of the series before rebuilding 'seen'.

t/t3455-history-squash.sh +1 -1
index 591463cb86..d21e9d9fc4 100755
@@ -309,7 +309,7 @@ test_expect_success '--no-edit uses last "amend!" message without an editor' '
 	test_commit WIP &&
 
 	cat >msg <<-EOF &&
-	amend! $(git rev-parse HEAD^ | tr a-f A-F)
+	amend! $(git rev-parse --short HEAD^)
 
 	The third reword
 
Signature · 1 line
2.55.0-758-g31b934252d
#153 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

In reply to Junio C Hamano
Quoted 5 lines · Junio C Hamano
Quoted 3 lines · Harald Nordgren via GitGitGadget

Thanks for the Helped-by: trailer, but this and several of the other patches are missing my sign off from the fixup patches I sent. My sign off should come above yours to reflect the chain of custody.

Quoted 29 lines
Quoted 29 lines
t/t3455-history-squash.sh +29 -0
+test_expect_success '--no-edit uses last "amend!" message without an editor' '
+	git reset --hard three &&
+	write_script editor <<-\EOF &&
+	exit 1
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	echo fix >file &&
+	git commit --author="Fix Me <[email protected]>" --fixup=HEAD -a &&
+	git commit --allow-empty -F - <<-EOF &&
+	amend! $(git rev-parse --short HEAD)
+
+	The first reword
+
+	More detail
+	EOF
+
+	git commit --allow-empty -F - <<-\EOF &&
+	amend! three
+
+	The second reword
+
+	Extra detail
+	EOF
+
+	test_commit WIP &&
+
+	cat >msg <<-EOF &&
+	amend! $(git rev-parse HEAD^ | tr a-f A-F)
+
Quoted 8 lines

t/t3455-history-squash.sh test_expect_success 'errors on a single revision that is not a range' ' · line 317

Quoted 4 lines
t/t3455-history-squash.sh +4 -0
+	The third reword
+
+	Excruciating detail
+	EOF

Care to explain why you need to (1) eat the exit status of the 'git rev-parse' command and (2) munge the commit object name by piping it into "tr"?

I chose to put the command substitution in the here doc, rather than move it out and use a variable because it was more convenient and it clearly shows what's going to be in the subject line. To me the benefits of that outweigh the minute risk that we'll miss a bug, that's not triggered elsewhere in the test suite, where "git rev-parse" produces the correct output but does not exit cleanly.

It's piped into "tr" to check that

	target = lookup_commit_reference_by_name(s);
	if (target && istarts_with(oid_to_hex(&target->object.oid), s))

uses istarts_with() so that we accept uppercase object ids.

Quoted 4 lines

When another in-flight topic is in effect, this test will break due to "amend!" followed by a string that is *not* an object name, as the other topic declares that uppercase letters are not valid in a hexadecimal string.

I've just had a quick look at that other thread, it would be more convincing to me if there was some explanation of the security issues that accepting uppercase object ids causes. While I appreciate brian may not be able to talk about specific vulnerabilities in particular products, it would help to outline the security issues in general terms.

Thanks

Phillip

#154 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

Quoted 6 lines · Phillip Wood

It's piped into "tr" to check that

	target = lookup_commit_reference_by_name(s);
	if (target && istarts_with(oid_to_hex(&target->object.oid), s))

uses istarts_with() so that we accept uppercase object ids.

You should check in with brian about istarts_with() that is used here, as I suspect he missed it in his effort to declare uppercase hexadecimal literals invalid. In the meantime, '| tr' must be removed if this topic wants to play nicely with the 'bc/restrict-hex-to-lowercase' topic. In my fixup patch, I changed it to use 'rev-parse --short' to test at least the "starts with" aspect of this code.

Quoted 5 lines

I've just had a quick look at that other thread, it would be more convincing to me if there was some explanation of the security issues that accepting uppercase object ids causes. While I appreciate brian may not be able to talk about specific vulnerabilities in particular products, it would help to outline the security issues in general terms.

True.

Thanks.

#155 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

Quoted 4 lines · Junio C Hamano
Quoted 2 lines · Phillip Wood

It's piped into "tr" to check that

Quoted 8 lines
Quoted 4 lines
	target = lookup_commit_reference_by_name(s);
	if (target && istarts_with(oid_to_hex(&target->object.oid), s))

uses istarts_with() so that we accept uppercase object ids.

You should check in with brian about istarts_with() that is used here, as I suspect he missed it in his effort to declare uppercase hexadecimal literals invalid.

Ah no, no, no. This is a new code introduced by this series.

And as I said, I do not see why we want to be case insensitive with this comparison.

#156 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

In reply to Phillip Wood
Quoted 5 lines · Phillip Wood

I've just had a quick look at that other thread, it would be more convincing to me if there was some explanation of the security issues that accepting uppercase object ids causes. While I appreciate brian may not be able to talk about specific vulnerabilities in particular products, it would help to outline the security issues in general terms.

In any case, I do not see any new use of istarts_with() in the patch to deliberately allow upppercase hexadecimal. It is not like there is an existing machinery that has generated uppercase hexadecimal for a long time and we need to support their output, or anything like that.

#157 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

In reply to Phillip Wood
Quoted 5 lines · Phillip Wood

I've just had a quick look at that other thread, it would be more convincing to me if there was some explanation of the security issues that accepting uppercase object ids causes. While I appreciate brian may not be able to talk about specific vulnerabilities in particular products, it would help to outline the security issues in general terms.

In any case, I do not see any reason to use istarts_with() in the patch to deliberately allow upppercase hexadecimal. It is not like there is an existing machinery that has generated uppercase hexadecimal for a long time and we need to support their output, or anything like that.

#158 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

In reply to Phillip Wood
Quoted 7 lines
Quoted 3 lines
Quoted 3 lines

Thanks for the Helped-by: trailer, but this and several of the other patches are missing my sign off from the fixup patches I sent. My sign off should come above yours to reflect the chain of custody.

Got it, should I use both headers?

Harald

#159 lore raw

Re: [PATCH v13 7/8] history: create squashed commits without editing

Quoted 8 lines · Harald Nordgren
Quoted 7 lines
Quoted 3 lines
Quoted 3 lines

Thanks for the Helped-by: trailer, but this and several of the other patches are missing my sign off from the fixup patches I sent. My sign off should come above yours to reflect the chain of custody.

Quoted 5 lines

Got it, should I use both headers?

In general swapping the first and second makes more sense.

S: PW
H: PW
S: HN

Because as a chain of custody this says: Phillip signed off, then Harald said that PW helped him, then Harald signed off on what he got from PW with perhaps his own changes mixed in.

sent from mobile

#160 lore raw

PATCH v13 8/8 history: support editing squashed commit messages

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Open the editor by default when squashing and provide --no-edit as the opt-out. Record the exact commits selected by the revision walk, rearrange that todo list with the sequencer's autosquash machinery, and build the message template from the resulting order.

Match interactive rebase's treatment of marker messages: comment out fixup! messages, retain squash! bodies, and let amend! replace its target unless a preceding squash! requires both bodies. This keeps message editing aligned with the marker validation used by the no-edit path.

---
 Documentation/git-history.adoc |  18 ++-
 builtin/history.c              |  97 +++++++++++++++-
 t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++
 3 files changed, 305 insertions(+), 7 deletions(-)
Documentation/git-history.adoc +16 -2
index fb04a67685..2e2e31f521 100644
@@ -133,8 +133,9 @@ 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.
 +
-With `--no-edit`, the oldest commit's message is preserved, except that an
-`amend!` commit targeting it replaces its message.
+An editor opens pre-filled with the messages of all the folded commits so you
+can combine them. With `--no-edit`, the oldest commit's message is preserved
+instead, except that an `amend!` commit targeting it replaces its message.
 +
 The selected commits must form a connected graph with a single tip and must
 not include a root commit. Every parent of a commit after the oldest one must
@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,
 the last `amend!` message is used if there is one; a `squash!` or `amend!` is
 otherwise refused if folding it would discard its message.
 +
+The editor 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 local branch descended from a selected commit but not from the range tip
 cannot be rewritten as a descendant of the result, so with the default
 `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
@@ -163,6 +171,12 @@ OPTIONS
 	objects will be written into the repository, so applying these printed
 	ref updates is generally safe.
 
+`--edit`::
+`--no-edit`::
+	For `squash`, open an editor to combine the messages of the folded commits.
+	This is the default; use `--no-edit` to keep the selected message without
+	opening an editor.
+
 `--reedit-message`::
 	Open an editor to modify the target commit's message.
 
builtin/history.c +92 -5

160 lines not on this page · raw message

t/t3455-history-squash.sh +197 -0

203 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#161 lore raw

v14 cover history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

Adds git history squash <revision-range> to fold a range of commits.

Changes in v14:

* Update commit message trailers.
* Simplify rev-parse and tr complexity in the tests.

Changes in v13:

* Split the squash implementation into five patches covering parsing, range validation, branch protection, commit creation, and message editing.
* Print the sorted names of local branches that prevent a squash, with clearer advice for --update-refs=head.
* Isolate --no-edit behavior from the final patch that enables default editor-based message combining.

Changes in v12:

* Incorporated fixups from Phillip:
* Reworks range validation into a single walk that rejects roots and multiple tips while preserving every parent when squashing into a merge.
* Resolves fixup!, squash! and amend! targets directly, preserving message intent and safely consolidating related markers.
* Builds the editor template from the exact selected revisions, including exclusions, while retaining --no-edit behavior.
* Protects descendant local branches while leaving tags and remote-tracking refs unchanged.

Changes in v11:

* Make message editing the default with the autosquash-style template, add --no-edit instead of squash-specific --reedit-message.
* Validate one actual boundary and tip, rejecting root-reaching and multi-tip ranges.
* Protect only interior local branches, leaving tags and remote-tracking refs unchanged.
* Move sequencer preparation before squash and fold the later message-editing patch into the feature commit.

Changes in v10:

* Record the full revision expression in squash reflog.
* Preserve the boundary-walk invariant when sanitizing rev-list options.
* Clarify amend! and --reedit-message documentation.

Changes in v9:

* Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
* Improve tests.

Changes in v8:

* --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
* A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
* Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
* Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
* Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

* --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
* Reuse git rebase -i's squash-message code: a preparatory sequencer:
commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
* Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
* Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
* Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

Changes in v6:

* git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works.
* The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently).
* Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

* The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.
* Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit.

Changes in v4:

* git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected.

Changes in v3:

* Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.
* Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point).
* A merge inside the range is folded fine, only a range with more than one base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just the oldest.

Harald Nordgren (8):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
sequencer: share the squash message marker helpers and flags
history: add skeleton for squash subcommand
history: validate squash revision ranges
history: protect branches when squashing a range
history: create squashed commits without editing
history: support editing squashed commit messages

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  59 +-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 720 +++++++++++++++++++++++--
 object.h                         |   1 +
 sequencer.c                      |  70 +--
 sequencer.h                      |  30 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++
 10 files changed, 1702 insertions(+), 71 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

base-commit: 18e66859d87fb4b76599f73460b54f0848c76b16
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v14
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v14
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v13:

 1:  b175bdca54 = 1:  19c5e311a3 history: extract helper for a commit's parent tree
 2:  965074f9e8 = 2:  aadb7a5df4 history: give commit_tree_ext a message template
 3:  cdbdd766b2 = 3:  e1621f656e sequencer: share the squash message marker helpers and flags
 4:  f7d953b081 ! 4:  016ae0fd42 history: add skeleton for squash subcommand

@@ Commit message
Keep this step limited to defining the revision input contract so graph validation and the rewrite can be added independently.

+ Signed-off-by: Phillip Wood <[email protected]>

5: dbbf66ba02 ! 5: 4992d14ce4 history: validate squash revision ranges
@@ Commit message
parents will be preserved and the single tip whose tree will be used by the rewrite.

+ Signed-off-by: Phillip Wood <[email protected]>

6: e71a8adfbe ! 6: b862a5b4c3 history: protect branches when squashing a range
@@ Commit message
Add advice.historyUpdateRefs for the hint that points to --update-refs=head.

+ Signed-off-by: Phillip Wood <[email protected]>

7: 8b3551d0d4 ! 7: 03528d3b34 history: create squashed commits without editing
@@ Commit message
applicable amend! body supplying the message.

+ Signed-off-by: Phillip Wood <[email protected]>

@@ t/t3455-history-squash.sh: test_expect_success 'errors on a single revision that
+ test_commit WIP &&
+
+ cat >msg <<-EOF &&
-+ amend! $(git rev-parse HEAD^ | tr a-f A-F)
++ amend! $(git rev-parse --short HEAD^)
+
+ The third reword
+
8: 821217ed7c ! 8: 98aa3922cb history: support editing squashed commit messages
@@ Commit message
unless a preceding squash! requires both bodies. This keeps message editing aligned with the marker validation used by the no-edit path.

+ Signed-off-by: Phillip Wood <[email protected]>

Signature · 1 line
gitgitgadget
#162 lore raw

PATCH v14 1/8 history: extract helper for a commit's parent tree

From: Harald Nordgren <[email protected]>

Three places resolve the tree of a commit's first parent, falling back to the empty tree for a root commit, each repeating the same parse and oidcpy dance. Extract a first_parent_tree_oid() helper and route the existing callers through it.

No change in behavior.

---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)
builtin/history.c +26 -32
index 000155ad9c..c22715d848 100644
@@ -164,6 +164,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,21 +190,11 @@ 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));
-		}
-
-		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 commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,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);
 }
@@ -830,16 +831,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);
 
Signature · 1 line
gitgitgadget
#163 lore raw

PATCH v14 2/8 history: give commit_tree_ext a message template

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

commit_tree_ext() reuses the message of the commit it is handed. A caller that folds several commits together wants to seed the message from more than that single commit, so add an optional message_template parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)
builtin/history.c +10 -6
index c22715d848..a60e8dbcd0 100644
@@ -108,6 +108,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	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);
 }
 
@@ -675,7 +679,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) {
@@ -886,7 +890,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"));
@@ -903,7 +907,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"));
Signature · 1 line
gitgitgadget
#164 lore raw

PATCH v14 3/8 sequencer: share the squash message marker helpers and flags

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

When "git rebase -i" squashes commits it builds an editor template with a "This is a combination of N commits." banner, a "This is the 1st/Nth commit message:" header above each kept message (or a "will be skipped" header for a dropped one), and a commented-out subject for any fixup!, squash! or amend! commit. The banner, the headers and the subject-commenting all live in static helpers in sequencer.c wired to the rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(), add_squash_message_header() (which takes a flag for the "will be skipped" variant) and squash_subject_comment_len(), and use them from update_squash_messages() and append_squash_message(). Also move the todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git history squash" the same template.

No change in behavior.

---
 sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
 sequencer.h | 30 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 31 deletions(-)
sequencer.c +39 -31
index 83c3849205..4d2d59ecbe 100644
@@ -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) ||
@@ -2012,20 +2032,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;
+
+	commented_len = squash_subject_comment_len(body,
+				command == TODO_SQUASH || seen_squash(ctx));
 
-	strbuf_addf(buf, "\n%s ", comment_line_str);
-	strbuf_addf(buf, _(nth_commit_msg_fmt),
-		    ++ctx->current_fixup_count + 1);
+	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 */
@@ -2090,9 +2103,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))
@@ -2116,12 +2128,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),
@@ -2140,9 +2149,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);
sequencer.h +30 -0
index 64a9c7fb1b..b01f897020 100644
@@ -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,
Signature · 1 line
gitgitgadget
#165 lore raw

PATCH v14 4/8 history: add skeleton for squash subcommand

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Add the entry point and option parsing for "git history squash". Pass the remaining arguments through setup_revisions() so the command accepts revision ranges and rev-list options, while restoring the ordering and simplification settings required by the fold if an option changes them.

Require at least one BOTTOM revision. The squashed commit needs a commit outside the selected range to serve as its base, so a single positive revision is not a sufficient range.

Keep this step limited to defining the revision input contract so graph validation and the rewrite can be added independently.

---
 Documentation/git-history.adoc |  1 +
 builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+)
Documentation/git-history.adoc +1 -0
index 28b477cd37..b660baf94d 100644
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
 
 DESCRIPTION
 -----------
builtin/history.c +94 -0

118 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#166 lore raw

PATCH v14 5/8 history: validate squash revision ranges

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Walk the selected commits in topological order from oldest to newest and mark each one as it is seen. Every parent after the oldest commit must already be selected or also be a parent of the oldest commit. This accepts merges contained by the range while rejecting a merge arm that entered it from elsewhere.

Track the remaining graph tips during the same walk and require exactly one. Also reject empty and single-commit ranges and any selection that reaches a root commit. These checks identify the oldest commit whose parents will be preserved and the single tip whose tree will be used by the rewrite.

---
 builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----
 object.h                  |   1 +
 t/meson.build             |   1 +
 t/t3455-history-squash.sh |  65 +++++++++++++++++++++
 4 files changed, 174 insertions(+), 12 deletions(-)
 create mode 100755 t/t3455-history-squash.sh
builtin/history.c +107 -12
index 4f5a7a46ce..6541a397e8 100644
@@ -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 '<base>..<tip>' 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;
-
-	ret = error(_("squashing commits is not implemented yet"));
+		return ret;
 
-out:
-	release_revisions(&revs);
-	return ret;
+	return error(_("squashing commits is not implemented yet"));
 }
 
 static int update_worktree(struct repository *repo,
object.h +1 -0
index 8fb03ff90a..dcf30156ca 100644
@@ -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
t/meson.build +1 -0
index 2133c840da..a349bb73b5 100644
@@ -407,6 +407,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',
t/t3455-history-squash.sh new file +65 -0

65 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#167 lore raw

PATCH v14 6/8 history: protect branches when squashing a range

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

A local branch that descends from the selected graph without containing its tip cannot be replayed as a descendant of the squashed commit. Find those branches with ref-filter before creating any replacement objects and refuse the operation unless --update-refs=head was requested.

Limit this protection to local branches, matching the refs that the default history rewrite mode updates; tags and remote-tracking refs remain untouched. Sort the blocking refs and print their short branch names so the user can decide whether to move them or leave them behind.

Add advice.historyUpdateRefs for the hint that points to --update-refs=head.

---
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/history.c                | 70 ++++++++++++++++++++++++++++++--
 t/t3455-history-squash.sh        | 39 ++++++++++++++++++
 5 files changed, 111 insertions(+), 4 deletions(-)
Documentation/config/advice.adoc +4 -0
index 81f80a9274..3d91c90eda 100644
@@ -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 local branch
+		cannot be rewritten as a descendant of the squashed commit, to
+		tell the user about `--update-refs=head`.
 	ignoredHook::
 		Shown when a hook is ignored because the hook is not
 		set as executable.
advice.c +1 -0
index 63bf8b0c5f..401d047391 100644
@@ -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" },
advice.h +1 -0
index 66f6cd6a77..3f0b4f0485 100644
@@ -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,
builtin/history.c +66 -4

135 lines not on this page · raw message

t/t3455-history-squash.sh +39 -0
index df92aa4f6c..b1f65de5f5 100755
@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '
 	test_grep "parent .* of commit .* is outside the revision range" err
 '
 
+test_expect_success 'prints branches that cannot follow the squash' '
+	test_when_finished \
+		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
+		 git branch -D feature" &&
+	git checkout -f -b feature start &&
+	test_commit C1 &&
+	test_commit C2 &&
+	git checkout -b topic-1 start &&
+	test_commit C3 &&
+	test_commit C4 &&
+	git checkout C3 &&
+	test_commit C5 &&
+	git checkout feature &&
+	git merge C5 &&
+	test_commit C6 &&
+	git checkout -b topic-2 C2 &&
+	test_commit C7 &&
+	git checkout feature &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  topic-1$" err &&
+	test_grep "^  topic-2$" err &&
+	test_grep "^hint: .* --update-refs=head" err
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+
+	test_must_fail git -c advice.historyUpdateRefs=false \
+		history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  mid$" err &&
+	test_grep ! "hint:" err &&
+
+	git branch -D mid
+'
+
 test_done
Signature · 1 line
gitgitgadget
#168 lore raw

PATCH v14 7/8 history: create squashed commits without editing

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Create one replacement commit from the resolved range when --no-edit is selected. Preserve the authorship and all parents of the oldest commit, use the tip tree, and replay descendants through the existing history rewrite machinery. Record the complete revision expression in the reflog and retain dry-run and update-refs behavior.

Resolve fixup!, squash! and amend! subjects while walking the range. Reject markers whose targets are not selected and refuse any no-edit fold that would discard a squash! or amend! message. A range made entirely from related markers can still be consolidated, with the last applicable amend! body supplying the message.

---
 Documentation/git-history.adoc |  44 ++-
 builtin/history.c              | 324 ++++++++++++++++-
 object.h                       |   2 +-
 t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--
 4 files changed, 968 insertions(+), 35 deletions(-)
Documentation/git-history.adoc +42 -2
index b660baf94d..fb04a67685 100644
@@ -44,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 merges
+that lie inside the selected range, subject to the restrictions below.
 
 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
@@ -114,6 +117,43 @@ linkgit:gitglossary[7].
 It is invalid to select either all or no hunks, as that would lead to
 one of the commits becoming empty.
 
+`squash <revision-range>`::
+	Fold all commits in _<revision-range>_ into the oldest commit of that
+	range. The resulting commit keeps the oldest commit's authorship and
+	takes the tree of the range's newest commit, so the whole range
+	collapses into a single commit. Commits above the range are replayed
+	on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash 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.
++
+With `--no-edit`, the oldest commit's message is preserved, except that an
+`amend!` commit targeting it replaces its message.
++
+The selected commits must form a connected graph with a single tip and must
+not include a root commit. Every parent of a commit after the oldest one must
+either be selected or also be a parent of the oldest commit. When the oldest
+commit is a merge, all of its parents are preserved in the squashed commit.
++
+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. With `--no-edit`,
+the last `amend!` message is used if there is one; a `squash!` or `amend!` is
+otherwise refused if folding it would discard its message.
++
+A local branch descended from a selected commit but not from the range tip
+cannot be rewritten as a descendant of the result, so with the default
+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
+to rewrite only the current branch and leave such branches unchanged. Tags
+and remote-tracking refs are always left unchanged.
+
 OPTIONS
 -------
 
builtin/history.c +316 -8

372 lines not on this page · raw message

object.h +1 -1
index dcf30156ca..46cade33fb 100644
@@ -74,7 +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
+ * builtin/history.c:                    11---13
  * commit-graph.c:                                15
  * commit-reach.c:                                  16-------20
  * builtin/last-modified.c:                         1617
t/t3455-history-squash.sh +609 -24

680 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#169 lore raw

Re: [PATCH v14 7/8] history: create squashed commits without editing

builtin/history.c out: · line 1197

Quoted 8 lines · Harald Nordgren via GitGitGadget
builtin/history.c +8 -0
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {

Why istarts_with()? "fixup! ABCdef" should not be accepted, should it?

#170 lore raw

Re: [PATCH v14 7/8] history: create squashed commits without editing

Quoted 12 lines · Junio C Hamano

builtin/history.c out: · line 1197

Quoted 8 lines · Harald Nordgren via GitGitGadget
builtin/history.c +8 -0
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {

Why istarts_with()? "fixup! ABCdef" should not be accepted, should it?

I agree there isn't really a compelling case for mixed case oids, but accepting all uppercase, or all lowercase seems reasonable, or are we planning to completely ban uppercase oids as brain has suggested? The aim here is to accept any oid that rebase would, but not accept ref names.

Thanks

Phillip

#171 lore raw

Re: [PATCH v14 7/8] history: create squashed commits without editing

Quoted 14 lines · Phillip Wood
Quoted 12 lines · Junio C Hamano

builtin/history.c out: · line 1197

Quoted 8 lines · Harald Nordgren via GitGitGadget
builtin/history.c +8 -0
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {

Why istarts_with()? "fixup! ABCdef" should not be accepted, should it?

Quoted 2 lines

I agree there isn't really a compelling case for mixed case oids, but accepting all uppercase, or all lowercase seems reasonable, or are we

Tell that to brian who wrote the bc/restrict-hex-to-lowercase topic in <[email protected]>.

Quoted 2 lines

planning to completely ban uppercase oids as brain has suggested? The aim here is to accept any oid that rebase would, but not accept ref names.

I understand that we never emitted hexadecimal containing uppercase letters ourselves, so 'commit --fixup' and friends wouldn't have added anything that requires istarts_with(). So accepting any oid that we would have given the users after "fixup!" but not refnames can be achieved by starts_with() just fine, no?

#172 lore raw

Re: [PATCH v14 7/8] history: create squashed commits without editing

Quoted 21 lines · Junio C Hamano
Quoted 14 lines · Phillip Wood
Quoted 12 lines · Junio C Hamano

builtin/history.c out: · line 1197

Quoted 8 lines · Harald Nordgren via GitGitGadget
builtin/history.c +8 -0
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {

Why istarts_with()? "fixup! ABCdef" should not be accepted, should it?

Quoted 2 lines

I agree there isn't really a compelling case for mixed case oids, but accepting all uppercase, or all lowercase seems reasonable, or are we

Tell that to brian who wrote the bc/restrict-hex-to-lowercase topic in <[email protected]>.

Quoted 8 lines
Quoted 2 lines
Quoted 1 line

planning to completely ban uppercase oids as brain has suggested? The

aim here is to accept any oid that rebase would, but not accept ref names.

I understand that we never emitted hexadecimal containing uppercase letters ourselves, so 'commit --fixup' and friends wouldn't have added anything that requires istarts_with(). So accepting any oid that we would have given the users after "fixup!" but not refnames can be achieved by starts_with() just fine, no?

If they're using "git commit --fixup" then yes. If we're going to reject uppercase hex everywhere then clearly we should be using starts_with() here, but if we keep accepting uppercase hex elsewhere why should we reject it here?

Thanks

Phillip

#173 lore raw

Re: [PATCH v14 7/8] history: create squashed commits without editing

Quoted 31 lines · Phillip Wood
Quoted 21 lines · Junio C Hamano
Quoted 14 lines · Phillip Wood
Quoted 12 lines · Junio C Hamano

builtin/history.c out: · line 1197

Quoted 8 lines · Harald Nordgren via GitGitGadget
builtin/history.c +8 -0
+	/*
+	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
+	 * "fixup! main". If the target is not being squshed check the subject
+	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
+	 * squashed together.
+	 */
+	target = lookup_commit_reference_by_name(s);
+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {

Why istarts_with()? "fixup! ABCdef" should not be accepted, should it?

Quoted 2 lines

I agree there isn't really a compelling case for mixed case oids, but accepting all uppercase, or all lowercase seems reasonable, or are we

Tell that to brian who wrote the bc/restrict-hex-to-lowercase topic in <[email protected]>.

Quoted 8 lines
Quoted 2 lines
Quoted 1 line

planning to completely ban uppercase oids as brain has suggested? The

aim here is to accept any oid that rebase would, but not accept ref names.

I understand that we never emitted hexadecimal containing uppercase letters ourselves, so 'commit --fixup' and friends wouldn't have added anything that requires istarts_with(). So accepting any oid that we would have given the users after "fixup!" but not refnames can be achieved by starts_with() just fine, no?

Quoted 4 lines

If they're using "git commit --fixup" then yes. If we're going to reject uppercase hex everywhere then clearly we should be using starts_with() here, but if we keep accepting uppercase hex elsewhere why should we reject it here?

The point is not to add any *new* places that accept uppercase hex, and this is to participate in the move to discourge the use of uppercase hex.

#174 lore raw

PATCH v14 8/8 history: support editing squashed commit messages

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Open the editor by default when squashing and provide --no-edit as the opt-out. Record the exact commits selected by the revision walk, rearrange that todo list with the sequencer's autosquash machinery, and build the message template from the resulting order.

Match interactive rebase's treatment of marker messages: comment out fixup! messages, retain squash! bodies, and let amend! replace its target unless a preceding squash! requires both bodies. This keeps message editing aligned with the marker validation used by the no-edit path.

---
 Documentation/git-history.adoc |  18 ++-
 builtin/history.c              |  97 +++++++++++++++-
 t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++
 3 files changed, 305 insertions(+), 7 deletions(-)
Documentation/git-history.adoc +16 -2
index fb04a67685..2e2e31f521 100644
@@ -133,8 +133,9 @@ 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.
 +
-With `--no-edit`, the oldest commit's message is preserved, except that an
-`amend!` commit targeting it replaces its message.
+An editor opens pre-filled with the messages of all the folded commits so you
+can combine them. With `--no-edit`, the oldest commit's message is preserved
+instead, except that an `amend!` commit targeting it replaces its message.
 +
 The selected commits must form a connected graph with a single tip and must
 not include a root commit. Every parent of a commit after the oldest one must
@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,
 the last `amend!` message is used if there is one; a `squash!` or `amend!` is
 otherwise refused if folding it would discard its message.
 +
+The editor 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 local branch descended from a selected commit but not from the range tip
 cannot be rewritten as a descendant of the result, so with the default
 `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
@@ -163,6 +171,12 @@ OPTIONS
 	objects will be written into the repository, so applying these printed
 	ref updates is generally safe.
 
+`--edit`::
+`--no-edit`::
+	For `squash`, open an editor to combine the messages of the folded commits.
+	This is the default; use `--no-edit` to keep the selected message without
+	opening an editor.
+
 `--reedit-message`::
 	Open an editor to modify the target commit's message.
 
builtin/history.c +92 -5

160 lines not on this page · raw message

t/t3455-history-squash.sh +197 -0

203 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#175 lore raw

v15 cover history: add squash subcommand to fold a range

In reply to Harald Nordgren via GitGitGadget

Adds git history squash <revision-range> to fold a range of commits.

Changes in v15:

* Fixed istarts_with() → starts_with() in squash_check_autosquash_subject() so fixup! ABCdef (mixed/upper-case hex) is no longer accepted.
* Fixed a typo in the adjacent comment (squshed → squashed).

Changes in v14:

* Update commit message trailers.
* Simplify rev-parse and tr complexity in the tests.

Changes in v13:

* Split the squash implementation into five patches covering parsing, range validation, branch protection, commit creation, and message editing.
* Print the sorted names of local branches that prevent a squash, with clearer advice for --update-refs=head.
* Isolate --no-edit behavior from the final patch that enables default editor-based message combining.

Changes in v12:

* Incorporated fixups from Phillip:
* Reworks range validation into a single walk that rejects roots and multiple tips while preserving every parent when squashing into a merge.
* Resolves fixup!, squash! and amend! targets directly, preserving message intent and safely consolidating related markers.
* Builds the editor template from the exact selected revisions, including exclusions, while retaining --no-edit behavior.
* Protects descendant local branches while leaving tags and remote-tracking refs unchanged.

Changes in v11:

* Make message editing the default with the autosquash-style template, add --no-edit instead of squash-specific --reedit-message.
* Validate one actual boundary and tip, rejecting root-reaching and multi-tip ranges.
* Protect only interior local branches, leaving tags and remote-tracking refs unchanged.
* Move sequencer preparation before squash and fold the later message-editing patch into the feature commit.

Changes in v10:

* Record the full revision expression in squash reflog.
* Preserve the boundary-walk invariant when sanitizing rev-list options.
* Clarify amend! and --reedit-message documentation.

Changes in v9:

* Use the last amend! targeting the oldest folded commit as the default squashed message. Ignore amend! markers targeting later commits while selecting that replacement message.
* Improve tests.

Changes in v8:

* --reedit-message now builds the same editor template as git rebase -i --autosquash: fixup!, squash! and amend! commits are grouped under the commit they target instead of shown in commit order, and an amend! replaces its target's message.
* A fixup!, squash! or amend! is refused only when its target is outside the range, so several fixups for an in-range commit fold together. A range that is entirely markers for one below-range target is combined into a single commit, keeping the last amend! message.
* Merges inside the range are folded when the range has a single base, with no dedicated opt-in flag, --ancestry-path ensures only commits descended from the base are folded, and a range reaching more than one base is rejected.
* Rev-list options are accepted and sanitized the way git replay does, forcing the walk order back with a warning, which also fixes git history squash -- --reverse slipping past the previous option check.
* Kept this as an explicit squash subcommand rather than making --reedit-message the default or renaming the command.

Changes in v7:

* --reedit-message now builds the same editor template git rebase -i shows for a squash (a combination of N commits banner with each folded message under its own header) and follows autosquash for markers: a fixup! message falls out (commented under a will be skipped header), while a squash! or amend! keeps its body with only the marker subject commented so its remark can be reworded in. Only the message text is affected, every commit's changes are always folded in.
* Reuse git rebase -i's squash-message code: a preparatory sequencer:
commit extracts the banner, header and marker-comment helpers so both rebase and git history squash build the identical template from one source.
* Refuse a range whose oldest commit is a fixup!, squash! or amend!, since the marker's target cannot be inside the range.
* Reorder the squash usage so dashed options come before <revision-range>, and spell out HEAD instead of @ in the documentation and examples.
* Expand the squash commit message and documentation with this overview, and scope the merge limitation so it no longer contradicts squash folding a single-base interior merge.

Changes in v6:

* git history squash now accepts multiple revision arguments, read like the arguments to git-rev-list, so a compound range such as @~3.. ^topic works.
* The base to reparent onto is now the oldest in-range commit's parent; a boundary other than that base means the range has more than one base and is rejected. This also fixes the earlier overly-restrictive handling of merges and side branches.
* A single-commit range (e.g. @^!) is rejected with "nothing to squash" (this also covers the @^!-style example that previously succeeded silently).
* Commit messages reworded: the squash commit now gives an overview of fixup!/squash!/amend! handling, rewording, merge-parent and ref behavior.

Changes in v5:

* The range walk now uses --ancestry-path, so only commits descended from the base are folded; a single revision such as HEAD or HEAD~1 is now rejected as "not a <base>..<tip> range" rather than treated as a squash down to the root.
* This adopts the --ancestry-path suggestion; the multi-base rejection is unchanged, so a side branch that forked before the base and merged in is still refused.
* Added tests covering more merge topologies: two interior merges, a nested merge, an octopus merge, an octopus arm forked before the base, a merge among the descendants replayed above the range, and a ref pointing at an interior merge commit.

Changes in v4:

* git history squash now detects when another ref points at a commit inside the range being folded and refuses, with an advice.historyUpdateRefs hint to use --update-refs=head.
* A merge inside the range is folded fine as long as the range has a single base; a range with merge commit at the tip or base also folds correctly. Only a range with more than one base is rejected.

Changes in v3:

* Moved the feature out of git rebase and into a new git history squash <revision-range> subcommand, per the list discussion. git rebase --squash is dropped.
* Takes an arbitrary range (git history squash @~3.., git history squash @~5..@~2), folding it into the oldest commit and replaying any descendants on top.
* Implemented as a single tree operation rather than picking each commit, so there are no repeated conflict stops (addresses Phillip's efficiency point).
* A merge inside the range is folded fine, only a range with more than one base is rejected.
* --reedit-message seeds the editor with every folded-in message, not just the oldest.

Harald Nordgren (8):
history: extract helper for a commit's parent tree
history: give commit_tree_ext a message template
sequencer: share the squash message marker helpers and flags
history: add skeleton for squash subcommand
history: validate squash revision ranges
history: protect branches when squashing a range
history: create squashed commits without editing
history: support editing squashed commit messages

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  59 +-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 720 +++++++++++++++++++++++--
 object.h                         |   1 +
 sequencer.c                      |  70 +--
 sequencer.h                      |  30 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 886 +++++++++++++++++++++++++++++++
 t/t9902-completion.sh            |   1 +
 11 files changed, 1703 insertions(+), 71 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

base-commit: b8242b093d9e941a34460d715e3ce616a34ac3fe
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2337%2FHaraldNordgren%2Frebase-fixup-fold-v15
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2337/HaraldNordgren/rebase-fixup-fold-v15
Pull-Request: https://github.com/git/git/pull/2337

Range-diff vs v14:

 1:  19c5e311a3 = 1:  98a1b756bc history: extract helper for a commit's parent tree
 2:  aadb7a5df4 = 2:  9d99354988 history: give commit_tree_ext a message template
 3:  e1621f656e = 3:  b7a1796838 sequencer: share the squash message marker helpers and flags
 4:  016ae0fd42 ! 4:  8ad1faaa9a history: add skeleton for squash subcommand

@@ builtin/history.c: int cmd_history(int argc,
OPT_END(),
};

+
+ ## t/t9902-completion.sh ##
+@@ t/t9902-completion.sh: test_expect_success 'git history subcommands' '
+ fixup Z
+ reword Z
+ split Z
++ squash Z
+ EOF
+ test_completion "git history --" ""
+ '

 5:  4992d14ce4 = 5:  4389729c83 history: validate squash revision ranges
 6:  b862a5b4c3 = 6:  4d30ff1936 history: protect branches when squashing a range
 7:  03528d3b34 ! 7:  96349a802e history: create squashed commits without editing
     @@ builtin/history.c: out:
      +	}
      +	/*
      +	 * Allow "fixup! <hex object id>", but not "fixup! HEAD^" or
     -+	 * "fixup! main". If the target is not being squshed check the subject
     ++	 * "fixup! main". If the target is not being squashed check the subject
      +	 * to allow "fixup! abc123" and "fixup! <subject of abc123>" to be
      +	 * squashed together.
      +	 */
      +	target = lookup_commit_reference_by_name(s);
     -+	if (target && istarts_with(oid_to_hex(&target->object.oid), s)) {
     ++	if (target && starts_with(oid_to_hex(&target->object.oid), s)) {
      +		unsigned flags =
      +			target->object.flags & (SQUASH_SEEN | SQUASH_AMEND_TARGET);
      +		if (!flags) {

8: 98aa3922cb = 8: 8da0333646 history: support editing squashed commit messages

Signature · 1 line
gitgitgadget
#176 lore raw

PATCH v15 1/8 history: extract helper for a commit's parent tree

From: Harald Nordgren <[email protected]>

Three places resolve the tree of a commit's first parent, falling back to the empty tree for a root commit, each repeating the same parse and oidcpy dance. Extract a first_parent_tree_oid() helper and route the existing callers through it.

No change in behavior.

---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)
builtin/history.c +26 -32
index 000155ad9c..c22715d848 100644
@@ -164,6 +164,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,21 +190,11 @@ 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));
-		}
-
-		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 commit_tree_ext(repo, action, original, original->parents,
 			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
@@ -475,18 +484,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);
 }
@@ -830,16 +831,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);
 
Signature · 1 line
gitgitgadget
#177 lore raw

PATCH v15 2/8 history: give commit_tree_ext a message template

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

commit_tree_ext() reuses the message of the commit it is handed. A caller that folds several commits together wants to seed the message from more than that single commit, so add an optional message_template parameter. When NULL, the behavior is unchanged.

Pass NULL from the existing fixup and split callers.

---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)
builtin/history.c +10 -6
index c22715d848..a60e8dbcd0 100644
@@ -108,6 +108,7 @@ enum commit_tree_flags {
 static int commit_tree_ext(struct repository *repo,
 			   const char *action,
 			   struct commit *commit_with_message,
+			   const char *message_template,
 			   const struct commit_list *parents,
 			   const struct object_id *old_tree,
 			   const struct object_id *new_tree,
@@ -137,13 +138,16 @@ static int commit_tree_ext(struct repository *repo,
 		original_author = xmemdupz(ptr, len);
 	find_commit_subject(original_message, &original_body);
 
+	if (!message_template)
+		message_template = original_body;
+
 	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
 		ret = fill_commit_message(repo, old_tree, new_tree,
-					  original_body, action, &commit_message);
+					  message_template, action, &commit_message);
 		if (ret < 0)
 			goto out;
 	} else {
-		strbuf_addstr(&commit_message, original_body);
+		strbuf_addstr(&commit_message, message_template);
 	}
 
 	original_extra_headers = read_commit_extra_headers(commit_with_message,
@@ -196,7 +200,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	if (first_parent_tree_oid(repo, original, &parent_tree_oid) < 0)
 		return -1;
 
-	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);
 }
 
@@ -675,7 +679,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) {
@@ -886,7 +890,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"));
@@ -903,7 +907,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"));
Signature · 1 line
gitgitgadget
#178 lore raw

PATCH v15 3/8 sequencer: share the squash message marker helpers and flags

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

When "git rebase -i" squashes commits it builds an editor template with a "This is a combination of N commits." banner, a "This is the 1st/Nth commit message:" header above each kept message (or a "will be skipped" header for a dropped one), and a commented-out subject for any fixup!, squash! or amend! commit. The banner, the headers and the subject-commenting all live in static helpers in sequencer.c wired to the rebase state, so no other command can present a squash the same way.

Pull the three pieces out into add_squash_combination_header(), add_squash_message_header() (which takes a flag for the "will be skipped" variant) and squash_subject_comment_len(), and use them from update_squash_messages() and append_squash_message(). Also move the todo_item_flags enum to the header, so a caller reading the output of todo_list_rearrange_squash() can tell an amend! (TODO_REPLACE_FIXUP_MSG) from a plain fixup!. A later change reuses all of this to give "git history squash" the same template.

No change in behavior.

---
 sequencer.c | 70 +++++++++++++++++++++++++++++------------------------
 sequencer.h | 30 +++++++++++++++++++++++
 2 files changed, 69 insertions(+), 31 deletions(-)
sequencer.c +39 -31
index 65afd100d9..557149d62f 100644
@@ -1881,18 +1881,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) ||
@@ -2013,20 +2033,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;
+
+	commented_len = squash_subject_comment_len(body,
+				command == TODO_SQUASH || seen_squash(ctx));
 
-	strbuf_addf(buf, "\n%s ", comment_line_str);
-	strbuf_addf(buf, _(nth_commit_msg_fmt),
-		    ++ctx->current_fixup_count + 1);
+	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 */
@@ -2091,9 +2104,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))
@@ -2117,12 +2129,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),
@@ -2141,9 +2150,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);
sequencer.h +30 -0
index 64a9c7fb1b..b01f897020 100644
@@ -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,
Signature · 1 line
gitgitgadget
#179 lore raw

PATCH v15 4/8 history: add skeleton for squash subcommand

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Add the entry point and option parsing for "git history squash". Pass the remaining arguments through setup_revisions() so the command accepts revision ranges and rev-list options, while restoring the ordering and simplification settings required by the fold if an option changes them.

Require at least one BOTTOM revision. The squashed commit needs a commit outside the selected range to serve as its base, so a single positive revision is not a sufficient range.

Keep this step limited to defining the revision input contract so graph validation and the rewrite can be added independently.

---
 Documentation/git-history.adoc |  1 +
 builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++
 t/t9902-completion.sh          |  1 +
 3 files changed, 96 insertions(+)
Documentation/git-history.adoc +1 -0
index 28b477cd37..b660baf94d 100644
@@ -12,6 +12,7 @@ git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(
 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
+git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
 
 DESCRIPTION
 -----------
builtin/history.c +94 -0

118 lines not on this page · raw message

t/t9902-completion.sh +1 -0
index 38cf221718..978c42c629 100755
@@ -3227,6 +3227,7 @@ test_expect_success 'git history subcommands' '
 	fixup Z
 	reword Z
 	split Z
+	squash Z
 	EOF
 	test_completion "git history --" ""
 '
Signature · 1 line
gitgitgadget
#180 lore raw

Re: [PATCH v15 4/8] history: add skeleton for squash subcommand

Quoted 14 lines · Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Add the entry point and option parsing for "git history squash". Pass the remaining arguments through setup_revisions() so the command accepts revision ranges and rev-list options, while restoring the ordering and simplification settings required by the fold if an option changes them.

Require at least one BOTTOM revision. The squashed commit needs a commit outside the selected range to serve as its base, so a single positive revision is not a sufficient range.

Keep this step limited to defining the revision input contract so graph validation and the rewrite can be added independently.

Quoted 8 lines
---
 Documentation/git-history.adoc |  1 +
 builtin/history.c              | 94 ++++++++++++++++++++++++++++++++++
 t/t9902-completion.sh          |  1 +
 3 files changed, 96 insertions(+)

Nice. As the "complete 'git history'" topic is now in, with this part

t/t9902-completion.sh test_expect_success 'git history subcommands' ' · line 3233

Quoted 12 lines
t/t9902-completion.sh +1 -0
index 38cf221718..978c42c629 100755
@@ -3227,6 +3227,7 @@ test_expect_success 'git history subcommands' '
 	fixup Z
 	reword Z
 	split Z
+	squash Z
 	EOF
 	test_completion "git history --" ""
 '

being a part of this series, I no longer need to worry about making an evil merge for this ;-).

Thanks.

#181 lore raw

PATCH v15 5/8 history: validate squash revision ranges

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Walk the selected commits in topological order from oldest to newest and mark each one as it is seen. Every parent after the oldest commit must already be selected or also be a parent of the oldest commit. This accepts merges contained by the range while rejecting a merge arm that entered it from elsewhere.

Track the remaining graph tips during the same walk and require exactly one. Also reject empty and single-commit ranges and any selection that reaches a root commit. These checks identify the oldest commit whose parents will be preserved and the single tip whose tree will be used by the rewrite.

---
 builtin/history.c         | 119 ++++++++++++++++++++++++++++++++++----
 object.h                  |   1 +
 t/meson.build             |   1 +
 t/t3455-history-squash.sh |  65 +++++++++++++++++++++
 4 files changed, 174 insertions(+), 12 deletions(-)
 create mode 100755 t/t3455-history-squash.sh
builtin/history.c +107 -12
index 4f5a7a46ce..6541a397e8 100644
@@ -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 '<base>..<tip>' 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;
-
-	ret = error(_("squashing commits is not implemented yet"));
+		return ret;
 
-out:
-	release_revisions(&revs);
-	return ret;
+	return error(_("squashing commits is not implemented yet"));
 }
 
 static int update_worktree(struct repository *repo,
object.h +1 -0
index 8fb03ff90a..dcf30156ca 100644
@@ -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
t/meson.build +1 -0
index 7f53cca7d1..ba6f33cdff 100644
@@ -407,6 +407,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',
t/t3455-history-squash.sh new file +65 -0

65 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#182 lore raw

PATCH v15 6/8 history: protect branches when squashing a range

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

A local branch that descends from the selected graph without containing its tip cannot be replayed as a descendant of the squashed commit. Find those branches with ref-filter before creating any replacement objects and refuse the operation unless --update-refs=head was requested.

Limit this protection to local branches, matching the refs that the default history rewrite mode updates; tags and remote-tracking refs remain untouched. Sort the blocking refs and print their short branch names so the user can decide whether to move them or leave them behind.

Add advice.historyUpdateRefs for the hint that points to --update-refs=head.

---
 Documentation/config/advice.adoc |  4 ++
 advice.c                         |  1 +
 advice.h                         |  1 +
 builtin/history.c                | 70 ++++++++++++++++++++++++++++++--
 t/t3455-history-squash.sh        | 39 ++++++++++++++++++
 5 files changed, 111 insertions(+), 4 deletions(-)
Documentation/config/advice.adoc +4 -0
index 81f80a9274..3d91c90eda 100644
@@ -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 local branch
+		cannot be rewritten as a descendant of the squashed commit, to
+		tell the user about `--update-refs=head`.
 	ignoredHook::
 		Shown when a hook is ignored because the hook is not
 		set as executable.
advice.c +1 -0
index 63bf8b0c5f..401d047391 100644
@@ -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" },
advice.h +1 -0
index 66f6cd6a77..3f0b4f0485 100644
@@ -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,
builtin/history.c +66 -4

135 lines not on this page · raw message

t/t3455-history-squash.sh +39 -0
index df92aa4f6c..b1f65de5f5 100755
@@ -62,4 +62,43 @@ test_expect_success 'rejects a merge parent outside the range' '
 	test_grep "parent .* of commit .* is outside the revision range" err
 '
 
+test_expect_success 'prints branches that cannot follow the squash' '
+	test_when_finished \
+		"git switch -f $GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME; \
+		 git branch -D feature" &&
+	git checkout -f -b feature start &&
+	test_commit C1 &&
+	test_commit C2 &&
+	git checkout -b topic-1 start &&
+	test_commit C3 &&
+	test_commit C4 &&
+	git checkout C3 &&
+	test_commit C5 &&
+	git checkout feature &&
+	git merge C5 &&
+	test_commit C6 &&
+	git checkout -b topic-2 C2 &&
+	test_commit C7 &&
+	git checkout feature &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  topic-1$" err &&
+	test_grep "^  topic-2$" err &&
+	test_grep "^hint: .* --update-refs=head" err
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+
+	test_must_fail git -c advice.historyUpdateRefs=false \
+		history squash start.. 2>err &&
+	test_grep "^error: the following branches cannot be rewritten" err &&
+	test_grep "^  mid$" err &&
+	test_grep ! "hint:" err &&
+
+	git branch -D mid
+'
+
 test_done
Signature · 1 line
gitgitgadget
#183 lore raw

PATCH v15 7/8 history: create squashed commits without editing

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Create one replacement commit from the resolved range when --no-edit is selected. Preserve the authorship and all parents of the oldest commit, use the tip tree, and replay descendants through the existing history rewrite machinery. Record the complete revision expression in the reflog and retain dry-run and update-refs behavior.

Resolve fixup!, squash! and amend! subjects while walking the range. Reject markers whose targets are not selected and refuse any no-edit fold that would discard a squash! or amend! message. A range made entirely from related markers can still be consolidated, with the last applicable amend! body supplying the message.

---
 Documentation/git-history.adoc |  44 ++-
 builtin/history.c              | 324 ++++++++++++++++-
 object.h                       |   2 +-
 t/t3455-history-squash.sh      | 633 +++++++++++++++++++++++++++++++--
 4 files changed, 968 insertions(+), 35 deletions(-)
Documentation/git-history.adoc +42 -2
index b660baf94d..fb04a67685 100644
@@ -44,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 merges
+that lie inside the selected range, subject to the restrictions below.
 
 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
@@ -114,6 +117,43 @@ linkgit:gitglossary[7].
 It is invalid to select either all or no hunks, as that would lead to
 one of the commits becoming empty.
 
+`squash <revision-range>`::
+	Fold all commits in _<revision-range>_ into the oldest commit of that
+	range. The resulting commit keeps the oldest commit's authorship and
+	takes the tree of the range's newest commit, so the whole range
+	collapses into a single commit. Commits above the range are replayed
+	on top of the result.
++
+The range is given in the usual `<base>..<tip>` form, where _<base>_ is
+the commit just below the oldest commit to squash. For example, `git
+history squash 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.
++
+With `--no-edit`, the oldest commit's message is preserved, except that an
+`amend!` commit targeting it replaces its message.
++
+The selected commits must form a connected graph with a single tip and must
+not include a root commit. Every parent of a commit after the oldest one must
+either be selected or also be a parent of the oldest commit. When the oldest
+commit is a merge, all of its parents are preserved in the squashed commit.
++
+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. With `--no-edit`,
+the last `amend!` message is used if there is one; a `squash!` or `amend!` is
+otherwise refused if folding it would discard its message.
++
+A local branch descended from a selected commit but not from the range tip
+cannot be rewritten as a descendant of the result, so with the default
+`--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
+to rewrite only the current branch and leave such branches unchanged. Tags
+and remote-tracking refs are always left unchanged.
+
 OPTIONS
 -------
 
builtin/history.c +316 -8

372 lines not on this page · raw message

object.h +1 -1
index dcf30156ca..46cade33fb 100644
@@ -74,7 +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
+ * builtin/history.c:                    11---13
  * commit-graph.c:                                15
  * commit-reach.c:                                  16-------20
  * builtin/last-modified.c:                         1617
t/t3455-history-squash.sh +609 -24

680 lines not on this page · raw message

Signature · 1 line
gitgitgadget
#184 lore raw

PATCH v15 8/8 history: support editing squashed commit messages

In reply to Harald Nordgren via GitGitGadget

From: Harald Nordgren <[email protected]>

Open the editor by default when squashing and provide --no-edit as the opt-out. Record the exact commits selected by the revision walk, rearrange that todo list with the sequencer's autosquash machinery, and build the message template from the resulting order.

Match interactive rebase's treatment of marker messages: comment out fixup! messages, retain squash! bodies, and let amend! replace its target unless a preceding squash! requires both bodies. This keeps message editing aligned with the marker validation used by the no-edit path.

---
 Documentation/git-history.adoc |  18 ++-
 builtin/history.c              |  97 +++++++++++++++-
 t/t3455-history-squash.sh      | 197 +++++++++++++++++++++++++++++++++
 3 files changed, 305 insertions(+), 7 deletions(-)
Documentation/git-history.adoc +16 -2
index fb04a67685..2e2e31f521 100644
@@ -133,8 +133,9 @@ 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.
 +
-With `--no-edit`, the oldest commit's message is preserved, except that an
-`amend!` commit targeting it replaces its message.
+An editor opens pre-filled with the messages of all the folded commits so you
+can combine them. With `--no-edit`, the oldest commit's message is preserved
+instead, except that an `amend!` commit targeting it replaces its message.
 +
 The selected commits must form a connected graph with a single tip and must
 not include a root commit. Every parent of a commit after the oldest one must
@@ -148,6 +149,13 @@ of markers for one target is combined into a single commit. With `--no-edit`,
 the last `amend!` message is used if there is one; a `squash!` or `amend!` is
 otherwise refused if folding it would discard its message.
 +
+The editor 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 local branch descended from a selected commit but not from the range tip
 cannot be rewritten as a descendant of the result, so with the default
 `--update-refs=branches` the command refuses. Rerun with `--update-refs=head`
@@ -163,6 +171,12 @@ OPTIONS
 	objects will be written into the repository, so applying these printed
 	ref updates is generally safe.
 
+`--edit`::
+`--no-edit`::
+	For `squash`, open an editor to combine the messages of the folded commits.
+	This is the default; use `--no-edit` to keep the selected message without
+	opening an editor.
+
 `--reedit-message`::
 	Open an editor to modify the target commit's message.
 
builtin/history.c +92 -5

160 lines not on this page · raw message

t/t3455-history-squash.sh +197 -0

203 lines not on this page · raw message

Signature · 1 line
gitgitgadget