Git development
 help / color / mirror / Atom feed
* Re: [PATCH v6 2/2] config: add "worktree" and "worktree/i" includeIf conditions
From: Chen Linxuan @ 2026-07-06 12:18 UTC (permalink / raw)
  To: Patrick Steinhardt
  Cc: me, git, Kristoffer Haugsbakk, Junio C Hamano, Phillip Wood
In-Reply-To: <akeW4yFC8uuu2o8a@pks.im>

On Fri, Jul 3, 2026 at 7:03 PM Patrick Steinhardt <ps@pks.im> wrote:
>
> On Fri, Jul 03, 2026 at 11:13:18AM +0800, Chen Linxuan via B4 Relay wrote:
> > diff --git a/t/t1305-config-include.sh b/t/t1305-config-include.sh
> > index f3892578e4ff..4e840dfdb35b 100755
> > --- a/t/t1305-config-include.sh
> > +++ b/t/t1305-config-include.sh
> > @@ -396,4 +396,132 @@ test_expect_success 'onbranch without repository but explicit nonexistent Git di
> [snip]
> > +test_expect_success SYMLINKS 'conditional include, worktree resolves symlinks' '
> > +     mkdir real-wt &&
> > +     ln -s real-wt link-wt &&
> > +     git init link-wt/repo &&
> > +     (
> > +             cd link-wt/repo &&
> > +             # repo->worktree resolves symlinks, so use real path in pattern
> > +             echo "[includeIf \"worktree:**/real-wt/repo\"]path=bar-link" >>.git/config &&
> > +             echo "[test]wtlink=2" >.git/bar-link &&
> > +             echo 2 >expect &&
> > +             git config test.wtlink >actual &&
> > +             test_cmp expect actual
> > +     )
> > +'
>
> Okay, this covers one scenario. But with "gitdir:" we're actually able
> to use both the symlinked and the real location:
>
>     test_expect_success SYMLINKS 'conditional include, worktree matching symlink' '
>         mkdir sym-real &&
>         ln -s sym-real sym-link &&
>         git init sym-link/repo &&
>         (
>                 cd sym-link/repo &&
>                 link_path="$(pwd)" &&
>                 real_path="$(test-tool path-utils real_path "$link_path")" &&
>                 cat >>.git/config <<-EOF &&
>                 [includeIf "gitdir:$link_path/.git"]
>                         path = gitdir-link
>                 [includeIf "gitdir:$real_path/.git"]
>                         path = gitdir-real
>                 [includeIf "worktree:$link_path"]
>                         path = worktree-link
>                 [includeIf "worktree:$real_path"]
>                         path = worktree-real
>                 EOF
>                 echo "[test]gitdirlink=1" >.git/gitdir-link &&
>                 echo "[test]gitdirreal=1" >.git/gitdir-real &&
>                 echo "[test]worktreelink=1" >.git/worktree-link &&
>                 echo "[test]worktreereal=1" >.git/worktree-real &&
>
>                 git config get test.gitdirlink &&
>                 git config get test.gitdirreal &&
>                 git config get test.worktreereal &&
>                 test_must_fail git config test.worktreelink
>         )
>     '
>
> The last call to git-config(1) fails, which is inconsistent with how
> resolve the path for "gitdir".
>

I investigated the symlink mismatch.

`gitdir:` works because `opts->git_dir` still preserves the discovered or
user-provided spelling, and `include_by_path()` matches both its realpath
and its absolute non-realpath form.

`worktree:` is different: `repo_get_work_tree()` returns
`repo->worktree`, which is stored by `repo_set_worktree()` via
`real_pathdup(path, 1)`. So the symlink spelling is already lost before
we evaluate includeIf conditions.

Changing `repo->worktree` itself to preserve the original spelling looks
risky, because several users access `repo->worktree` directly, and setup
code appears to rely on it being canonical.

My current possible v7 approach is to keep `repo->worktree` canonical,
but store an additional absolute, normalized, non-realpath worktree path
for `includeIf.worktree`. For the ordinary discovered-repository case,
this has to be derived in `setup_discovered_git_dir()` from physical
`cwd`, the worktree-root offset, and a validated `$PWD`, because
`set_git_work_tree()` is otherwise only called with `"."`.

This makes your suggested test pass, but the plumbing is less trivial
than the original patch. Does this approach sound reasonable, or would
you prefer different semantics for symlinked worktree paths?

Chen Linxuan

> Other than that I didn't have anything to add, thanks!
>
> Patrick
>

^ permalink raw reply

* Re: weird quadratic reftable behavior, was: Re: [PATCH 3/3] t5551: pack refs after creating many tags
From: Kristofer Karlsson @ 2026-07-06 11:37 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: Jeff King, Michael Montalbo, git, Junio C Hamano
In-Reply-To: <aktPP_aRI5Xfo4RA@pks.im>

On Mon, 6 Jul 2026 at 08:46, Patrick Steinhardt <ps@pks.im> wrote:
>
> An easy scenario where you don't have to disable compaction would be
> what Peff posted: you create X references and then delete all of them.
> That shouldn't result in compaction and directly hits the case that we
> care about.

Right, thanks. The recreate-same-refs case works with compaction
enabled and shows the expected improvement (~100x for 8000 refs).

I also found a worse case that feels more realistic: delete 8000
"old-*" refs, then create 8000 "new-*" refs. Since "new" is
lexicographically after "old", every create scans all tombstones.
That one goes from 27s to 0.09s after fixing it.

> If we can demonstrate a significant improvement in the above case then
> it would be worth it, I guess.

I will clean up the patch and submit it shortly.

Thanks,
Kristofer

^ permalink raw reply

* Re: [PATCH 10/11] sequencer: use an enum to represent result of picking a commit
From: Oswald Buddenhagen @ 2026-07-06 11:12 UTC (permalink / raw)
  To: Phillip Wood; +Cc: git, Uwe Kleine-König, Junio C Hamano
In-Reply-To: <e4050ead27f1e01ca72acc849fa16bd67e0d1c4b.1782833268.git.phillip.wood@dunelm.org.uk>

On Tue, Jun 30, 2026 at 04:29:00PM +0100, Phillip Wood wrote:
>Rather than using an integer where -1 is an error, 0 is success and
>1 means there were conflicts use an enum. This is clearer and lets
>us add a separate return value for commits that are dropped because
>they become empty in the next commit.
>
have you attempted widening the scope of the enum? the three conversions 
between the new enum and existing int return values irk me.


^ permalink raw reply

* Re: [PATCH 09/11] sequencer: return early from pick_one_commit() on success
From: Oswald Buddenhagen @ 2026-07-06 11:08 UTC (permalink / raw)
  To: Phillip Wood; +Cc: git, Uwe Kleine-König, Junio C Hamano
In-Reply-To: <2541a4d6e3d41272c31c8fafdf4eadcbc71b63f3.1782833268.git.phillip.wood@dunelm.org.uk>

On Tue, Jun 30, 2026 at 04:28:59PM +0100, Phillip Wood wrote:
>The only block that does not return early is the one guarded by
>"!res". Move the return into that block to make it clear that after
>recording the commit as rewritten all we do is return from the function.
>
i think it would be much more logical to just squash that into the 
parent commit.


^ permalink raw reply

* Re: [PATCH 08/11] sequencer: simplify pick_one_commit()
From: Oswald Buddenhagen @ 2026-07-06 11:06 UTC (permalink / raw)
  To: Phillip Wood; +Cc: git, Uwe Kleine-König, Junio C Hamano
In-Reply-To: <f51751fa3ec1545b7304b869d91d21b055218755.1782833268.git.phillip.wood@dunelm.org.uk>

On Tue, Jun 30, 2026 at 04:28:58PM +0100, Phillip Wood wrote:
>+++ b/sequencer.c
>@@ -4981,14 +4983,13 @@ static int pick_one_commit(struct repository *r,
> 		}
> 		return error_with_patch(r, commit,
> 					arg, item->arg_len, opts, res, !res);
>-	}
>-	if (is_rebase_i(opts) && !res)
>+	} else if (!res) {
>
because of this ...

> 		record_in_rewritten(&item->commit->object.oid,
> 				    peek_command(todo_list, 1));
>-	if (res && is_fixup(item->command)) {
>+	} else if (res && is_fixup(item->command)) {
>
.. the res conditional is pointless here.

> 		return error_failed_squash(r, item->commit, opts,
> 					   item->arg_len, arg);
>-	} else if (res && is_rebase_i(opts)) {
>+	} else if (res) {
>
and here as well.

> 		int to_amend = 0;
> 		struct object_id oid;
> 

^ permalink raw reply

* Re: [PATCH v4 2/2] Makefile: support universal macOS builds via RUST_TARGETS
From: Patrick Steinhardt @ 2026-07-06 10:49 UTC (permalink / raw)
  To: Shardul Natu via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, Shardul Natu, Koji Nakamaru
In-Reply-To: <88fc2e0bd88756a07467bdaf75f6a344d2e58b41.1783188355.git.gitgitgadget@gmail.com>

On Sat, Jul 04, 2026 at 06:05:55PM +0000, Shardul Natu via GitGitGadget wrote:
> From: Shardul Natu <snatu@google.com>
> 
> On macOS, Universal Binaries contain native executable code for
> multiple architectures (such as Intel x86_64 and Apple Silicon arm64)
> bundled into a single file. This is standard practice for macOS
> distribution and CI packaging (such as internal distribution packages
> or tooling like Burrito/Homebrew), allowing a single build artifact
> to run natively across all Macs without Rosetta emulation or
> maintaining separate packages.
> 
> When building Git C code for multiple architectures on macOS, the
> Apple toolchain (clang) natively supports universal builds via
> CFLAGS/LDFLAGS. When "-arch x86_64 -arch arm64" is passed, clang
> automatically compiles and links universal binaries for all C object
> files and executables out of the box.
> 
> Cargo and rustc, however, do not support multiple "-arch" flags or
> emitting universal binaries in a single invocation. Instead, Cargo
> requires invoking each target triple independently (e.g., passing
> "--target x86_64-apple-darwin" and "--target aarch64-apple-darwin").
> 
> To bridge this gap when Rust is enabled:
>   1. Allow specifying space-separated target triples in RUST_TARGETS.
>   2. Introduce declarative pattern rules (target/%/...) to compile
>      each target-specific library slice via Cargo.
>   3. On macOS, if multiple targets are specified, use "lipo" (part of
>      the mandatory Xcode Command Line Tools) to combine the resulting
>      static libraries into target/release/libgitcore.a.
>   4. Ensure target directory creation before invoking lipo via
>      mkdir_p_parent_template.

Nit: The last item really is quite uninteresting in the bigger scheme of
things.

> Once $(RUST_LIB) is compiled into a universal static archive, the
> standard C linker seamlessly links it with the C object files to
> produce universal Git executables.

Okay, this overall reads a lot better now.

> diff --git a/Makefile b/Makefile
> index 7db38ecce9..ecada0acb4 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -500,6 +500,14 @@ include shared.mak
>  #
>  # Building Rust code requires Cargo.
>  #
> +# Define RUST_TARGETS if you want to cross-compile. If left unspecified, it uses
> +# the default rust target on the system.

s/rust/Rust/

> @@ -3022,8 +3031,30 @@ $(LIB_FILE): $(LIB_OBJS)
>  	$(QUIET_AR)$(RM) $@ && $(AR) $(ARFLAGS) $@ $^
>  
>  ifndef NO_RUST
> +ifeq ($(RUST_TARGETS),)
>  $(RUST_LIB): Cargo.toml $(RUST_SOURCES) $(LIB_FILE)
>  	$(QUIET_CARGO)cargo build $(CARGO_ARGS)
> +else
> +ifneq ($(words $(RUST_TARGETS)),1)
> +ifneq ($(uname_S),Darwin)
> +$(error Building universal Rust libraries requires macOS (lipo is not available on $(uname_S)))
> +endif
> +endif
> +
> +RUST_MEMBER_LIBS = $(foreach target,$(RUST_TARGETS),target/$(target)/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME))
> +$(RUST_MEMBER_LIBS): target/%/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME): Cargo.toml $(RUST_SOURCES) $(LIB_FILE)
> +	$(QUIET_CARGO)cargo build $(CARGO_ARGS) --target $*

With this we now have both:

    - target/$ARCH/$BUILD_CONFIG/

    - target/$BUILD_CONFIG/

Is there any reason why we have to have those two different layouts
instead of swapping the order in the first item so that all artifacts
are in "target/$BUILD_CONFIG/"? Essentially, what I'm proposing instead
is:

    - "target/$BUILD_CONFIG/" for the final universal executable.

    - "target/$BUILD_CONFIG/$ARCH" for the per-arch artifacts.

Patrick

^ permalink raw reply

* Re: [PATCH v4 1/2] Makefile: add $(RUST_LIB) prerequisite to osxkeychain
From: Patrick Steinhardt @ 2026-07-06 10:49 UTC (permalink / raw)
  To: Shardul Natu via GitGitGadget
  Cc: git, Kristoffer Haugsbakk, Shardul Natu, Koji Nakamaru
In-Reply-To: <41de7d391ac00c70bfa981d20ed9df22dbdf7ace.1783188355.git.gitgitgadget@gmail.com>

On Sat, Jul 04, 2026 at 06:05:54PM +0000, Shardul Natu via GitGitGadget wrote:
> From: Shardul Natu <snatu@google.com>
> diff --git a/Makefile b/Makefile
> index 1f3f099f5c..7db38ecce9 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -4074,7 +4078,8 @@ $(LIBGIT_HIDDEN_EXPORT): $(LIBGIT_PARTIAL_EXPORT)
>  contrib/libgit-sys/libgitpub.a: $(LIBGIT_HIDDEN_EXPORT)
>  	$(AR) $(ARFLAGS) $@ $^
>  
> -contrib/credential/osxkeychain/git-credential-osxkeychain: contrib/credential/osxkeychain/git-credential-osxkeychain.o $(LIB_FILE) GIT-LDFLAGS
> +# When Rust is enabled, git-credential-osxkeychain depends on Rust symbols in $(RUST_LIB)
> +contrib/credential/osxkeychain/git-credential-osxkeychain: contrib/credential/osxkeychain/git-credential-osxkeychain.o $(LIB_FILE) $(RUST_LIB) GIT-LDFLAGS
>  	$(QUIET_LINK)$(CC) $(ALL_CFLAGS) -o $@ $(ALL_LDFLAGS) \
>  		$(filter %.o,$^) $(LIBS) -framework Security -framework CoreFoundation

I was wondering why no other target declares an explicit dependency on
RUST_LIB. As it turns out, all the other targets that link "$(LIBS)" all
already depend on "$(GITLIBS)", which includes both "$(LIB_FILE)" and
"$(RUST_LIB)". So shouldn't we also depend depend on "$(GITLIBS)" here
instead of on either of the other two variables?

Patrick

^ permalink raw reply

* Re: [PATCH] rebase -i: introduce `pick -x` to add "cherry picked from commit ..."
From: Phillip Wood @ 2026-07-06 10:08 UTC (permalink / raw)
  To: Jeff King, Trevor Gross
  Cc: git, Junio C Hamano, Stefan Haller, Derrick Stolee, Phillip Wood
In-Reply-To: <20260706002415.GC2301945@coredump.intra.peff.net>

On 06/07/2026 01:24, Jeff King wrote:
> On Sun, Jul 05, 2026 at 02:09:06PM +0000, Trevor Gross wrote:
> 
>> It is sometimes useful to do cherry picks via rebases when there is a
>> sequence of picks or other git operations to combine. However, there is
>> no interactive rebase equivalent to the cherry-pick `-x` flag, which
>> adds a line to the commit body indicating the original commit.
>>
>> Using `exec git cherry-pick ... -x` does work, but is not as nice
>> because it interrupts rebase flow; after resolving a conflict, both `git
>> cherry-pick --continue` and `git rebase --continue` must be run.
> 
> To me this feels like you're approaching the problem backwards. Mostly
> because rebase and cherry-pick are _kind of_ the same operation.
> 
> Usually a rebase is about rewriting the commits on a new base so that
> you can throw away the old ones. And that's why git-rebase generally
> rewrites the branch you're on, and replaces those old commits. So adding
> a "cherry-picked from..." annotation doesn't make sense there; nobody
> would have those old commits!

Exactly

> And so while cherry-pick is doing roughly the same thing under the hood,
> it has different defaults: you specify a read-only source from which to
> pick the commits (and "-x" may or may not make sense).
> 
> So I can see why you might use git-rebase to do what is essentially a
> cherry-pick, porting options from cherry-pick to rebase feels weird. Why
> can't we fix the problems in cherry-pick that make you want to use
> rebase instead?

I think that would be a better solution. Trevor - what is missing from 
"git cherry-pick" that means you end up using "git rebase" instead?

> So what I'm wondering specifically: have we done 99% of the work to have
> interactive cherry-pick, and we just need to add a "-i" option to let
> the user edit that todo file before we start executing it?
> 
> To be clear, I don't know the answer. It's been ages since I've looked
> at sequencer code, so there might be more gotchas. That's just my gut
> feeling from a high level after reading your message.

I don't think it would be much work. The code that edits the todo list 
is rebase specific because it deals with rebase.missingCommitsCheck but 
it shouldn't be too difficult to generalize it. I do wonder though if it 
makes sense to support all of the usual commands when cherry-picking 
especially with `-x`. In particular I'm not sure about adding support 
for `edit -x`, or for `pick -x` followed by `fixup` - what does the 
trailer mean when the commit has been edited or fixed up? (though if 
you're back-porting bug fixes I guess some degree of editing is inevitable)

On a slight tangent I've sometimes wanted to be able to do

	git cherry-pick --exec 'make test' some commits

>> To improve this, introduce `-x` to the pick, reword, and edit todo
>> rebase commands.  This uses the same logic as cherry-pick to add a
>> "(cherry picked from commit ...)" note to the commit body.
> 
> There is one thing that differs here from how cherry-pick works. Even
> though cherry-pick is using the sequencer under the hood, it does not
> allow individual "pick -x" commands, but instead records it as an option
> for the whole operation. So if you add "-x" to the conflicting
> cherry-pick above, you can see:
> 
>    $ cat .git/sequencer/opts
>    [options]
> 	record-origin = true
> 
> That's less flexible, since you can't have per-pick "-x" behavior. If
> that's important to you, I think it might be reasonable to support the
> "-x" option for those sequencer commands, and have "cherry-pick -x" just
> add it automatically to each line (rather than record the global
> option).

Yes, if we're adding a per-commit flag to record the origin it would be 
much nicer just to set that flag when we build the todo list rather than 
having to do

	if (opt->record_origin || (item->flags &  TODO_RECORD_ORIGIN))

to see whether we need to add the trailer.

>> Of note is that rebase will fastforward wherever possible, meaning the
>> check for TODO_RECORD_ORIGIN doesn't get hit and the message will not
>> get amended. This differs from the cherry-pick logic, which will add
>> "cherry picked from ..." even if a rewrite isn't otherwise necessary.
> 
> This sounds like another case where cherry-pick and rebase have subtly
> different behaviors, even though the core functionality is still "pick
> these commits". So being able to stick to the cherry-pick command for
> cherry-picking may be preferable.

I think that is a consequence of the way this patch is implemented - it 
adds the new per-commit flag but does not change the conditions for 
preventing a fast-forward in do_pick_commit() or skip_unnecessary_picks().

Thanks

Phillip


^ permalink raw reply

* Re: [PATCH v7 2/3] graph: add a 2 commit buffer for lookahead
From: Chandra Pratap @ 2026-07-06  9:49 UTC (permalink / raw)
  To: Pablo Sabater
  Cc: git, ayu.chandekar, christian.couder, gitster, jltobler,
	karthik.188, krka, peff, phillip.wood, siddharthasthana31
In-Reply-To: <20260704-ps-pre-commit-indent-v7-2-a94706cc8376@gmail.com>

On Sat, 4 Jul 2026 at 14:24, Pablo Sabater <pabloosabaterr@gmail.com> wrote:
>
> In a subsequent commit the graph renderer needs to know if the next
> commit is a visual root or if it is the last commit to be shown. This
> requires peeking 2 commits ahead.
>
> Commits are pre-fetched at get_revision_internal() where they are also
> marked as SHOWN.
>
> Update graph_is_interesting() so it considers commits inside the
> lookahead as interesting as well.

Nit: lookahead -> lookahead buffer.

> Helped-by: Kristofer Karlsson <krka@spotify.com>
> Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
> ---
>  graph.c    | 47 +++++++++++++++++++++++++++++++++++++++++++++++
>  graph.h    | 17 +++++++++++++++++
>  revision.c | 17 ++++++++++++++++-
>  3 files changed, 80 insertions(+), 1 deletion(-)
>
> diff --git a/graph.c b/graph.c
> index 842282685f..300ae67669 100644
> --- a/graph.c
> +++ b/graph.c
> @@ -315,6 +315,14 @@ struct git_graph {
>          * diff_output_prefix_callback().
>          */
>         struct strbuf prefix_buf;
> +
> +       /*
> +        * Lookahead buffer: up to 2 pre-fetched commits that will be shown.
> +        * Populated by get_revision() so graph_peek_next_visible() can use
> +        * actual walk results instead of peeking at rev_info internals.
> +        */
> +       struct commit *lookahead[2];
> +       int lookahead_nr;
>  };
>
>  static inline int graph_needs_truncation(struct git_graph *graph, int lane)
> @@ -388,6 +396,9 @@ struct git_graph *graph_init(struct rev_info *opt)
>         graph->num_columns = 0;
>         graph->num_new_columns = 0;
>         graph->mapping_size = 0;
> +       graph->lookahead[0] = NULL;
> +       graph->lookahead[1] = NULL;

Style: Manually NULLing out each entry doesn't look quite right to me.
Maybe do something like this instead?

memset(graph->lookahead, 0, sizeof(graph->lookahead));

Although for an array of only two elements, manually NULLing is still quite
readable and avoids the minor function-call overhead of memset().

Feel free to ignore this if you want.

> +       graph->lookahead_nr = 0;
>         /*
>          * Start the column color at the maximum value, since we'll
>          * always increment it for the first commit we output.
> @@ -456,6 +467,15 @@ static void graph_ensure_capacity(struct git_graph *graph, int num_columns)
>   */
>  static int graph_is_interesting(struct git_graph *graph, struct commit *commit)
>  {
> +       /*
> +        * Commits in the lookahead buffer have been pre-fetched by
> +        * get_revision() and will be shown in the future. They already
> +        * have the SHOWN flag set by get_revision_internal(), but the
> +        * graph still needs to treat them as interesting parents.
> +        */
> +       for (int i = 0; i < graph->lookahead_nr; i++)
> +               if (graph->lookahead[i] == commit)
> +                       return 1;
>         /*
>          * If revs->boundary is set, commits whose children have
>          * been shown are always interesting, even if they have the
> @@ -763,6 +783,33 @@ static int graph_needs_pre_commit_line(struct git_graph *graph)
>                graph->expansion_row < graph_num_expansion_rows(graph);
>  }
>
> +struct commit *graph_pop_lookahead(struct git_graph *graph)
> +{
> +       struct commit *c;
> +
> +       if (!graph->lookahead_nr)
> +               return NULL;
> +
> +       c = graph->lookahead[0];
> +       graph->lookahead[0] = graph->lookahead[1];
> +       graph->lookahead[1] = NULL;

Do we need to NULL out the retrieved buffer entries? If so, it is
worthwhile asserting that the entire buffer is NULLed out in the
!graph->lookahead_nr check above.

> +       graph->lookahead_nr--;
> +       return c;
> +}

Not the best engineering practice, but I guess it is fine to constrain
the logic to _only_ a 2-entry buffer since that's what we'll always
deal with anyway.

> +
> +int graph_get_lookahead_room(struct git_graph *graph)
> +{
> +       return 2 - graph->lookahead_nr;

We should use ARRAY_SIZE(graph->lookahead) instead of hardcoding
the value 2.
[snip]

^ permalink raw reply

* [PATCH v7 5/5] history: re-edit a squash with every message
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

By default "git history squash" reuses the oldest commit's message.
When --reedit-message is given it only reopened that one message, so the
messages of the folded-in commits were lost.

Gather the messages of every commit in the range, oldest first, and build
the same editor template that "git rebase -i" shows for a squash, using
add_squash_combination_header(), add_squash_message_header() and
squash_subject_comment_len(). Only the message text differs, the changes
are always folded in. Following autosquash, a fixup!'s message is
commented out in full under a "will be skipped" header, while a squash! or
amend! keeps its body with only the marker subject commented.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-history.adoc |  12 +++-
 builtin/history.c              |  73 ++++++++++++++++++++-
 t/t3455-history-squash.sh      | 115 +++++++++++++++++++++++++++++++++
 3 files changed, 196 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 783ddf4a51..f51332e731 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -117,15 +117,21 @@ like the arguments to linkgit:git-rev-list[1], so several arguments may be
 given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
 already on `topic`.
 +
-The oldest commit's message and authorship are preserved by default,
-unless you specify `--reedit-message`. A merge commit inside the range is
+The oldest commit's message and authorship are preserved by default. With
+`--reedit-message`, an editor opens pre-filled with the messages of all the
+folded commits so you can combine them. A merge commit inside the range is
 folded like any other, but the range must have a single base, so a range
 that reaches more than one entry point (for example a side branch that
 forked before the range and was later merged into it) is rejected.
 +
 Because the oldest commit's message is reused, the range may not begin
 with a `fixup!`, `squash!`, or `amend!` commit, whose target is
-necessarily outside the range.
+necessarily outside the range. The changes from every commit in the range
+are always folded in. Only the message text differs. With
+`--reedit-message` the template mirrors `git rebase -i`: the message of a
+`fixup!` elsewhere in the range is commented out in full, while a
+`squash!` or `amend!` keeps its message body with only the marker subject
+commented, so you can fold the remark into the result.
 +
 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
diff --git a/builtin/history.c b/builtin/history.c
index 63911a493d..8999ba9533 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1114,6 +1114,68 @@ static int find_interior_ref(const struct reference *ref, void *cb_data)
 	return 0;
 }
 
+static int build_squash_message(struct repository *repo,
+				struct commit *base,
+				struct commit *tip,
+				struct strbuf *out)
+{
+	struct commit_list *commits = NULL, **tail = &commits, *c;
+	struct rev_info revs;
+	struct commit *commit;
+	struct strvec args = STRVEC_INIT;
+	int n = 0, total, 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)))
+		tail = &commit_list_insert(commit, tail)->next;
+	total = commit_list_count(commits);
+
+	for (c = commits; c; c = c->next) {
+		const char *message, *body;
+		size_t commented_len;
+		int skip;
+
+		message = repo_logmsg_reencode(repo, c->item, NULL, NULL);
+		find_commit_subject(message, &body);
+
+		skip = starts_with(body, "fixup! ");
+		commented_len = skip ? strlen(body) :
+			squash_subject_comment_len(body, 1);
+
+		if (!n)
+			add_squash_combination_header(out, total);
+		strbuf_addch(out, '\n');
+		add_squash_message_header(out, ++n, 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, c->item, message);
+	}
+
+	ret = 0;
+
+out:
+	commit_list_free(commits);
+	reset_revision_walk();
+	release_revisions(&revs);
+	strvec_clear(&args);
+	return ret;
+}
+
 static int cmd_history_squash(int argc,
 			      const char **argv,
 			      const char *prefix,
@@ -1138,6 +1200,7 @@ static int cmd_history_squash(int argc,
 		OPT_END(),
 	};
 	struct strbuf reflog_msg = STRBUF_INIT;
+	struct strbuf message = STRBUF_INIT;
 	struct oidset interior = OIDSET_INIT;
 	struct commit *base, *oldest, *tip, *rewritten;
 	const struct object_id *base_tree_oid, *tip_tree_oid;
@@ -1181,6 +1244,12 @@ static int cmd_history_squash(int argc,
 		}
 	}
 
+	if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+		ret = build_squash_message(repo, base, tip, &message);
+		if (ret < 0)
+			goto out;
+	}
+
 	ret = setup_revwalk(repo, action, tip, &revs);
 	if (ret < 0)
 		goto out;
@@ -1189,7 +1258,8 @@ static int cmd_history_squash(int argc,
 	tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
 	commit_list_append(base, &parents);
 
-	ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+	ret = commit_tree_ext(repo, "squash", oldest,
+			      message.len ? message.buf : NULL, parents,
 			      base_tree_oid, tip_tree_oid, &rewritten, flags);
 	if (ret < 0) {
 		ret = error(_("failed writing squashed commit"));
@@ -1210,6 +1280,7 @@ static int cmd_history_squash(int argc,
 
 out:
 	strbuf_release(&reflog_msg);
+	strbuf_release(&message);
 	oidset_clear(&interior);
 	commit_list_free(parents);
 	release_revisions(&revs);
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
index 6598649971..1985c83fbb 100755
--- a/t/t3455-history-squash.sh
+++ b/t/t3455-history-squash.sh
@@ -186,6 +186,121 @@ test_expect_success 'preserves authorship of the oldest commit' '
 	test_cmp expect actual
 '
 
+test_expect_success '--reedit-message offers every folded-in message' '
+	git reset --hard start &&
+	echo b >file &&
+	git add file &&
+	git commit -m "re-one subject" -m "re-one body line" &&
+	test_commit --no-tag re-two file c &&
+	test_commit re-three file d &&
+
+	write_script editor <<-\EOF &&
+	cat "$1" >edited &&
+	echo combined >"$1"
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	git history squash --reedit-message start.. &&
+
+	cat >expect <<-EOF &&
+	# This is a combination of 3 commits.
+	# This is the 1st commit message:
+
+	re-one subject
+
+	re-one body line
+
+	# This is the commit message #2:
+
+	re-two
+
+	# This is the commit message #3:
+
+	re-three
+
+	# Please enter the commit message for the squash changes. Lines starting
+	# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+	# Changes to be committed:
+	#	modified:   file
+	#
+	EOF
+	test_cmp expect edited &&
+	echo combined >expect &&
+	git log --format="%s" -1 >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
+	git reset --hard start &&
+	test_commit --no-tag mark-base file b &&
+	printf "fixup! mark-base\n\nfixup body\n" >msg &&
+	echo c >file &&
+	git add file &&
+	git commit -qF msg &&
+	printf "squash! mark-base\n\nsquash remark\n" >msg &&
+	echo d >file &&
+	git add file &&
+	git commit -qF msg &&
+	printf "amend! mark-base\n\namended message\n" >msg &&
+	echo e >file &&
+	git add file &&
+	git commit -qF msg &&
+
+	write_script editor <<-\EOF &&
+	cat "$1" >edited
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	git history squash --reedit-message start.. &&
+
+	cat >expect <<-EOF &&
+	# This is a combination of 4 commits.
+	# This is the 1st commit message:
+
+	mark-base
+
+	# The commit message #2 will be skipped:
+
+	# fixup! mark-base
+	#
+	# fixup body
+
+	# This is the commit message #3:
+
+	# squash! mark-base
+
+	squash remark
+
+	# This is the commit message #4:
+
+	# amend! mark-base
+
+	amended message
+
+	# Please enter the commit message for the squash changes. Lines starting
+	# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+	# Changes to be committed:
+	#	modified:   file
+	#
+	EOF
+	test_cmp expect edited &&
+	git log -1 --format="%B" >final &&
+	test_grep ! "fixup body" final &&
+	test_grep "squash remark" final &&
+	test_grep "amended message" final
+'
+
+test_expect_success '--reedit-message aborts on an empty message' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+
+	write_script editor <<-\EOF &&
+	>"$1"
+	EOF
+	test_set_editor "$(pwd)/editor" &&
+	test_must_fail git history squash --reedit-message start.. &&
+
+	test_cmp_rev "$head_before" HEAD
+'
+
 test_expect_success '--update-refs=head only moves HEAD' '
 	git reset --hard three &&
 	git branch -f other HEAD &&
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v7 4/5] sequencer: extract helpers for the squash message markers
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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(). A later change
reuses them to give "git history squash --reedit-message" the same
template.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 sequencer.c | 64 ++++++++++++++++++++++++++++++++---------------------
 sequencer.h | 23 +++++++++++++++++++
 2 files changed, 62 insertions(+), 25 deletions(-)

diff --git a/sequencer.c b/sequencer.c
index 57855b0066..f4893e8f40 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1892,6 +1892,32 @@ static const char skip_first_commit_msg_str[] = N_("The 1st commit message will
 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 +2031,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 +2102,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 +2127,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 +2148,8 @@ static int update_squash_messages(struct repository *r,
 	if (command == TODO_SQUASH || is_fixup_flag(command, flag)) {
 		res = append_squash_message(&buf, body, command, opts, flag);
 	} else if (command == TODO_FIXUP) {
-		strbuf_addf(&buf, "\n%s ", comment_line_str);
-		strbuf_addf(&buf, _(skip_nth_commit_msg_fmt),
-			    ++ctx->current_fixup_count + 1);
+		strbuf_addch(&buf, '\n');
+		add_squash_message_header(&buf, ++ctx->current_fixup_count + 1, 1);
 		strbuf_addstr(&buf, "\n\n");
 		strbuf_add_commented_lines(&buf, body, strlen(body),
 					   comment_line_str);
diff --git a/sequencer.h b/sequencer.h
index 3164bd437d..feed0e9de3 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -208,6 +208,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,
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v7 3/5] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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 the oldest one, keeping that commit's
message and 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, or from an editor with --reedit-message. As that
message is reused, a range whose oldest commit is a fixup!, squash! or
amend! is refused, since the marker's target cannot be in the range.

The range is read like the arguments to "git rev-list", so several
arguments such as "HEAD~3..HEAD ^topic" are allowed. 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.

Inspired-by: Sergey Chernov <serega.morph@gmail.com>
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  43 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 245 +++++++++++++++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 517 +++++++++++++++++++++++++++++++
 7 files changed, 809 insertions(+), 3 deletions(-)
 create mode 100755 t/t3455-history-squash.sh

diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc
index 257db58918..f4d692d136 100644
--- a/Documentation/config/advice.adoc
+++ b/Documentation/config/advice.adoc
@@ -55,6 +55,10 @@ all advice messages.
 	forceDeleteBranch::
 		Shown when the user tries to delete a not fully merged
 		branch without the force option set.
+	historyUpdateRefs::
+		Shown when `git history squash` refuses because a ref points
+		into the range being folded, to tell the user about
+		`--update-refs=head`.
 	ignoredHook::
 		Shown when a hook is ignored because the hook is not
 		set as executable.
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 2ba8121795..783ddf4a51 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
 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>
 
 DESCRIPTION
 -----------
@@ -42,8 +43,11 @@ at once.
 LIMITATIONS
 -----------
 
-This command does not (yet) work with histories that contain merges. You
-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+This command does not (yet) replay merge commits onto the rewritten
+history: if a commit that would be replayed is a merge, the operation is
+rejected, and you should use linkgit:git-rebase[1] with the
+`--rebase-merges` flag instead. The `squash` subcommand can still fold a
+merge that lies inside the range, as long as the range has a single base.
 
 Furthermore, the command does not support operations that can result in merge
 conflicts. This limitation is by design as history rewrites are not intended to
@@ -97,6 +101,38 @@ 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 message and
+	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. _<revision-range>_ is read
+like the arguments to linkgit:git-rev-list[1], so several arguments may be
+given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
+already on `topic`.
++
+The oldest commit's message and authorship are preserved by default,
+unless you specify `--reedit-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 than one entry point (for example a side branch that
+forked before the range and was later merged into it) is rejected.
++
+Because the oldest commit's message is reused, the range may not begin
+with a `fixup!`, `squash!`, or `amend!` commit, whose target is
+necessarily outside the range.
++
+A branch or tag that points at a commit inside the range would be left
+dangling once those commits are folded away, so with the default
+`--update-refs=branches` the command refuses. Rerun with
+`--update-refs=head` to rewrite only the current branch and leave such
+refs pointing at the old commits.
+
 OPTIONS
 -------
 
@@ -107,7 +143,8 @@ OPTIONS
 	ref updates is generally safe.
 
 `--reedit-message`::
-	Open an editor to modify the target commit's message.
+	Open an editor to modify the rewritten commit's message. For `squash`
+	the editor is pre-filled with the messages of all the folded commits.
 
 `--empty=(drop|keep|abort)`::
 	Control what happens when a commit becomes empty as a result of the
diff --git a/advice.c b/advice.c
index 0018501b7b..5c6ff95e31 100644
--- a/advice.c
+++ b/advice.c
@@ -58,6 +58,7 @@ static struct {
 	[ADVICE_FETCH_SHOW_FORCED_UPDATES]		= { "fetchShowForcedUpdates" },
 	[ADVICE_FORCE_DELETE_BRANCH]			= { "forceDeleteBranch" },
 	[ADVICE_GRAFT_FILE_DEPRECATED]			= { "graftFileDeprecated" },
+	[ADVICE_HISTORY_UPDATE_REFS]			= { "historyUpdateRefs" },
 	[ADVICE_IGNORED_HOOK]				= { "ignoredHook" },
 	[ADVICE_IMPLICIT_IDENTITY]			= { "implicitIdentity" },
 	[ADVICE_MERGE_CONFLICT]				= { "mergeConflict" },
diff --git a/advice.h b/advice.h
index 8def280688..911b4e4643 100644
--- a/advice.h
+++ b/advice.h
@@ -25,6 +25,7 @@ enum advice_type {
 	ADVICE_FETCH_SHOW_FORCED_UPDATES,
 	ADVICE_FORCE_DELETE_BRANCH,
 	ADVICE_GRAFT_FILE_DEPRECATED,
+	ADVICE_HISTORY_UPDATE_REFS,
 	ADVICE_IGNORED_HOOK,
 	ADVICE_IMPLICIT_IDENTITY,
 	ADVICE_MERGE_CONFLICT,
diff --git a/builtin/history.c b/builtin/history.c
index 305bde3102..63911a493d 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "advice.h"
 #include "cache-tree.h"
 #include "commit.h"
 #include "commit-reach.h"
@@ -30,6 +31,8 @@
 	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)] [--reedit-message] <revision-range>")
 
 static void change_data_free(void *util, const char *str UNUSED)
 {
@@ -973,6 +976,246 @@ out:
 	return ret;
 }
 
+/*
+ * Resolve a "<base>..<tip>" revision range into the base commit just outside
+ * the range (which becomes the parent of the squashed commit), the oldest
+ * commit contained in the range (whose message the squash reuses), and the
+ * range tip (whose tree becomes the result). A merge inside the range is fine,
+ * but the range must have a single base and must not reach a root commit.
+ */
+static int resolve_squash_range(struct repository *repo,
+				const char **argv,
+				struct commit **base_out,
+				struct commit **oldest_out,
+				struct commit **tip_out,
+				struct oidset *interior_out)
+{
+	struct rev_info revs;
+	struct commit *commit, *base = NULL, *oldest = NULL, *tip = NULL;
+	struct commit_list *boundaries = NULL, *b;
+	struct strvec args = STRVEC_INIT;
+	size_t i;
+	int ret;
+
+	repo_init_revisions(repo, &revs, NULL);
+	strvec_push(&args, "ignored");
+	strvec_push(&args, "--reverse");
+	strvec_push(&args, "--topo-order");
+	strvec_push(&args, "--boundary");
+	strvec_push(&args, "--ancestry-path");
+	strvec_pushv(&args, argv);
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (args.nr != 1) {
+		ret = error(_("unrecognized argument: %s"), args.v[1]);
+		goto out;
+	}
+
+	/*
+	 * 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 & UNINTERESTING)
+			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))) {
+		if (commit->object.flags & BOUNDARY) {
+			commit_list_insert(commit, &boundaries);
+			continue;
+		}
+		if (!oldest)
+			oldest = commit;
+		if (tip)
+			oidset_insert(interior_out, &tip->object.oid);
+		tip = commit;
+	}
+
+	if (!oldest) {
+		ret = error(_("the revision range is empty"));
+		goto out;
+	}
+
+	if (oldest == tip) {
+		ret = error(_("the revision range holds a single commit; "
+			      "nothing to squash"));
+		goto out;
+	}
+
+	if (!oldest->parents)
+		BUG("an in-range commit must have a parent");
+	base = oldest->parents->item;
+
+	/*
+	 * A boundary other than the base is an in-range commit reaching a
+	 * commit outside the range, so the range has more than one base.
+	 */
+	for (b = boundaries; b; b = b->next) {
+		if (b->item != base) {
+			ret = error(_("the revision range has more than one base; "
+				      "cannot squash"));
+			goto out;
+		}
+	}
+
+	*base_out = base;
+	*oldest_out = oldest;
+	*tip_out = tip;
+	ret = 0;
+
+out:
+	commit_list_free(boundaries);
+	reset_revision_walk();
+	release_revisions(&revs);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int reject_fixupish_oldest(struct repository *repo,
+				  struct commit *oldest)
+{
+	const char *message, *subject;
+	int ret = 0;
+
+	message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
+	find_commit_subject(message, &subject);
+	if (starts_with(subject, "fixup! ") ||
+	    starts_with(subject, "squash! ") ||
+	    starts_with(subject, "amend! "))
+		ret = error(_("the range begins with a fixup!, squash! or amend! "
+			      "commit whose target is not in the range"));
+	repo_unuse_commit_buffer(repo, oldest, message);
+	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 int cmd_history_squash(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_SQUASH_USAGE,
+		NULL,
+	};
+	enum ref_action action = REF_ACTION_DEFAULT;
+	enum commit_tree_flags flags = 0;
+	int dry_run = 0;
+	struct option options[] = {
+		OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+			       N_("control which refs should be updated"),
+			       PARSE_OPT_NONEG, parse_ref_action),
+		OPT_BOOL('n', "dry-run", &dry_run,
+			 N_("perform a dry-run without updating any refs")),
+		OPT_BIT(0, "reedit-message", &flags,
+			N_("open an editor to modify the commit message"),
+			COMMIT_TREE_EDIT_MESSAGE),
+		OPT_END(),
+	};
+	struct strbuf reflog_msg = STRBUF_INIT;
+	struct oidset interior = OIDSET_INIT;
+	struct commit *base, *oldest, *tip, *rewritten;
+	const struct object_id *base_tree_oid, *tip_tree_oid;
+	struct commit_list *parents = NULL;
+	struct rev_info revs = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (!argc) {
+		ret = error(_("command expects a revision range"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	if (action == REF_ACTION_DEFAULT)
+		action = REF_ACTION_BRANCHES;
+
+	ret = resolve_squash_range(repo, argv, &base, &oldest, &tip,
+				   &interior);
+	if (ret < 0)
+		goto out;
+
+	ret = reject_fixupish_oldest(repo, oldest);
+	if (ret < 0)
+		goto 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);
+		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;
+		}
+	}
+
+	ret = setup_revwalk(repo, action, tip, &revs);
+	if (ret < 0)
+		goto out;
+
+	base_tree_oid = &repo_get_commit_tree(repo, base)->object.oid;
+	tip_tree_oid = &repo_get_commit_tree(repo, tip)->object.oid;
+	commit_list_append(base, &parents);
+
+	ret = commit_tree_ext(repo, "squash", oldest, NULL, parents,
+			      base_tree_oid, tip_tree_oid, &rewritten, flags);
+	if (ret < 0) {
+		ret = error(_("failed writing squashed commit"));
+		goto out;
+	}
+
+	strbuf_addf(&reflog_msg, "squash: updating %s", argv[0]);
+
+	ret = handle_reference_updates(&revs, action, tip, rewritten,
+				       reflog_msg.buf, dry_run,
+				       REPLAY_EMPTY_COMMIT_ABORT);
+	if (ret < 0) {
+		ret = error(_("failed replaying descendants"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&reflog_msg);
+	oidset_clear(&interior);
+	commit_list_free(parents);
+	release_revisions(&revs);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -982,6 +1225,7 @@ int cmd_history(int argc,
 		GIT_HISTORY_FIXUP_USAGE,
 		GIT_HISTORY_REWORD_USAGE,
 		GIT_HISTORY_SPLIT_USAGE,
+		GIT_HISTORY_SQUASH_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -989,6 +1233,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
 		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
+		OPT_SUBCOMMAND("squash", &fn, cmd_history_squash),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 3219264fe7..63ea26b8ed 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -399,6 +399,7 @@ integration_tests = [
   't3451-history-reword.sh',
   't3452-history-split.sh',
   't3453-history-fixup.sh',
+  't3455-history-squash.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3455-history-squash.sh b/t/t3455-history-squash.sh
new file mode 100755
index 0000000000..6598649971
--- /dev/null
+++ b/t/t3455-history-squash.sh
@@ -0,0 +1,517 @@
+#!/bin/sh
+
+test_description='tests for git-history squash subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'setup linear history touching two files' '
+	test_commit base file a &&
+	git tag start &&
+	test_commit --no-tag one other x &&
+	test_commit --no-tag two file c &&
+	test_commit three file d
+'
+
+test_expect_success 'errors on missing range argument' '
+	test_must_fail git history squash 2>err &&
+	test_grep "expects a revision range" err
+'
+
+test_expect_success 'errors on an empty range' '
+	test_must_fail git history squash HEAD..HEAD 2>err &&
+	test_grep "the revision range is empty" err
+'
+
+test_expect_success 'errors on a single revision that is not a range' '
+	test_must_fail git history squash HEAD 2>err &&
+	test_grep "not a .*range" err &&
+	test_must_fail git history squash HEAD~1 2>err &&
+	test_grep "not a .*range" err
+'
+
+test_expect_success 'errors on a range holding a single commit' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "HEAD^!" 2>err &&
+	test_grep "single commit; nothing to squash" err &&
+	test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'accepts multiple revision arguments with an exclusion' '
+	git reset --hard three &&
+	git branch -f keep HEAD~2 &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start..HEAD ^keep &&
+
+	git log --format="%s" start..HEAD >actual &&
+	cat >expect <<-\EOF &&
+	two
+	one
+	EOF
+	test_cmp expect actual &&
+	test_cmp_rev keep HEAD~1 &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+
+	git branch -D keep
+'
+
+test_expect_success 'squashes a branch the current branch is not on' '
+	git reset --hard three &&
+	main=$(git symbolic-ref --short HEAD) &&
+	head_before=$(git rev-parse HEAD) &&
+	git checkout -b off-history start &&
+	test_commit --no-tag off-one off a &&
+	test_commit --no-tag off-two off b &&
+	git checkout "$main" &&
+
+	git history squash start..off-history &&
+
+	git rev-list --count start..off-history >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D off-history
+'
+
+test_expect_success 'squashes a range into a single commit without changing the tree' '
+	git reset --hard three &&
+	head_before=$(git rev-parse HEAD) &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash --dry-run start.. >out &&
+	predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git history squash start.. &&
+
+	test "$predicted" = "$(git rev-parse HEAD)" &&
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev start HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	git log --format="%s" -1 >subject &&
+	echo one >expect &&
+	test_cmp expect subject &&
+	git reflog >reflog &&
+	test_grep "squash: updating" reflog
+'
+
+test_expect_success 'squashes an interior range and replays descendants verbatim' '
+	git reset --hard three &&
+	final_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start..@~1 &&
+
+	git log --format="%s" start..HEAD >actual &&
+	cat >expect <<-\EOF &&
+	three
+	one
+	EOF
+	test_cmp expect actual &&
+
+	test_cmp_rev start HEAD~2 &&
+	test "$final_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'squashes when the base is the root commit' '
+	git reset --hard three &&
+	root=$(git rev-list --max-parents=0 HEAD) &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash "$root.." &&
+
+	git rev-list --count "$root..HEAD" >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$root" HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+
+test_expect_success 'reuses the message of a fixup! commit in the range' '
+	git reset --hard start &&
+	test_commit --no-tag reg1 file b &&
+	git commit --allow-empty -m "fixup! reg1" &&
+	test_commit reg2 file c &&
+
+	git history squash start.. &&
+
+	git log --format="%s" -1 >actual &&
+	echo reg1 >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'refuses a range whose oldest commit is a fixup!' '
+	git reset --hard start &&
+	test_commit --no-tag "fixup! something" file b &&
+	test_commit --no-tag tail 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 'does not interpret squash! or amend! markers' '
+	git reset --hard start &&
+	test_commit --no-tag marker-oldest file b &&
+	git commit --allow-empty -m "squash! marker-oldest" &&
+	git commit --allow-empty -m "amend! marker-oldest" &&
+	test_commit --no-tag marker-newest file c &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	git log --format="%s" -1 >actual &&
+	echo marker-oldest >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'preserves authorship of the oldest commit' '
+	git reset --hard start &&
+	GIT_AUTHOR_NAME=Squasher GIT_AUTHOR_EMAIL=squash@example.com \
+		test_commit --no-tag oldest file b &&
+	test_commit newest file c &&
+
+	git history squash start.. &&
+
+	git log -1 --format="%an <%ae>" >actual &&
+	echo "Squasher <squash@example.com>" >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--update-refs=head only moves HEAD' '
+	git reset --hard three &&
+	git branch -f other HEAD &&
+	other_before=$(git rev-parse other) &&
+
+	git history squash --update-refs=head start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$other_before" other
+'
+
+test_expect_success 'refuses to fold a range a ref points into' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "error: .* points into the squashed range" err &&
+	test_grep "hint: .*--update-refs=head" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D mid
+'
+
+test_expect_success 'advice.historyUpdateRefs silences the hint' '
+	git reset --hard three &&
+	git branch -f mid HEAD~1 &&
+
+	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 &&
+
+	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 rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$mid_before" mid &&
+
+	git branch -D mid
+'
+
+test_expect_success 'refuses to fold a range a tag points into' '
+	git reset --hard three &&
+	git tag -f mark HEAD~1 &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "refs/tags/mark" err &&
+	test_grep "points into the squashed range" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git tag -d mark
+'
+
+test_expect_success 'squashes a range whose internal merge has a single base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag before-side file b &&
+	git checkout -b inner-side &&
+	test_commit --no-tag on-inner-side inner x &&
+	git checkout "$main" &&
+	test_commit --no-tag after-side file c &&
+	git merge --no-ff -m merge inner-side &&
+	git branch -D inner-side &&
+	test_commit --no-tag after-merge file d &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	git log --format="%s" -1 >subject &&
+	echo before-side >expect &&
+	test_cmp expect subject &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file inner
+'
+
+test_expect_success 'folds a merge of a branch that forked at the base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b base-fork-side &&
+	test_commit --no-tag base-fork-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag base-fork-main file b &&
+	git merge --no-ff -m "merge base-fork-side" base-fork-side &&
+	git branch -D base-fork-side &&
+	test_commit --no-tag base-fork-tail file c &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev start HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file side
+'
+
+test_expect_success 'refuses a merge whose other parent is outside the range' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b outside-parent &&
+	test_commit --no-tag outside-parent outside x &&
+	git checkout "$main" &&
+	test_commit --no-tag outside-main file b &&
+	base=$(git rev-parse HEAD) &&
+	test_commit --no-tag outside-mid file c &&
+	git merge --no-ff -m "merge outside-parent" outside-parent &&
+	git branch -D outside-parent &&
+	merged=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "$base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range whose tip is a merge commit' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag tipmerge-base file b &&
+	git checkout -b tipmerge-side &&
+	test_commit --no-tag tipmerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag tipmerge-main file c &&
+	git merge --no-ff -m "merge tipmerge-side" tipmerge-side &&
+	git branch -D tipmerge-side &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file side
+'
+
+test_expect_success 'folds a range whose base is a merge commit' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b basemerge-side &&
+	test_commit --no-tag basemerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag basemerge-main file b &&
+	git merge --no-ff -m "merge basemerge-side" basemerge-side &&
+	git branch -D basemerge-side &&
+	base=$(git rev-parse HEAD) &&
+	test_commit --no-tag basemerge-one file c &&
+	test_commit --no-tag basemerge-two file d &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash "$base.." &&
+
+	git rev-list --count "$base..HEAD" >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test_cmp_rev "$base" HEAD^ &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
+'
+
+test_expect_success 'refuses to squash a range with more than one base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b forked-before &&
+	test_commit forked-side fside x &&
+	git checkout "$main" &&
+	test_commit forked-base file b &&
+	base=$(git rev-parse HEAD) &&
+	test_commit forked-main file c &&
+	git merge --no-ff -m merge forked-before &&
+	merged=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash "$base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'folds a range 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.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file s1 &&
+	test_path_is_file s2
+'
+
+test_expect_success 'folds a range with a nested merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b nested-outer &&
+	test_commit --no-tag nested-outer outer x &&
+	git checkout -b nested-inner &&
+	test_commit --no-tag nested-inner inner y &&
+	git checkout nested-outer &&
+	git merge --no-ff -m "merge inner" nested-inner &&
+	git checkout "$main" &&
+	test_commit --no-tag nested-main file b1 &&
+	git merge --no-ff -m "merge outer" nested-outer &&
+	git branch -D nested-outer nested-inner &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file outer &&
+	test_path_is_file inner
+'
+
+test_expect_success 'folds a range with an octopus merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag octo-base file a1 &&
+	git checkout -b octo-1 &&
+	test_commit --no-tag octo-1 o1 x &&
+	git checkout "$main" &&
+	git checkout -b octo-2 &&
+	test_commit --no-tag octo-2 o2 y &&
+	git checkout "$main" &&
+	git merge --no-ff -m octopus octo-1 octo-2 &&
+	git branch -D octo-1 octo-2 &&
+	tip_tree=$(git rev-parse HEAD^{tree}) &&
+
+	git history squash start.. &&
+
+	git rev-list --count start..HEAD >count &&
+	echo 1 >expect &&
+	test_cmp expect count &&
+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})" &&
+	test_path_is_file o1 &&
+	test_path_is_file o2
+'
+
+test_expect_success 'refuses an octopus merge with an arm forked before the base' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	git checkout -b octo-pre &&
+	test_commit octo-pre-side pside x &&
+	git checkout "$main" &&
+	test_commit octo-pre-main file b1 &&
+	octo_base=$(git rev-parse HEAD) &&
+	git checkout -b octo-within &&
+	test_commit --no-tag octo-within wside y &&
+	git checkout "$main" &&
+	git merge --no-ff -m octopus octo-pre octo-within &&
+	merged=$(git rev-parse HEAD) &&
+	git branch -D octo-pre octo-within &&
+
+	test_must_fail git history squash "$octo_base.." 2>err &&
+	test_grep "more than one base" err &&
+	test_cmp_rev "$merged" HEAD
+'
+
+test_expect_success 'refuses when a descendant above the range is a merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag desc-one file b &&
+	test_commit --no-tag desc-two file c &&
+	git tag desc-tip &&
+	git checkout -b desc-above &&
+	test_commit --no-tag desc-above above x &&
+	git checkout "$main" &&
+	test_commit --no-tag desc-main file d &&
+	git merge --no-ff -m "merge desc-above" desc-above &&
+	git branch -D desc-above &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start..desc-tip 2>err &&
+	test_grep "merge commits is not supported" err &&
+	test_cmp_rev "$head_before" HEAD
+'
+
+test_expect_success 'refuses to fold a range a ref points into at a merge' '
+	git reset --hard start &&
+	main=$(git symbolic-ref --short HEAD) &&
+	test_commit --no-tag refmerge-base file b &&
+	git checkout -b refmerge-side &&
+	test_commit --no-tag refmerge-side side x &&
+	git checkout "$main" &&
+	test_commit --no-tag refmerge-main file c &&
+	git merge --no-ff -m "interior merge" refmerge-side &&
+	git branch -D refmerge-side &&
+	git branch at-merge HEAD &&
+	test_commit --no-tag refmerge-tail file d &&
+	head_before=$(git rev-parse HEAD) &&
+
+	test_must_fail git history squash start.. 2>err &&
+	test_grep "at-merge" err &&
+	test_grep "points into the squashed range" err &&
+	test_cmp_rev "$head_before" HEAD &&
+
+	git branch -D at-merge
+'
+
+test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v7 2/5] history: give commit_tree_ext a message template
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/history.c | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index f95f26e684..305bde3102 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -101,6 +101,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,
@@ -130,13 +131,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,
@@ -189,7 +193,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);
 }
 
@@ -644,7 +648,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) {
@@ -855,7 +859,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"));
@@ -872,7 +876,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"));
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v7 0/5] history: add squash subcommand to fold a range
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren
In-Reply-To: <pull.2337.v6.git.git.1782635349.gitgitgadget@gmail.com>

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

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 (5):
  history: extract helper for a commit's parent tree
  history: give commit_tree_ext a message template
  history: add squash subcommand to fold a range
  sequencer: extract helpers for the squash message markers
  history: re-edit a squash with every message

 Documentation/config/advice.adoc |   4 +
 Documentation/git-history.adoc   |  49 ++-
 advice.c                         |   1 +
 advice.h                         |   1 +
 builtin/history.c                | 390 +++++++++++++++++--
 sequencer.c                      |  64 ++--
 sequencer.h                      |  23 ++
 t/meson.build                    |   1 +
 t/t3455-history-squash.sh        | 632 +++++++++++++++++++++++++++++++
 9 files changed, 1099 insertions(+), 66 deletions(-)
 create mode 100755 t/t3455-history-squash.sh


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

Range-diff vs v6:

 1:  fea6b79e60 = 1:  56ed8fadbb history: extract helper for a commit's parent tree
 2:  e2674e0bc4 = 2:  212e9c228f history: give commit_tree_ext a message template
 3:  811e393ab4 ! 3:  cf3346a1cd history: add squash subcommand to fold a range
     @@ Commit message
          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
          message and authorship and taking the tree of the newest commit, then
     -    replays the commits above the range on top. fixup!, squash! and amend!
     -    commits are folded like any other and are not interpreted, so the
     -    squashed message comes from the oldest commit, or from an editor with
     -    --reedit-message.
     +    replays the commits above the range on top. The squashed message comes
     +    from the oldest commit, or from an editor with --reedit-message. As that
     +    message is reused, a range whose oldest commit is a fixup!, squash! or
     +    amend! is refused, since the marker's target cannot be in the range.
      
          The range is read like the arguments to "git rev-list", so several
     -    arguments such as "@~3.. ^topic" are allowed. 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.
     +    arguments such as "HEAD~3..HEAD ^topic" are allowed. 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.
      
          Inspired-by: Sergey Chernov <serega.morph@gmail.com>
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
     @@ Documentation/git-history.adoc: SYNOPSIS
       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 <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
     ++git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>
       
       DESCRIPTION
       -----------
     +@@ Documentation/git-history.adoc: at once.
     + LIMITATIONS
     + -----------
     + 
     +-This command does not (yet) work with histories that contain merges. You
     +-should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
     ++This command does not (yet) replay merge commits onto the rewritten
     ++history: if a commit that would be replayed is a merge, the operation is
     ++rejected, and you should use linkgit:git-rebase[1] with the
     ++`--rebase-merges` flag instead. The `squash` subcommand can still fold a
     ++merge that lies inside the range, as long as the range has a single base.
     + 
     + Furthermore, the command does not support operations that can result in merge
     + conflicts. This limitation is by design as history rewrites are not intended to
      @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
       It is invalid to select either all or no hunks, as that would lead to
       one of the commits becoming empty.
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      ++
      +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 @~3..` folds the three most recent commits into one, and
     -+`git history squash @~5..@~2` squashes an interior range while leaving
     -+the two newest commits in place. _<revision-range>_ is read like the
     -+arguments to linkgit:git-rev-list[1], so several arguments may be given,
     -+for example `@~3.. ^topic` to additionally exclude what is already on
     -+`topic`.
     ++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. _<revision-range>_ is read
     ++like the arguments to linkgit:git-rev-list[1], so several arguments may be
     ++given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
     ++already on `topic`.
      ++
      +The oldest commit's message and authorship are preserved by default,
      +unless you specify `--reedit-message`. A merge commit inside the range is
     @@ Documentation/git-history.adoc: linkgit:gitglossary[7].
      +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.
      ++
     -+The folded commits disappear from the history, so with the default
     -+`--update-refs=branches` the command refuses when another ref points at
     -+one of them. Rerun with `--update-refs=head` to rewrite only the current
     -+branch and leave those refs pointing at the old commits.
     ++Because the oldest commit's message is reused, the range may not begin
     ++with a `fixup!`, `squash!`, or `amend!` commit, whose target is
     ++necessarily outside the range.
     +++
     ++A branch or tag that points at a commit inside the range would be left
     ++dangling once those commits are folded away, so with the default
     ++`--update-refs=branches` the command refuses. Rerun with
     ++`--update-refs=head` to rewrite only the current branch and leave such
     ++refs pointing at the old commits.
      +
       OPTIONS
       -------
       
     +@@ Documentation/git-history.adoc: OPTIONS
     + 	ref updates is generally safe.
     + 
     + `--reedit-message`::
     +-	Open an editor to modify the target commit's message.
     ++	Open an editor to modify the rewritten commit's message. For `squash`
     ++	the editor is pre-filled with the messages of all the folded commits.
     + 
     + `--empty=(drop|keep|abort)`::
     + 	Control what happens when a commit becomes empty as a result of the
      
       ## 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 <revision-range> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
     ++	N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--reedit-message] <revision-range>")
       
       static void change_data_free(void *util, const char *str UNUSED)
       {
     @@ builtin/history.c: out:
      +	return ret;
      +}
      +
     ++static int reject_fixupish_oldest(struct repository *repo,
     ++				  struct commit *oldest)
     ++{
     ++	const char *message, *subject;
     ++	int ret = 0;
     ++
     ++	message = repo_logmsg_reencode(repo, oldest, NULL, NULL);
     ++	find_commit_subject(message, &subject);
     ++	if (starts_with(subject, "fixup! ") ||
     ++	    starts_with(subject, "squash! ") ||
     ++	    starts_with(subject, "amend! "))
     ++		ret = error(_("the range begins with a fixup!, squash! or amend! "
     ++			      "commit whose target is not in the range"));
     ++	repo_unuse_commit_buffer(repo, oldest, message);
     ++	return ret;
     ++}
     ++
      +struct interior_ref_cb {
      +	const struct oidset *interior;
      +	const char *name;
     @@ builtin/history.c: out:
      +	if (ret < 0)
      +		goto out;
      +
     ++	ret = reject_fixupish_oldest(repo, oldest);
     ++	if (ret < 0)
     ++		goto out;
     ++
      +	if (action == REF_ACTION_BRANCHES) {
      +		struct interior_ref_cb cb = { .interior = &interior };
      +
     @@ t/t3455-history-squash.sh (new)
      +
      +test_expect_success 'squashes a range into a single commit without changing the tree' '
      +	git reset --hard three &&
     ++	head_before=$(git rev-parse HEAD) &&
      +	tip_tree=$(git rev-parse HEAD^{tree}) &&
      +
     ++	git history squash --dry-run start.. >out &&
     ++	predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
     ++	test_cmp_rev "$head_before" HEAD &&
     ++
      +	git history squash start.. &&
      +
     ++	test "$predicted" = "$(git rev-parse HEAD)" &&
      +	git rev-list --count start..HEAD >count &&
      +	echo 1 >expect &&
      +	test_cmp expect count &&
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success 'keeps the oldest message even if it is a fixup!' '
     ++test_expect_success 'refuses a range whose oldest commit is a fixup!' '
      +	git reset --hard start &&
      +	test_commit --no-tag "fixup! something" file b &&
     -+	test_commit tail file c &&
     ++	test_commit --no-tag tail 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 'does not interpret squash! or amend! markers' '
     ++	git reset --hard start &&
     ++	test_commit --no-tag marker-oldest file b &&
     ++	git commit --allow-empty -m "squash! marker-oldest" &&
     ++	git commit --allow-empty -m "amend! marker-oldest" &&
     ++	test_commit --no-tag marker-newest file c &&
      +
      +	git history squash start.. &&
      +
     ++	git rev-list --count start..HEAD >count &&
     ++	echo 1 >expect &&
     ++	test_cmp expect count &&
      +	git log --format="%s" -1 >actual &&
     -+	echo "fixup! something" >expect &&
     ++	echo marker-oldest >expect &&
      +	test_cmp expect actual
      +'
      +
     @@ t/t3455-history-squash.sh (new)
      +	test_cmp expect actual
      +'
      +
     -+test_expect_success '--dry-run predicts the rewrite without performing it' '
     -+	git reset --hard three &&
     -+	head_before=$(git rev-parse HEAD) &&
     -+	tip_tree=$(git rev-parse HEAD^{tree}) &&
     -+
     -+	git history squash --dry-run start.. >out &&
     -+	predicted=$(awk "/^update refs\/heads\// {print \$3}" out) &&
     -+	test_cmp_rev "$head_before" HEAD &&
     -+
     -+	git history squash start.. &&
     -+	test "$predicted" = "$(git rev-parse HEAD)" &&
     -+	git rev-list --count start..HEAD >count &&
     -+	echo 1 >expect &&
     -+	test_cmp expect count &&
     -+	test_cmp_rev start HEAD^ &&
     -+	test "$tip_tree" = "$(git rev-parse HEAD^{tree})"
     -+'
     -+
      +test_expect_success '--update-refs=head only moves HEAD' '
      +	git reset --hard three &&
      +	git branch -f other HEAD &&
 -:  ---------- > 4:  001356db93 sequencer: extract helpers for the squash message markers
 4:  4edf012b77 ! 5:  615fe4dd3f history: re-edit a squash with every message
     @@ Commit message
          When --reedit-message is given it only reopened that one message, so the
          messages of the folded-in commits were lost.
      
     -    Gather the messages of every commit in the range, oldest first, and use
     -    them as the editor template when re-editing, mirroring how "git rebase
     -    -i" presents a squash.
     +    Gather the messages of every commit in the range, oldest first, and build
     +    the same editor template that "git rebase -i" shows for a squash, using
     +    add_squash_combination_header(), add_squash_message_header() and
     +    squash_subject_comment_len(). Only the message text differs, the changes
     +    are always folded in. Following autosquash, a fixup!'s message is
     +    commented out in full under a "will be skipped" header, while a squash! or
     +    amend! keeps its body with only the marker subject commented.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## Documentation/git-history.adoc ##
     -@@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several arguments may be given,
     - for example `@~3.. ^topic` to additionally exclude what is already on
     - `topic`.
     +@@ Documentation/git-history.adoc: like the arguments to linkgit:git-rev-list[1], so several arguments may be
     + given, for example `HEAD~3..HEAD ^topic` to additionally exclude what is
     + already on `topic`.
       +
      -The oldest commit's message and authorship are preserved by default,
      -unless you specify `--reedit-message`. A merge commit inside the range is
     @@ Documentation/git-history.adoc: arguments to linkgit:git-rev-list[1], so several
       folded like any other, but the range must have a single base, so a range
       that reaches more than one entry point (for example a side branch that
       forked before the range and was later merged into it) is rejected.
     + +
     + Because the oldest commit's message is reused, the range may not begin
     + with a `fixup!`, `squash!`, or `amend!` commit, whose target is
     +-necessarily outside the range.
     ++necessarily outside the range. The changes from every commit in the range
     ++are always folded in. Only the message text differs. With
     ++`--reedit-message` the template mirrors `git rebase -i`: the message of a
     ++`fixup!` elsewhere in the range is commented out in full, while a
     ++`squash!` or `amend!` keeps its message body with only the marker subject
     ++commented, so you can fold the remark into the result.
     + +
     + 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
      
       ## builtin/history.c ##
      @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, void *cb_data)
     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
      +				struct commit *tip,
      +				struct strbuf *out)
      +{
     ++	struct commit_list *commits = NULL, **tail = &commits, *c;
      +	struct rev_info revs;
      +	struct commit *commit;
      +	struct strvec args = STRVEC_INIT;
     -+	int n = 0, ret;
     ++	int n = 0, total, ret;
      +
      +	repo_init_revisions(repo, &revs, NULL);
      +	strvec_push(&args, "ignored");
     @@ builtin/history.c: static int find_interior_ref(const struct reference *ref, voi
      +		goto out;
      +	}
      +
     -+	while ((commit = get_revision(&revs))) {
     ++	while ((commit = get_revision(&revs)))
     ++		tail = &commit_list_insert(commit, tail)->next;
     ++	total = commit_list_count(commits);
     ++
     ++	for (c = commits; c; c = c->next) {
      +		const char *message, *body;
     -+		struct strbuf one = STRBUF_INIT;
     ++		size_t commented_len;
     ++		int skip;
      +
     -+		message = repo_logmsg_reencode(repo, commit, NULL, NULL);
     ++		message = repo_logmsg_reencode(repo, c->item, NULL, NULL);
      +		find_commit_subject(message, &body);
     -+		strbuf_addstr(&one, body);
     -+		strbuf_trim_trailing_newline(&one);
      +
     -+		if (n++)
     -+			strbuf_addch(out, '\n');
     -+		strbuf_addbuf(out, &one);
     ++		skip = starts_with(body, "fixup! ");
     ++		commented_len = skip ? strlen(body) :
     ++			squash_subject_comment_len(body, 1);
     ++
     ++		if (!n)
     ++			add_squash_combination_header(out, total);
      +		strbuf_addch(out, '\n');
     ++		add_squash_message_header(out, ++n, 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);
      +
     -+		strbuf_release(&one);
     -+		repo_unuse_commit_buffer(repo, commit, message);
     ++		repo_unuse_commit_buffer(repo, c->item, message);
      +	}
      +
      +	ret = 0;
      +
      +out:
     ++	commit_list_free(commits);
      +	reset_revision_walk();
      +	release_revisions(&revs);
      +	strvec_clear(&args);
     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
      +	test_commit re-three file d &&
      +
      +	write_script editor <<-\EOF &&
     -+	cp "$1" buffer &&
     ++	cat "$1" >edited &&
      +	echo combined >"$1"
      +	EOF
      +	test_set_editor "$(pwd)/editor" &&
      +	git history squash --reedit-message start.. &&
      +
     -+	test_grep "re-one subject" buffer &&
     -+	test_grep "re-one body line" buffer &&
     -+	test_grep re-two buffer &&
     -+	test_grep re-three buffer &&
     -+	git log --format="%s" -1 >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 &&
      +	echo combined >expect &&
     ++	git log --format="%s" -1 >actual &&
      +	test_cmp expect actual
      +'
      +
     ++test_expect_success '--reedit-message handles fixup!, squash! and amend! like rebase' '
     ++	git reset --hard start &&
     ++	test_commit --no-tag mark-base file b &&
     ++	printf "fixup! mark-base\n\nfixup body\n" >msg &&
     ++	echo c >file &&
     ++	git add file &&
     ++	git commit -qF msg &&
     ++	printf "squash! mark-base\n\nsquash remark\n" >msg &&
     ++	echo d >file &&
     ++	git add file &&
     ++	git commit -qF msg &&
     ++	printf "amend! mark-base\n\namended message\n" >msg &&
     ++	echo e >file &&
     ++	git add file &&
     ++	git commit -qF msg &&
     ++
     ++	write_script editor <<-\EOF &&
     ++	cat "$1" >edited
     ++	EOF
     ++	test_set_editor "$(pwd)/editor" &&
     ++	git history squash --reedit-message start.. &&
     ++
     ++	cat >expect <<-EOF &&
     ++	# This is a combination of 4 commits.
     ++	# This is the 1st commit message:
     ++
     ++	mark-base
     ++
     ++	# The commit message #2 will be skipped:
     ++
     ++	# fixup! mark-base
     ++	#
     ++	# fixup body
     ++
     ++	# This is the commit message #3:
     ++
     ++	# squash! mark-base
     ++
     ++	squash remark
     ++
     ++	# This is the commit message #4:
     ++
     ++	# amend! mark-base
     ++
     ++	amended message
     ++
     ++	# Please enter the commit message for the squash changes. Lines starting
     ++	# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
     ++	# Changes to be committed:
     ++	#	modified:   file
     ++	#
     ++	EOF
     ++	test_cmp expect edited &&
     ++	git log -1 --format="%B" >final &&
     ++	test_grep ! "fixup body" final &&
     ++	test_grep "squash remark" final &&
     ++	test_grep "amended message" final
     ++'
     ++
      +test_expect_success '--reedit-message aborts on an empty message' '
      +	git reset --hard three &&
      +	head_before=$(git rev-parse HEAD) &&
     @@ t/t3455-history-squash.sh: test_expect_success 'preserves authorship of the olde
      +	test_cmp_rev "$head_before" HEAD
      +'
      +
     - test_expect_success '--dry-run predicts the rewrite without performing it' '
     + test_expect_success '--update-refs=head only moves HEAD' '
       	git reset --hard three &&
     - 	head_before=$(git rev-parse HEAD) &&
     + 	git branch -f other HEAD &&

-- 
gitgitgadget

^ permalink raw reply

* [PATCH v7 1/5] history: extract helper for a commit's parent tree
From: Harald Nordgren via GitGitGadget @ 2026-07-06  8:50 UTC (permalink / raw)
  To: git; +Cc: Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2337.v7.git.git.1783327849.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/history.c | 58 +++++++++++++++++++++--------------------------
 1 file changed, 26 insertions(+), 32 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index 091465a59e..f95f26e684 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -157,6 +157,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,
@@ -164,21 +183,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);
@@ -444,18 +453,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);
 }
@@ -799,16 +800,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);
 
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH] blame: reserve mark column only if necessary
From: René Scharfe @ 2026-07-06  8:38 UTC (permalink / raw)
  To: Laszlo Ersek, git
In-Reply-To: <b500479b-14c1-4fbb-a672-1d2cd4852601@posteo.net>

git blame prepends commit hashes of boundary commits with "^", ignored
commits with "?" and unblamable commits with "*" and reserves one column
for them by extending the hash abbreviation, to avoid showing ambiguous
hashes.

This reserved column wastes precious screen space, which can be
especially irritating when using the option -b to blank out boundary
commit hashes and not ignoring any commits.  Reserve it only as needed,
i.e. if any of those cases are actually shown.

Pointed-out-by: Laszlo Ersek <laszlo.ersek@posteo.net>
Signed-off-by: René Scharfe <l.s.r@web.de>
---
 Documentation/git-blame.adoc | 11 +++---
 builtin/blame.c              | 68 ++++++++++++++++++++++++------------
 t/t8002-blame.sh             |  7 ++--
 3 files changed, 53 insertions(+), 33 deletions(-)

diff --git a/Documentation/git-blame.adoc b/Documentation/git-blame.adoc
index 8808009e87e..2b74e455997 100644
--- a/Documentation/git-blame.adoc
+++ b/Documentation/git-blame.adoc
@@ -88,11 +88,12 @@ include::blame-options.adoc[]
 include::diff-algorithm-option.adoc[]
 
 `--abbrev=<n>`::
-	Instead of using the default _7+1_ hexadecimal digits as the
-	abbreviated object name, use _<m>+1_ digits, where _<m>_ is at
-	least _<n>_ but ensures the commit object names are unique.
-	Note that 1 column
-	is used for a caret to mark the boundary commit.
+	Instead of using the default _7_ hexadecimal digits as the
+	abbreviated object name, use at least _<n>_ digits, but ensure
+	the commit object names are unique.
+	If commits marked with caret (boundary), question mark (ignored)
+	or asterisk (unblamable) are shown, extend unmarked object names
+	to align them.
 
 
 THE DEFAULT FORMAT
diff --git a/builtin/blame.c b/builtin/blame.c
index ffbd3ce5c5a..5ae39d0458a 100644
--- a/builtin/blame.c
+++ b/builtin/blame.c
@@ -453,6 +453,36 @@ static void determine_line_heat(struct commit_info *ci, const char **dest_color)
 	*dest_color = colorfield[i].col;
 }
 
+static inline int maybe_putc(int c, FILE *out)
+{
+	return out ? putc(c, out) : 0;
+}
+
+static size_t print_marks(FILE *out, const struct blame_entry *ent, int opt)
+{
+	size_t len = 0;
+
+	if ((ent->suspect->commit->object.flags & UNINTERESTING) &&
+	    !blank_boundary && !(opt & OUTPUT_ANNOTATE_COMPAT)) {
+		maybe_putc('^', out);
+		len++;
+	}
+	if (mark_unblamable_lines && ent->unblamable) {
+		maybe_putc('*', out);
+		len++;
+	}
+	if (mark_ignored_lines && ent->ignored) {
+		maybe_putc('?', out);
+		len++;
+	}
+	return len;
+}
+
+static size_t count_marks(const struct blame_entry *ent, int opt)
+{
+	return print_marks(NULL, ent, opt);
+}
+
 static void emit_other(struct blame_scoreboard *sb, struct blame_entry *ent,
 		       int opt, struct blame_entry *prev_ent)
 {
@@ -499,23 +529,10 @@ static void emit_other(struct blame_scoreboard *sb, struct blame_entry *ent,
 		if (color)
 			fputs(color, stdout);
 
-		if (suspect->commit->object.flags & UNINTERESTING) {
-			if (blank_boundary) {
-				memset(hex, ' ', strlen(hex));
-			} else if (!(opt & OUTPUT_ANNOTATE_COMPAT)) {
-				length--;
-				putchar('^');
-			}
-		}
-
-		if (mark_unblamable_lines && ent->unblamable) {
-			length--;
-			putchar('*');
-		}
-		if (mark_ignored_lines && ent->ignored) {
-			length--;
-			putchar('?');
-		}
+		if ((suspect->commit->object.flags & UNINTERESTING) &&
+		    blank_boundary)
+			memset(hex, ' ', strlen(hex));
+		length -= print_marks(stdout, ent, opt);
 
 		printf("%.*s", (int)(length < GIT_MAX_HEXSZ ? length : GIT_MAX_HEXSZ), hex);
 		if (opt & OUTPUT_ANNOTATE_COMPAT) {
@@ -647,11 +664,15 @@ static void find_alignment(struct blame_scoreboard *sb, int *option)
 	struct blame_entry *e;
 	int compute_auto_abbrev = (abbrev < 0);
 	int auto_abbrev = DEFAULT_ABBREV;
+	size_t max_marks_count = 0;
 
 	for (e = sb->ent; e; e = e->next) {
 		struct blame_origin *suspect = e->suspect;
 		int num;
+		size_t marks_count = count_marks(e, *option);
 
+		if (max_marks_count < marks_count)
+			max_marks_count = marks_count;
 		if (compute_auto_abbrev)
 			auto_abbrev = update_auto_abbrev(auto_abbrev, suspect);
 		if (strcmp(suspect->path, sb->path))
@@ -685,8 +706,12 @@ static void find_alignment(struct blame_scoreboard *sb, int *option)
 	max_score_digits = decimal_width(largest_score);
 
 	if (compute_auto_abbrev)
-		/* one more abbrev length is needed for the boundary commit */
-		abbrev = auto_abbrev + 1;
+		abbrev = auto_abbrev;
+	if (abbrev < (int)the_hash_algo->hexsz) {
+		abbrev += max_marks_count;
+		if (abbrev > (int)the_hash_algo->hexsz)
+			abbrev = the_hash_algo->hexsz;
+	}
 }
 
 static void sanity_check_on_fail(struct blame_scoreboard *sb, int baa)
@@ -1047,10 +1072,7 @@ int cmd_blame(int argc,
 	} else if (show_progress < 0)
 		show_progress = isatty(2);
 
-	if (0 < abbrev && abbrev < (int)the_hash_algo->hexsz)
-		/* one more abbrev length is needed for the boundary commit */
-		abbrev++;
-	else if (!abbrev)
+	if (!abbrev)
 		abbrev = the_hash_algo->hexsz;
 
 	if (revs_file && read_ancestry(revs_file))
diff --git a/t/t8002-blame.sh b/t/t8002-blame.sh
index 7822947f028..bf04b8273ef 100755
--- a/t/t8002-blame.sh
+++ b/t/t8002-blame.sh
@@ -113,8 +113,7 @@ test_expect_success 'set up abbrev tests' '
 '
 
 test_expect_success 'blame --abbrev=<n> works' '
-	# non-boundary commits get +1 for alignment
-	check_abbrev 31 --abbrev=30 HEAD &&
+	check_abbrev 30 --abbrev=30 HEAD &&
 	check_abbrev 30 --abbrev=30 ^HEAD
 '
 
@@ -141,10 +140,8 @@ test_expect_success 'blame --abbrev gets truncated with boundary commit' '
 '
 
 test_expect_success 'blame --abbrev -b truncates the blank boundary' '
-	# Note that `--abbrev=` always gets incremented by 1, which is why we
-	# expect 11 leading spaces and not 10.
 	cat >expect <<-EOF &&
-	$(printf "%11s" "") (<author@example.com> 2005-04-07 15:45:13 -0700 1) abbrev
+	$(printf "%10s" "") (<author@example.com> 2005-04-07 15:45:13 -0700 1) abbrev
 	EOF
 	git blame -b --abbrev=10 ^HEAD -- abbrev.t >actual &&
 	test_cmp expect actual
-- 
2.55.0

^ permalink raw reply related

* Re: [PATCH v3 5/5] builtin/refs: add "rename" subcommand
From: Patrick Steinhardt @ 2026-07-06  7:12 UTC (permalink / raw)
  To: Toon Claes; +Cc: git, Junio C Hamano
In-Reply-To: <87o6go2lgt.fsf@emacs.iotcl.com>

On Fri, Jul 03, 2026 at 04:31:46PM +0200, Toon Claes wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
> > index e6a3528349..ce278c59bf 100644
> > --- a/Documentation/git-refs.adoc
> > +++ b/Documentation/git-refs.adoc
> > @@ -23,6 +23,7 @@ git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude
> >  git refs create [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value>
> >  git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
> >  git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
> > +git refs rename [--message=<reason>] <old-ref> <new-ref>
> 
> So symrefs cannot be renamed with this command?

Indeed, we don't support renaming symbolic references at all. This is a
limitation of our backends, even though it's not necessariliy a sensible
one.

Patrick

^ permalink raw reply

* Re: [PATCH v3 4/5] builtin/refs: add "create" subcommand
From: Patrick Steinhardt @ 2026-07-06  7:12 UTC (permalink / raw)
  To: Toon Claes; +Cc: git, Junio C Hamano
In-Reply-To: <87qzlk2m0h.fsf@emacs.iotcl.com>

On Fri, Jul 03, 2026 at 04:19:58PM +0200, Toon Claes wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
> > index 6475bdcc62..e6a3528349 100644
> > --- a/Documentation/git-refs.adoc
> > +++ b/Documentation/git-refs.adoc
> > @@ -181,6 +184,53 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix,
> >  	return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage);
> >  }
> >  
> > +static int cmd_refs_create(int argc, const char **argv, const char *prefix,
> > +			   struct repository *repo)
> > +{
> > +	static char const * const refs_create_usage[] = {
> > +		REFS_CREATE_USAGE,
> > +		NULL
> > +	};
> > +	const char *message = NULL;
> > +	unsigned flags = 0;
> > +	struct option opts[] = {
> > +		OPT_STRING(0, "message", &message, N_("reason"),
> > +			   N_("reason of the update")),
> > +		OPT_BIT(0 ,"no-deref", &flags,
> > +			N_("update <refname> not the one it points to"),
> > +			REF_NO_DEREF),
> 
> Can `git refs create --no-deref` be used to create symrefs? Should we
> add a test for that? Or can it not
> 
> I understand the symmetry, but does it make sense to ask the user to
> create symrefs with `--no-deref`? Feels a bit obscure. The docs say:
> 
> `--no-deref`::
> 	Operate on <ref> itself rather than the reference it points to via a
> 	symbolic ref.
> 
> That's far from obvious for a user to realize they need to pass that
> option if they want to create a symref.

It doesn't cause them to create a symref. What this flag controls is
whether the command would fail when the refname exists already as a
symbolic ref. That is:

    $ git symbolic-ref refs/heads/symref refs/heads/target
    $ git refs create refs/heads/symref $OID
    $ git refs exists refs/heads/target

The git-refs(1) command would have created "refs/heads/target" in this
case, and by passing "--no-deref" you'd instead make it fail.

This flag is somewhat weird. Having it is probably a sensible think to
do, but now that I think about it I wonder whether the default makes all
that much sense in the first place. That being said, _if_ we want to
change it then we should change it for all subcommands.

> > diff --git a/t/t1466-refs-create.sh b/t/t1466-refs-create.sh
> > new file mode 100755
> > index 0000000000..cfb21bf863
> > --- /dev/null
> > +++ b/t/t1466-refs-create.sh
> > @@ -0,0 +1,151 @@
[snip]
> > +test_expect_success 'create fails when the reference already exists' '
> > +	test_when_finished "rm -rf repo" &&
> > +	setup_repo repo &&
> > +	(
> > +		cd repo &&
> > +		A=$(git rev-parse A) &&
> > +		B=$(git rev-parse B) &&
> > +		git refs create refs/heads/foo $A &&
> > +		test_must_fail git refs create refs/heads/foo $B 2>err &&
> > +		test_grep "reference already exists" err &&
> > +		test_ref_matches refs/heads/foo "$A"
> > +	)
> > +'
> 
> I was curious about this test:
> 
> 	test_expect_success 'create succeed when the reference exists with the same value' '
> 		test_when_finished "rm -rf repo" &&
> 		setup_repo repo &&
> 		(
> 			cd repo &&
> 			A=$(git rev-parse A) &&
> 			git refs create refs/heads/foo $A &&
> 			git refs create refs/heads/foo $A &&
> 			test_ref_matches refs/heads/foo "$A"
> 		)
> 	'
> 
> That fails. It that intentional?

Yes, this is intentional. We didn't end up creating the reference, which
is what the user has asked us to do, and hence we fail.

[snip]
> > +test_expect_success 'create with symref target and --no-deref refuses to create reference' '
> > +	test_when_finished "rm -rf repo" &&
> > +	setup_repo repo &&
> > +	(
> > +		cd repo &&
> > +		A=$(git rev-parse A) &&
> > +		git symbolic-ref refs/heads/symref refs/heads/target &&
> > +		test_must_fail git refs create --no-deref refs/heads/symref $A 2>err &&
> > +		test_grep "dangling symref already exists" err &&
> > +		test_must_fail git reflog exists refs/heads/target
> > +	)
> > +'
> 
> Would it make sense to add this test:
> 
> 	test_expect_success 'create with symref target with --no-deref' '
> 		test_when_finished "rm -rf repo" &&
> 		setup_repo repo &&
> 		(
> 			cd repo &&
> 			A=$(git rev-parse A) &&
> 			git refs create refs/heads/target $A &&
> 			git refs create --no-deref refs/heads/symref refs/heads/target &&
> 			git reflog exists refs/heads/symref && false
> 		)
> 	'
> 
> But that makes me think, this option `--no-deref` is pretty obscure for
> use with `git refs create`. There are two situations:
> 
> * The symref doesn't exists: so --no-deref basically is forcing the
>   command to create a symref. That's confusing

No, it's not. It tells us that we only want to create the reference if
it doesn't exist and is not a symref. Otherwise, we'd potentially create
the reference that the symref is pointing to.

Patrick

^ permalink raw reply

* Re: weird quadratic reftable behavior, was: Re: [PATCH 3/3] t5551: pack refs after creating many tags
From: Patrick Steinhardt @ 2026-07-06  6:46 UTC (permalink / raw)
  To: Kristofer Karlsson; +Cc: Jeff King, Michael Montalbo, git, Junio C Hamano
In-Reply-To: <CAL71e4OavgfXtjN7QxkvmctS3fTpb5MtDsi-iUg=2izZCG5yxg@mail.gmail.com>

On Fri, Jul 03, 2026 at 02:09:45PM +0200, Kristofer Karlsson wrote:
> On Wed, 1 Jul 2026 at 12:07, Patrick Steinhardt <ps@pks.im> wrote:
> > >
> > > I can send a proper patch if needed/wanted, but I might have missed
> > > something silly here.
> >
> > Nice gains. I certainly think it would make sense to polish this a bit
> > and then cast it into a patch.
> >
> > Patrick
> 
> I have a small draft here https://github.com/gitgitgadget/git/pull/2166
> but I am honestly not sure if it's worth submitting as a patch - the
> change is somewhat small, but spread out, and I failed to properly
> reproduce the performance win in any realistic scenario (I had to
> disable compaction to see the improvement).

An easy scenario where you don't have to disable compaction would be
what Peff posted: you create X references and then delete all of them.
That shouldn't result in compaction and directly hits the case that we
care about.

> I would want to rely on your expertise to know if this change
> would be valuable to discuss as a patch at all.

If we can demonstrate a significant improvement in the above case then
it would be worth it, I guess.

Patrick

^ permalink raw reply

* Re: [PATCH] meson: wire up USE_NSEC build knob
From: Patrick Steinhardt @ 2026-07-06  6:43 UTC (permalink / raw)
  To: D. Ben Knoble
  Cc: Jeff King, git, brian m . carlson, Junio C Hamano, Ramsay Jones
In-Reply-To: <CALnO6CDAG4e4A_Qn-3QVe0s4D9xB333Sp0QRntNATwMygNXmQg@mail.gmail.com>

On Fri, Jul 03, 2026 at 11:46:14AM -0400, D. Ben Knoble wrote:
> [with apologies for the delay; I wasn't paying attention to "What's
> cooking" to notice that this was waiting on my response.]
> 
> On Mon, Jun 22, 2026 at 4:13 AM Patrick Steinhardt <ps@pks.im> wrote:
> > On Sun, Jun 21, 2026 at 01:49:34PM -0400, Jeff King wrote:
> > > But that's all outside the scope of your patch here.
> >
> > Kind of, I guess. If we figure that this mechanism is still subtly broken
> > then I'd argue that it doesn't make sense to expose the option via
> > Meson.
> 
> This bit addressed more down-thread, so I'll reply there.
> 
> To summarize: If we're all leaning in the direction of a run-time flag
> instead, I can noodle in that direction. That certainly involves a bit
> more surgery than just giving Meson access to the option, but the
> dynamism may be nice. I'm not too sure how we'd write a test case for
> it, though.

I don't think we'd necessarily need a way to detect this. Our current
build default is to have this disabled, so I'd keep it this way, but
automatically compile nsec-support into Git if available. And then we
provide a way for users to opt-in to the new behaviour via the config.

An automated test would of course be nice to have so that we know to
enable this in cases where we can determine that it works. But with the
above we'd already make the feature more accessible than it currently
is, because I'd expect that most distros simply don't enable the build
toggle at all.

Patrick

^ permalink raw reply

* [PATCH v3 9/9] gitlab-ci: enable "GIT_TEST_LONG"
From: Patrick Steinhardt @ 2026-07-06  6:24 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Johannes Schindelin, SZEDER Gábor, Jeff King
In-Reply-To: <20260706-b4-pks-t-fixes-for-GIT-TEST-LONG-v3-0-4f6c5a37fd1f@pks.im>

Starting with 7a094d68a2 (ci: run expensive tests on push builds to
integration branches, 2026-05-08) we run expensive tests in our CI for
certain events. So far, this has only been wired up for GitHub Workflows
though, which creates a test gap for GitLab CI.

Plug this gap by also making this work for the latter.

Note that these tests cannot be run on the Windows runners, as they only
have 7.5GB of RAM. This is insufficient for some of the EXPENSIVE tests,
so we explicitly disable "GIT_TEST_LONG" on these jobs.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitlab-ci.yml |  6 ++++++
 ci/lib.sh      | 12 ++++++++++--
 2 files changed, 16 insertions(+), 2 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a4aebe8b71..1c4d04da9d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -147,6 +147,9 @@ test:mingw64:
   needs:
     - job: "build:mingw64"
       artifacts: true
+  variables:
+    # Windows runners don't have enough RAM to run EXPENSIVE tests.
+    GIT_TEST_LONG: false
   before_script:
     - *windows_before_script
     - git-sdk/usr/bin/bash.exe -l -c 'tar xf artifacts/artifacts.tar.gz'
@@ -195,6 +198,9 @@ test:msvc-meson:
   script:
     - |
       & "C:/Program Files/Git/usr/bin/bash.exe" -l -c 'ci/run-test-slice-meson.sh build $CI_NODE_INDEX $CI_NODE_TOTAL'
+  variables:
+    # Windows runners don't have enough RAM to run EXPENSIVE tests.
+    GIT_TEST_LONG: false
   after_script:
     - |
       if ($env:CI_JOB_STATUS -ne "success") {
diff --git a/ci/lib.sh b/ci/lib.sh
index 01a0bc6b75..6c52154eac 100755
--- a/ci/lib.sh
+++ b/ci/lib.sh
@@ -215,6 +215,7 @@ then
 	test macos != "$CI_OS_NAME" || CI_OS_NAME=osx
 	CI_REPO_SLUG="$GITHUB_REPOSITORY"
 	CI_JOB_ID="$GITHUB_RUN_ID"
+	CI_EVENT="$GITHUB_EVENT_NAME"
 	CC="${CC_PACKAGE:-${CC:-gcc}}"
 	DONT_SKIP_TAGS=t
 	handle_failed_tests () {
@@ -239,6 +240,13 @@ then
 	CI_BRANCH="$CI_COMMIT_REF_NAME"
 	CI_COMMIT="$CI_COMMIT_SHA"
 
+	case "$CI_PIPELINE_SOURCE" in
+	merge_request_event)
+		CI_EVENT=pull_request;;
+	*)
+		CI_EVENT="$CI_PIPELINE_SOURCE";;
+	esac
+
 	case "$OS,$CI_JOB_IMAGE" in
 	Windows_NT,*)
 		CI_OS_NAME=windows
@@ -319,9 +327,9 @@ export SKIP_DASHED_BUILT_INS=YesPlease
 # enable "expensive" tests for PR events.
 # In order to catch bugs introduced at integration time by mismerges,
 # enable the long tests for pushes to the integration branches as well.
-case "$GITHUB_EVENT_NAME,$CI_BRANCH" in
+case "$CI_EVENT,$CI_BRANCH" in
 pull_request,*|push,*next*|push,*master*|push,*main*|push,*maint*)
-	export GIT_TEST_LONG=true
+	export GIT_TEST_LONG=${GIT_TEST_LONG:-true}
 	;;
 esac
 

-- 
2.55.0.795.g602f6c329a.dirty


^ permalink raw reply related

* [PATCH v3 8/9] gitlab-ci: disable RAM disk on macOS jobs
From: Patrick Steinhardt @ 2026-07-06  6:24 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Johannes Schindelin, SZEDER Gábor, Jeff King
In-Reply-To: <20260706-b4-pks-t-fixes-for-GIT-TEST-LONG-v3-0-4f6c5a37fd1f@pks.im>

When we added the macOS jobs to GitLab CI in 56090a35ab (ci: add macOS
jobs to GitLab CI, 2024-01-18) we had to work around some very slow
disks. This workaround essentially creates a RAM disk that we mount,
where all test data is being written into RAM instead of the real disk.

In the next commit though we're about to enable "GIT_TEST_LONG", which
will make tests run that are marked with the "EXPENSIVE" prerequisite.
This change will make a couple of tests run that write up to 8GB of data
into the test output directory. As our RAM disk is only 4GB in size,
this change will cause ENOSPC errors.

We could accommodate for this by increasing the size of the RAM disk.
In c9d708b7fc (gitlab-ci: upgrade macOS runners, 2026-05-21) we have
upgraded our runners to use the "large" runners, which have 16GB of RAM
available. So we could easily expand the RAM disk to a capacity of for
example 12GB. But some test runs have shown that this is still quite
flaky overall, as we get quite close to our limits.

Instead, drop the workaround completely. This does indeed slow down
execution of the test jobs:

  - osx-clang goes from 18 minutes to 25 minutes

  - osx-meson goes from 21 minutes to 33 minutes

  - osx-reftable stays at 21 minutes

The last one seems like an outlier. The only explanation that I have is
that we end up writing significantly less files with the reftable
backend, which ultimately causes less I/O.

Overall though, it's preferable to have something that works with the
least amount of flakiness compared to having something else that is
faster but unstable. Despite that, the macOS jobs aren't even the
slowest jobs, so this doesn't extend the overall pipeline's length.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitlab-ci.yml | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1a8e90932c..a4aebe8b71 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -88,13 +88,8 @@ test:osx:
   tags:
     - saas-macos-large-m2pro
   variables:
-    TEST_OUTPUT_DIRECTORY: "/Volumes/RAMDisk"
+    TEST_OUTPUT_DIRECTORY: "/tmp/test-output"
   before_script:
-    # Create a 4GB RAM disk that we use to store test output on. This small hack
-    # significantly speeds up tests by more than a factor of 2 because the
-    # macOS runners use network-attached storage as disks, which is _really_
-    # slow with the many small writes that our tests do.
-    - sudo diskutil apfs create $(hdiutil attach -nomount ram://8192000) RAMDisk
     - ./ci/install-dependencies.sh
   script:
     - ./ci/run-build-and-tests.sh

-- 
2.55.0.795.g602f6c329a.dirty


^ permalink raw reply related

* [PATCH v3 7/9] t: use `test_bool_env` to parse GIT_TEST_LONG
From: Patrick Steinhardt @ 2026-07-06  6:24 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Johannes Schindelin, SZEDER Gábor, Jeff King
In-Reply-To: <20260706-b4-pks-t-fixes-for-GIT-TEST-LONG-v3-0-4f6c5a37fd1f@pks.im>

It's currently hard to explicitly disable GIT_TEST_LONG by setting it to
`false`. Fix this by using `test_bool_env` instead.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 ci/lib.sh     | 2 +-
 t/test-lib.sh | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/ci/lib.sh b/ci/lib.sh
index b939110a6e..01a0bc6b75 100755
--- a/ci/lib.sh
+++ b/ci/lib.sh
@@ -321,7 +321,7 @@ export SKIP_DASHED_BUILT_INS=YesPlease
 # enable the long tests for pushes to the integration branches as well.
 case "$GITHUB_EVENT_NAME,$CI_BRANCH" in
 pull_request,*|push,*next*|push,*master*|push,*main*|push,*maint*)
-	export GIT_TEST_LONG=YesPlease
+	export GIT_TEST_LONG=true
 	;;
 esac
 
diff --git a/t/test-lib.sh b/t/test-lib.sh
index ceefb99bff..623fcfb747 100644
--- a/t/test-lib.sh
+++ b/t/test-lib.sh
@@ -210,7 +210,7 @@ parse_option () {
 	-i|--i|--im|--imm|--imme|--immed|--immedi|--immedia|--immediat|--immediate)
 		immediate=t ;;
 	-l|--l|--lo|--lon|--long|--long-|--long-t|--long-te|--long-tes|--long-test|--long-tests)
-		GIT_TEST_LONG=t; export GIT_TEST_LONG ;;
+		GIT_TEST_LONG=true; export GIT_TEST_LONG ;;
 	-r)
 		mark_option_requires_arg "$opt" run_list
 		;;
@@ -1849,7 +1849,7 @@ test_lazy_prereq AUTOIDENT '
 '
 
 test_lazy_prereq EXPENSIVE '
-	test -n "$GIT_TEST_LONG"
+	test_bool_env GIT_TEST_LONG false
 '
 
 test_lazy_prereq EXPENSIVE_ON_WINDOWS '

-- 
2.55.0.795.g602f6c329a.dirty


^ permalink raw reply related

* [PATCH v3 6/9] t7900: clean up large EXPENSIVE repository
From: Patrick Steinhardt @ 2026-07-06  6:24 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Johannes Schindelin, SZEDER Gábor, Jeff King
In-Reply-To: <20260706-b4-pks-t-fixes-for-GIT-TEST-LONG-v3-0-4f6c5a37fd1f@pks.im>

One of the tests in t7900 is marked with EXPENSIVE because we create a
repository with 2GB of data that we end up repacking. We never clean up
that repository though, so we occupy the full 2GB of data until the end
of the test suite.

Besides clogging our disk, having an EXPENSIVE test that alters the
repository's state used by subsequent tests is also a bad idea, as it
can easily have an impact on the heuristics used by other maintenance
tasks.

Adapt the test so that we create the data in a standalone repository
that we clean up at the end of the test. While at it, also disable
auto-maintenance so that it does not race with our manual maintenance.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 t/t7900-maintenance.sh | 56 ++++++++++++++++++++++++++++----------------------
 1 file changed, 31 insertions(+), 25 deletions(-)

diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh
index d7f82e1bec..8a7e1306d0 100755
--- a/t/t7900-maintenance.sh
+++ b/t/t7900-maintenance.sh
@@ -461,36 +461,42 @@ test_expect_success 'incremental-repack task' '
 '
 
 test_expect_success EXPENSIVE 'incremental-repack 2g limit' '
-	test_config core.compression 0 &&
+	test_when_finished rm -rf expensive-repo &&
+	git init expensive-repo &&
+	(
+		cd expensive-repo &&
+		git config set core.compression 0 &&
+		git config set maintenance.auto false &&
 
-	for i in $(test_seq 1 5)
-	do
-		test-tool genrandom foo$i $((512 * 1024 * 1024 + 1)) >>big ||
-		return 1
-	done &&
-	git add big &&
-	git commit -qm "Add big file (1)" &&
+		for i in $(test_seq 1 5)
+		do
+			test-tool genrandom foo$i $((512 * 1024 * 1024 + 1)) >>big ||
+			return 1
+		done &&
+		git add big &&
+		git commit -qm "Add big file (1)" &&
 
-	# ensure any possible loose objects are in a pack-file
-	git maintenance run --task=loose-objects &&
+		# ensure any possible loose objects are in a pack-file
+		git maintenance run --task=loose-objects &&
 
-	rm big &&
-	for i in $(test_seq 6 10)
-	do
-		test-tool genrandom foo$i $((512 * 1024 * 1024 + 1)) >>big ||
-		return 1
-	done &&
-	git add big &&
-	git commit -qm "Add big file (2)" &&
+		rm big &&
+		for i in $(test_seq 6 10)
+		do
+			test-tool genrandom foo$i $((512 * 1024 * 1024 + 1)) >>big ||
+			return 1
+		done &&
+		git add big &&
+		git commit -qm "Add big file (2)" &&
 
-	# ensure any possible loose objects are in a pack-file
-	git maintenance run --task=loose-objects &&
+		# ensure any possible loose objects are in a pack-file
+		git maintenance run --task=loose-objects &&
 
-	# Now run the incremental-repack task and check the batch-size
-	GIT_TRACE2_EVENT="$(pwd)/run-2g.txt" git maintenance run \
-		--task=incremental-repack 2>/dev/null &&
-	test_subcommand git multi-pack-index repack \
-		 --no-progress --batch-size=2147483647 <run-2g.txt
+		# Now run the incremental-repack task and check the batch-size
+		GIT_TRACE2_EVENT="$(pwd)/run-2g.txt" git maintenance run \
+			--task=incremental-repack 2>/dev/null &&
+		test_subcommand git multi-pack-index repack \
+			--no-progress --batch-size=2147483647 <run-2g.txt
+	)
 '
 
 run_incremental_repack_and_verify () {

-- 
2.55.0.795.g602f6c329a.dirty


^ permalink raw reply related

* [PATCH v3 5/9] t7508: skip EXPENSIVE test that is broken without SIZE_T_IS_64BIT
From: Patrick Steinhardt @ 2026-07-06  6:24 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano, Johannes Schindelin, SZEDER Gábor, Jeff King
In-Reply-To: <20260706-b4-pks-t-fixes-for-GIT-TEST-LONG-v3-0-4f6c5a37fd1f@pks.im>

One of the tests in t7508 is marked as EXPENSIVE because it ends up
creating and adding files that are multiple gigabytes in size. This
takes a while to complete, hence the EXPENSIVE prerequisite.

Besides being expensive though the test can only work on systems where
`size_t` is at least 64 bit. This is because one of the created files
is larger than 4GB, and because Git tracks object size via `size_t` it
will eventually blow up.

This test has also been blowing up in the "linux32" CI job in GitHub
Workflows since 7a094d68a2 (ci: run expensive tests on push builds to
integration branches, 2026-05-08). But that job doesn't only fail, it
also hangs, and that has been concealing the failure.

Fix the issue by marking the test as requiring 64 bit `size_t`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 t/t7508-status.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/t/t7508-status.sh b/t/t7508-status.sh
index c2057bc94c..dfdd78b6fe 100755
--- a/t/t7508-status.sh
+++ b/t/t7508-status.sh
@@ -1773,7 +1773,7 @@ test_expect_success 'slow status advice when core.untrackedCache true, and fsmon
 	)
 '
 
-test_expect_success EXPENSIVE 'status does not re-read unchanged 4 or 8 GiB file' '
+test_expect_success EXPENSIVE,SIZE_T_IS_64BIT 'status does not re-read unchanged 4 or 8 GiB file' '
 	(
 		mkdir large-file &&
 		cd large-file &&

-- 
2.55.0.795.g602f6c329a.dirty


^ permalink raw reply related


This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox