Git development
 help / color / mirror / Atom feed
* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren @ 2026-06-09 13:20 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt, Phillip Wood
In-Reply-To: <xmqqa4t3ubwj.fsf@gitster.g>

On Tue, Jun 9, 2026 at 2:38 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Harald Nordgren <haraldnordgren@gmail.com> writes:
>
> > The GitHub CI has been broken for some time, maybe I should have told
> > you about this earlier, but it coincided with a period where other
> > open source projects I worked on also had mass CI failures, so I
> > chalked it up to upstream issues (GitHub, Linux, etc). But it seems to
> > have not gone away.
> >
> > All of my GitHub pull requests have broken tests (see e.g. which a
> > quite minimal change: https://github.com/git/git/pull/2313). This
> > makes it harder to detect actual issues. But of course it's not an
> > excuse.
>
> FWIW, the breakage was observed in my local testing, and that is why
> I found it so disturbing.  Apparently you didn't see such breakages
> that can be detected so easily during your local testing (otherwise
> you wouldn't have pushed it out to update your GitHub pull request),
> which may mean something in the test are platform dependent?

No, it was broken on my local machine as well. I was sloppy when I
pushed out v13 and didn't run tests locally.

Usually I will push to my GitHub PR incrementally as I work on a new
version. I don’t necessarily keep the GitHub PR clean between
submitting versions. I will diff against my latest version tag to see
my own progress, and if I mess it up I can always hard reset to the
latest version tag to start over from there.

Normally, I would never push out a new version unless the GitHub CI
passes. But it’s been broken for a month.


Harald

^ permalink raw reply

* Re: [PATCH] switch: add --ensure option
From: Junio C Hamano @ 2026-06-09 12:59 UTC (permalink / raw)
  To: Lei Zhu via GitGitGadget; +Cc: git, Lei Zhu
In-Reply-To: <pull.2324.git.git.1780997009796.gitgitgadget@gmail.com>

"Lei Zhu via GitGitGadget" <gitgitgadget@gmail.com> writes:

> Users who often switch between topic branches may not know whether the
> local branch already exists. Without this option, they need to check
> for the branch first and then choose between `git switch <branch>` and
> `git switch -c <branch>`. The new option folds that workflow into a
> single command.

Quite honestly, I am not sure if the use case should be supported
with a new option, or we should actively discourage it by rejecting
any patch that takes us in this direction, as the actions the user
would take after seeing the result of "git checkout -b" or "git
switch -c" are quite different among (just off the top of my head):

 (1) Ah we already had the branch created exactly to work on this;
     instead of forking a new effort, switch to the existing branch
     and build on the effort we made previously, as it forks from an
     acceptable base, which might have been different from where we
     wanted to start at when we said "git switch -c <branch> <base>".

 (2) Ah we already had the branch created exactly to work on this.
     Unfortunately, it was forked from way too new base before we
     realized that this is also an important bugfix that needs to be
     mergeable to the maintenance track.  Let's create a new branch
     that is a copy of the existing one with "-maint" in its name,
     rebase it on the maintenance track, and work there.

 (3) Ah we already had a branch that happened to have the same name,
     but created for totally different reasons.  We do want to fork
     a new branch but need to give it a different name.

 (4) There wasn't a branch with the given name, so we created a new
     branch at the right starting point we just picked when we ran
     "git checkout -b"/"git switch -c".  Let's start working on the
     topic.

You cover *only* case (4) perfectly.  When your "-c <branch>" picks
an existing branch, the user still needs to figure out which among
situations (1)-(3) (of course, there may be others) they are in, and
act accordingly.  "git checkout -b" and "git switch -c" that fails,
reminding that there is an existing branch with the same name, gives
users a stronger form of reminder than switching blindly to the
existing branch, which may (in case (1)) or may not (in cases (2)
and (3)) be where the user wants to be when taking the next action.

Having said that.

 * The option name "-e" would make all users expect that this has
   something to do with "--editor".  Start with a longer name,
   perhaps "--create-if-missing" or something, and then see if
   others can come up with a better short-hand.  Obviously whoever
   chooses "-e" is not equipped well to do so (yet), and the
   reviewer who pointed out "-e" is not a good idea without being
   able to offer a better alternative is not, either ;-).

 * Adding a new flag only to "switch" without "checkout" will
   unnecessary confuse users.  This is because, even though
   "switch/restore" started as an experiment to _supersede_
   "checkout", they were not successful, not in the sense that
   "switch/restore" were harder to use than the original, but in the
   sense that the userbase and teaching materials are already used
   to the original and removing it is practically infeasible.

^ permalink raw reply

* Re: [PATCH 0/3] config: allow disabling config includes
From: Derrick Stolee @ 2026-06-09 12:59 UTC (permalink / raw)
  To: Jeff King, Derrick Stolee via GitGitGadget; +Cc: git, gitster
In-Reply-To: <20260608225149.GB340696@coredump.intra.peff.net>

On 6/8/2026 6:51 PM, Jeff King wrote:
> On Mon, Jun 08, 2026 at 01:57:03PM +0000, Derrick Stolee via GitGitGadget wrote:
> 
>> This series introduces a new way to ignore config include directives via two
>> mechanisms:
>>
>>  * GIT_CONFIG_INCLUDES=0 in the environment.
>>  * git --no-includes ... in the command line.
>>
>> My motivation is from a tricky situation where users want to do the risky
>> thing and include a repo-tracked file for sharing aliases and other
>> recommended config. They are then struggling in a later build step that is
>> running Git commands (under a tool we don't control and can't change) that
>> then cause filesystem accesses outside of the build system's sandbox.
> 
> I'm not opposed to global control of includes, but this is just one way
> in which config can escape the sandbox. They can always point to files
> (e.g., core.attributesFile) or even commands that totally leave the
> sandbox (e.g., ext diff or textconv commands). Fundamentally git config
> is equivalent to arbitrary code execution, so pointing an include at a
> repo-tracked file carries the risk of confusion both malicious and
> accidental.
> 
> So I dunno. From the described motivation, this feels like a band-aid
> that fixes only one narrow instance of a greater problem.
> 
> The notion of enabling/disabling includes per-command is itself a
> flexible building block. So it's possible that it has other uses in
> general. But it's also a fairly broad hammer that covers more than your
> use case. If you're planning to use "git --no-includes" in some script,
> then it breaks the config of anybody who uses includes in their
> user-level ~/.gitconfig file.
> 
> So you may need some more directed limiting.

Are you suggesting some kind of internal sandbox to limit Git from
accessing repository paths from config includes and other config-set keys?
That would be a more complete solution, but I'm not sure how we could plug
all of those holes at once. I'll think on it, though.

>> One thing I do worry about is whether or not this would cause a significant
>> break in behavior, or if this is a relatively safe thing to allow.
> 
> Yeah. Consider something like:
> 
>   $ cat ~/.gitconfig
>   [user]
>   name = My Name
>   email = me@personal.example.com
>   [includeIf "gitdir:/path/to/work/stuff"]
>   path = .gitconfig-work
> 
>   $ cat ~/.gitconfig-work
>   [user]
>   email = me@work.example.com
> 
> Using "git --no-include" will silently use the wrong user.email value.
> That's OK if the user is asking for it, but if you are planning to
> sprinkle "--no-include" inside scripts, that's likely to cause
> confusion.

This is exactly the kind of case I was worried about. This specific case
only impacts write operations, but some tools do those things. And this
email case is a common one that users do in their global config to isolate
personal and professional identities.

I'm trying to think if there's a place where we'd have some config that is
critical to the repo functioning not in its local config (like the repo
format version or extensions). Perhaps borrowing from your work/personal
example, a user could use a different credential helper for work than they
use for personal repositories.

Perhaps we need to be very careful to warn users of this option or
environment variable that behavior can change from typical use.

Or: are we venturing into territory where we don't even want to create a
new foot-gun? If there were another way to solve the situation that I'm
facing without these risks, then I'd be open to it. Any ideas?

Thanks,
-Stolee

^ permalink raw reply

* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Junio C Hamano @ 2026-06-09 12:38 UTC (permalink / raw)
  To: Harald Nordgren
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt, Phillip Wood
In-Reply-To: <CAHwyqnWpkF-8czt8+G4GJpMTb1qXG6FtN1HKrT5H+OcfAjQL=Q@mail.gmail.com>

Harald Nordgren <haraldnordgren@gmail.com> writes:

> The GitHub CI has been broken for some time, maybe I should have told
> you about this earlier, but it coincided with a period where other
> open source projects I worked on also had mass CI failures, so I
> chalked it up to upstream issues (GitHub, Linux, etc). But it seems to
> have not gone away.
>
> All of my GitHub pull requests have broken tests (see e.g. which a
> quite minimal change: https://github.com/git/git/pull/2313). This
> makes it harder to detect actual issues. But of course it's not an
> excuse.

FWIW, the breakage was observed in my local testing, and that is why
I found it so dusturbing.  Apparently you didn't see such breakages
that can be detected so easily during your local testing (otherwise
you wouldn't have pushed it out to update your GitHub pull request),
which may mean something in the test are platform dependent?

^ permalink raw reply

* Re: [PATCH v4 0/8] Auto-configure advertised remotes via URL allowlist
From: Toon Claes @ 2026-06-09 12:25 UTC (permalink / raw)
  To: Christian Couder
  Cc: git, Junio C Hamano, Patrick Steinhardt, Taylor Blau,
	Karthik Nayak, Elijah Newren, Kristoffer Haugsbakk
In-Reply-To: <CAP8UFD0r96KxU3kW2khJ_MySgtv0ZpU26KR1vNimp_FwigQfXA@mail.gmail.com>

Christian Couder <christian.couder@gmail.com> writes:

>> But I previously mentioned I felt the naming of 'acceptFromServer' and
>> 'acceptFromServerUrl' are a bit confusing. So I'm wondering whether we
>> can consider another proposal:
>>
>> What if 'acceptFromServer' would configure if 'acceptFromServerUrl'
>> should be used? I mean, imagine we put this in the config:
>>
>>     [promisor]
>>         acceptFromServer = Match
>>         acceptFromServerUrl = https://my-org.com/*
>>
>> (we can still argue over naming, but to get the idea)
>>
>> So the value "Match" for 'acceptFromServer' would inform Git to use
>> 'acceptFromServerUrl'. This way precedence isn't a concern no more,
>> because every value for 'acceptFromServer' is mutually exclusive.
>
> In this case I would prefer to remove 'acceptFromServerUrl' entirely
> and to make acceptFromServer accept values like:
>
>     match:https://my-org.com/*
>
> By the way "match" might not be the best term. Maybe something like
> "auto-configure" would be better.

I think that's too complicated. Let's not do that.

>> This has one downside though, you can no longer combine
>> acceptFromServer=KnownUrl with a 'acceptFromServerUrl'. So URLs
>> advertised by the server can no longer fall-through to
>> 'acceptFromServer' if they don't match 'acceptFromServerUrl'. You can
>> argue whether that's a good thing or not.
>
> I think it's a good thing to have this fall-through. It allows setting
> up things like this:
>
> In the global config:
>
> [promisor]
>         acceptFromServerUrl = https://my-org.com/*
>
> In the config of only a few repo that need it:
>
> [promisor]
>         acceptFromServer = knownUrl
>
> This way remotes from my-org.com are accepted in all the repos, while
> other remotes are accepted only if their name and URLs have already
> been configured in the repos that need them.
>
> This allows relatively lenient security for internal repos and more
> strict security for external ones, and I suspect that many users will
> want something like that.
>
> What you suggest doesn't allow that. It could force users to choose
> for each repo between either URL based allowlist or local
> configuration of every remote.

Well yes, that's why I mentioned:

> You can argue whether that's a good thing or not.

If it's intentional and as you mention there's a valid use-case for
this, then I agree with your approach in this series.

> Also I think it's easier to explain that 'acceptFromServerUrl' is a
> different mechanism (that allows auto-configuration, contrary to
> 'acceptFromServer') if these two variables are independent.

True, although naming-wise it doesn't feel like that. But I no longer
gonna keep picking on that, so ignore this comment please. :-)

>> What do you think? If you disagree, I'm fine with the current approach
>> and I think this version looks good.
>
> Thanks for your review and for being fine with the current approach if
> I disagree.

Thanks for explaining, I still agree moving on like this.

-- 
Cheers,
Toon

^ permalink raw reply

* Re: [PATCH v3 0/3] Documentation: recommend the use of b4
From: Toon Claes @ 2026-06-09 12:04 UTC (permalink / raw)
  To: Patrick Steinhardt, git
  Cc: Junio C Hamano, Tuomas Ahola, Weijie Yuan, Ramsay Jones,
	SZEDER Gábor, Kristoffer Haugsbakk
In-Reply-To: <20260608-pks-b4-v3-0-f5e497d10c56@pks.im>

Patrick Steinhardt <ps@pks.im> writes:

> Hi,
>
> this small patch series wires up b4 in Git and recommends the use
> thereof via "MyFirstContribution", as discussed in [1].
>
> Changes in v3:
>   - I wasn't really able to judge consensus one way or the other
>     regarding the deep vs shallow nesting of cover letters, so I still
>     have the change to shallow nesting of cover letters part of this
>     series. If we continue to be split on this one (or if we favor the
>     current status quo) I'm happy to drop the first patch and adapt the
>     last patch to use deep nesting of cover letters instead.

Personally I don't care too much. I'm used to the shallow threading, so
I'm fine with that.

Now on the other hand, looking at a few examples I see GitGitGadget does
deep nesting. Wouldn't it make sense to be consistent?

Anyhow, I don't think it's worth it to keep bike shedding about this. In
all methods we recommend to Cc people, I think that's more important
then caring about how messages are threaded (for example, I've noticed
LKML doesn't thread at all, i.e. `b4.send-same-thread=no` which is the
default).

Bottom line, for me this series is good to go in.

-- 
Cheers,
Toon

^ permalink raw reply

* Re: [PATCH v2] describe: limit default ref iteration to tags
From: Jeff King @ 2026-06-09 11:09 UTC (permalink / raw)
  To: Tamir Duberstein; +Cc: git, Junio C Hamano, Patrick Steinhardt
In-Reply-To: <20260608-describe-tag-ref-scope-v2-1-256fd36dca32@gmail.com>

On Mon, Jun 08, 2026 at 07:32:14PM -0700, Tamir Duberstein wrote:

> The benchmark checkout had 120,532 refs, of which 330 were tags. With
> `$repo` naming the checkout, `$commit` an exactly tagged commit, and
> `$parent` and `$this` the two binaries, I ran:
> 
>     hyperfine --warmup 3 --runs 15 \
>         --command-name parent \
>         '$parent -C $repo describe --exact-match $commit' \
>         --command-name 'this commit' \
>         '$this -C $repo describe --exact-match $commit'
> 
> The results were:
> 
>     Benchmark 1: parent
>       Time (mean ± σ):     171.7 ms ±  18.5 ms    [User: 23.9 ms, System: 133.6 ms]
>       Range (min … max):   142.3 ms … 198.3 ms    15 runs
> 
>     Benchmark 2: this commit
>       Time (mean ± σ):       9.9 ms ±   1.1 ms    [User: 3.3 ms, System: 4.7 ms]
>       Range (min … max):     8.8 ms …  13.1 ms    15 runs
> 
>     Summary
>       this commit ran
>        17.35 ± 2.63 times faster than parent
> 
> Both revisions were built with -O3, -mcpu=native, and ThinLTO using
> Apple clang 21.0.0 on macOS 26.5. The machine was a MacBook Pro
> (Mac16,6) with a 16-core Apple M4 Max (12 performance and four
> efficiency cores) and 128 GB RAM.

This patch looks fine to me, but let me pick a nit for a minute, because
I think there is a broader conversation to be had.

Given the discussion in earlier rounds and sibling topics, I assume the
commit message here was AI-generated. And it's OK in the sense that it
is describing what happened and I assume is entirely accurate. But as a
human reader, it feels so much more verbose than what I'd expect, as it
is full of semi-irrelevant details. Why set --warmup and --runs? Why
bother with --command-name, which just means you have to show the
commands separately anyway? Is the amount of RAM in the machine
important for this test? Surely it could be if it was absurdly tiny, but
in general, no, I would not expect it to be.

So while it is perhaps reasonable to document every detail in case
somebody later wants to verify or reproduce timings, it is a little
overwhelming when trying to tell a story, the core of which is:

  In a repo with ~120k refs, ~300 of which were tags, running:

    git describe --exact-match $some_tag

  went from ~170ms to ~10ms, since we no longer needed to iterate all of
  those other refs.

That has _way_ less detail, but makes the point succinctly.

I dunno. I am not trying to pick apart your commit in particular, but am
more interested in the broader use of AI commit messages going forward.
This kind of verbosity is quite common in the output (from my limited
experience), and I think creates more work for reviewers. Should we be
expecting contributors to make things more concise before submitting
(either manually or through prompting)? Or do people even agree that the
shorter version is preferable? I could be the only one.

I have a few other comments on the patch itself below.

> diff --git a/builtin/describe.c b/builtin/describe.c
> index 1c47d7c0b7..3532c8ff22 100644
> --- a/builtin/describe.c
> +++ b/builtin/describe.c
> @@ -740,6 +740,9 @@ int cmd_describe(int argc,
>  		return ret;
>  	}
>  
> +	if (!all)
> +		for_each_ref_opts.prefix = "refs/tags/";
> +
>  	hashmap_init(&names, commit_name_neq, NULL, 0);
>  	refs_for_each_ref_ext(get_main_ref_store(the_repository),
>  			      get_name, NULL, &for_each_ref_opts);

The code change looks fine. It creates a bit of a subtle dependency
between what's happening here, and the filtering inside get_name(). But
I think that's OK for the scope of a single command. It _might_ be
possible to simplify the top of get_name(), since we'd no longer see
non-tag refs in the input. But it also may not, since we have to strip
out the prefix anyway. It can certainly come on top as a cleanup later
if we want.

> diff --git a/t/perf/p6100-describe.sh b/t/perf/p6100-describe.sh

It is a little curious that we add a perf test here, but the commit
message does not even show it off. ;)

I ran it myself here and had trouble showing improvement, simply because
it is already quite fast! I guess that's because I'm on Linux, where
warm-cache filesystem operations are pretty fast. Bumping $ref_count by
a factor of 10 made the "before" case 30ms, and after is still sub-1ms.

> +test_expect_success 'set up many unrelated refs' '
> +	ref_count=10000 &&
> +	git tag -m tip tip HEAD &&
> +	for i in $(test_seq $ref_count)
> +	do
> +		printf "create refs/heads/describe-perf/%05d HEAD\n" $i ||
> +		return 1
> +	done >instructions &&
> +	git update-ref --stdin <instructions
> +'

A few things come to mind on reading this.

I have mixed feelings on sticking synthetic constructions in the t/perf
suite. Part of the original point was that we'd run it against real
repos to see how they perform. But that implies that people running it
have some clue about which tests may be interesting on which repos,
which is hopeful at best. So we've turned to this kind of synthetic
construction at times (and this is certainly not the first). It's
probably a reasonable tactic here.

I suspect the resulting state is not all that realistic, though. If you
have 10,000 refs, you probably didn't make them all at once. And so in
practice the majority of them would be packed. Sticking "git pack-refs
--all" at the end might give more realistic numbers.

Bumping to a larger number of refs shows the effect more clearly, but at
the cost of making the setup take a long time (since we have to take a
lockfile on each ref!). We could sneak around it by generating a
packed-refs file directly, but now the test really would be
backend-specific. Probably better not to go there.

And finally, the loop can be written a bit more succinctly these days
as:

diff --git a/t/perf/p6100-describe.sh b/t/perf/p6100-describe.sh
index ed9f1abe18..b365dc67ee 100755
--- a/t/perf/p6100-describe.sh
+++ b/t/perf/p6100-describe.sh
@@ -30,12 +30,8 @@ test_perf 'describe HEAD with one tag' '
 test_expect_success 'set up many unrelated refs' '
 	ref_count=10000 &&
 	git tag -m tip tip HEAD &&
-	for i in $(test_seq $ref_count)
-	do
-		printf "create refs/heads/describe-perf/%05d HEAD\n" $i ||
-		return 1
-	done >instructions &&
-	git update-ref --stdin <instructions
+	test_seq -f "create refs/heads/describe-perf/%05d HEAD" $ref_count |
+	git update-ref --stdin
 '
 
 test_perf 'describe exact tag with many unrelated refs' '


Probably not worth re-rolling on its own, though.

-Peff

^ permalink raw reply related

* Re: [PATCH v1 0/1] environment: move protect_hfs and protect_ntfs
From: Christian Couder @ 2026-06-09 10:54 UTC (permalink / raw)
  To: Tian Yuchen
  Cc: git, christian, phillip.wood123, Ayush Chandekar,
	Olamide Caleb Bello
In-Reply-To: <20260606143412.15443-2-cat@malon.dev>

On Sat, Jun 6, 2026 at 4:35 PM Tian Yuchen <cat@malon.dev> wrote:
>
> Hi everyone,
>
> This series continues the ongoing libification effort by moving the
> global **filesystem** variables, protect_hfs and protect_ntfs, into
> struct repo_config_values.
>
> Place them within the **per-repository** configuration structure
> aligns with our goal of removing global states.
>
> RFC Questions:
>
> 1. Should we keep PROTECT_HFS_DEFAULT and PROTECT_NTFS_DEFAULT
> in repo_config_values_init()?
>
>         void repo_config_values_init(struct repo_config_values *cfg)
>                 {
>                         cfg->attributes_file = NULL;
>                         cfg->apply_sparse_checkout = 0;
>                         cfg->protect_hfs = PROTECT_HFS_DEFAULT;
>                         cfg->protect_ntfs = PROTECT_NTFS_DEFAULT;
>                         cfg->branch_track = BRANCH_TRACK_REMOTE;
>                 }
>
> Or is it better if they are used anywhere other than in environment.c?

I think it's better to keep them in "environment.c". The
repo_protect_ntfs() and repo_protect_hfs() function I suggest adding
to "environment.c" in my reply to the patch should help with keeping
the macros in "environment.c".

> If so...
> 2. Is it worth introducing a Macro or Getter for safe access?
>
>         ((the_repository->gitdir ? repo_config_values(the_repository)->protect_hfs : 0))

Yes, it seems to me that a getter is enough.

> The current approach looks verbose and lacks readability, and
> hard-coded 0 and 1 are used as fallback values. I wonder if a macro or a
> getter could be introduced, for example...
>
>         #define SAFE_PROTECT_HFS(repo) \
>                 (((repo) && (repo)->gitdir && (repo) == the_repository) ? \
>                 repo_config_values(repo)->protect_hfs : PROTECT_HFS_DEFAULT)
>
> ...to improve the coding style a bit. Although I am aware that introducing
> new macros is generally frowned upon, I would still like to know which
> parts this might make difficult to maintain.

Unless there are features that we really want which a function can't
provide, a function is better as it provides type safety and is
usually easier to maintain.

> 3. Note that Derrick attempted to use get_int_config_global to wrap
> this kind of Filesystem Level global variables. This approach bypassed
> struct repository, did not actually eliminate global state, and the
> reviewer politely rejected it. Nevertheless, I am still curious as
> to whether this approach might still be inspiring today.
>
> https://lore.kernel.org/git/a42dd9397d07b2dc4a0d7e75bfe1af2e46cad262.1685716420.git.gitgitgadget@gmail.com/

To help your reviewers, it might be interesting if you could already
tell why this was rejected by Glen Choo (who reviewed Derrick Stolee's
work then). It seems to me that Glen said that using plain fields in a
struct should be better as long as the fields are always initialized
during the setup process.

And it seems to me that our patch follows the direction that Glen suggested.

Thanks.

^ permalink raw reply

* Re: [PATCH 04/16] odb/source-packed: start converting to a proper `struct odb_source`
From: Patrick Steinhardt @ 2026-06-09 10:47 UTC (permalink / raw)
  To: Karthik Nayak; +Cc: git
In-Reply-To: <aifAVpxanV31KUpC@pks.im>

On Tue, Jun 09, 2026 at 09:27:25AM +0200, Patrick Steinhardt wrote:
> On Mon, Jun 08, 2026 at 08:29:04AM -0700, Karthik Nayak wrote:
> > Patrick Steinhardt <ps@pks.im> writes:
> > > diff --git a/odb/source-packed.c b/odb/source-packed.c
> > > index 12e785be48..f81a990cbd 100644
> > > --- a/odb/source-packed.c
> > > +++ b/odb/source-packed.c
> > > +	CALLOC_ARRAY(packed, 1);
> > > +	odb_source_init(&packed->base, parent->base.odb, ODB_SOURCE_PACKED,
> > > +			parent->base.path, parent->base.local);
> > > +	packed->files = parent;
> > > +	strmap_init(&packed->packs_by_path);
> > > +
> > > +	packed->base.free = odb_source_packed_free;
> > > +
> > > +	if (!is_absolute_path(parent->base.path))
> > > +		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
> > > +
> > 
> > Tangent: seems like no one sets the 'name' field within
> > `chdir_notify_register()`. It is meant for tracing purposes, but if no
> > one is using it, we might as well remove it...? Perhaps #leftoverbits
> 
> There are some callers: `chdir_notify_reparent()` calls
> `chdir_notify_register()` with a name, and the reference backends call
> that function with names.
> 
> Ultimately though I think that this infrastructure is somewhat misguided
> overall: we use this to update relative paths after chdir(3p), but if we
> stored absolute paths in the first place then we wouldn't have to care
> about the paths changing at all.
> 
> I plan to revisit this infra for the object database going forward: we
> expose and use `struct odb_source::path` in various other subsystems,
> including user-facing ones. This is inherently wrong though, as there
> may be sources that don't even have an on-disk path. So there's a need
> to drop that field and make it an internal implementation detail of the
> source's backend. And once we've done that, we can just as well start to
> store absolute paths.
> 
> For the reference backends we can already do that refactoring now-ish.
> I'll send a patch series later today.

Well, as ever so often I went down the rabbit hole and discovered way
more than I wanted:

  - We never free the `struct repository::refs_private` field.

  - We create the refdb multiple times in case we have "onbranch"
    conditionals and never free them.

  - The chicken-and-egg problem we have with "onbranch" and refdb
    creation is awkward.

So I'm now sitting at 11 patches to fix all of those bugs, and I'll also
have to make changes to "setup.c" to fix them. So I'll first have to
wait for ps/setup-centralize-odb-creation to land.

Patrick

^ permalink raw reply

* [PATCH RFC v2 2/2] builtin/history: abort reword on same message
From: Pablo Sabater @ 2026-06-09 10:42 UTC (permalink / raw)
  To: git; +Cc: cat, ps, kaartic.sivaraam, pabloosabaterr, ben.knoble, gitster
In-Reply-To: <20260609-ps-history-reword-v2-0-a0e6028ca9b4@gmail.com>

When using `git history reword <commit>` if the new message is the same
as the original, it continues and rewrites the history when nothing
changed.

`git commit --amend` and `git rebase -i` with reword share this behavior
and it is wrong as well, but changing them breaks what people are used
to. Take the opportunity of `git history` being a new command and handle
it correctly from the start.

Create COMMIT_TREE_ABORT_ON_SAME_MESSAGE and make a check for if the
messages are the same and the flag is set so other subcommands like
fixup that do not want this behavior just don't send the abort flag.

Make commit_tree_ext() return 1 when facing the same message so its
callers can choose what to do.

Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
---
 builtin/history.c         | 14 +++++++++++++-
 t/t3451-history-reword.sh | 16 ++++++++++++++++
 t/t3453-history-fixup.sh  | 22 ++++++++++++++++++++++
 3 files changed, 51 insertions(+), 1 deletion(-)

diff --git a/builtin/history.c b/builtin/history.c
index b3e2e5270d..be07690da4 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -96,6 +96,7 @@ static int fill_commit_message(struct repository *repo,
 
 enum commit_tree_flags {
 	COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
+	COMMIT_TREE_ABORT_ON_SAME_MESSAGE = (1 << 1),
 };
 
 static int commit_tree_ext(struct repository *repo,
@@ -135,6 +136,13 @@ static int commit_tree_ext(struct repository *repo,
 					  original_body, action, &commit_message);
 		if (ret < 0)
 			goto out;
+
+		if (flags & COMMIT_TREE_ABORT_ON_SAME_MESSAGE &&
+		    !strcmp(original_body, commit_message.buf)) {
+			fprintf(stderr, _("Message unchanged, aborting reword.\n"));
+			ret = 1;
+			goto out;
+		}
 	} else {
 		strbuf_addstr(&commit_message, original_body);
 	}
@@ -693,7 +701,8 @@ static int cmd_history_reword(int argc,
 	struct strbuf reflog_msg = STRBUF_INIT;
 	struct commit *original, *rewritten;
 	struct rev_info revs = { 0 };
-	enum commit_tree_flags flags = COMMIT_TREE_EDIT_MESSAGE;
+	enum commit_tree_flags flags = COMMIT_TREE_EDIT_MESSAGE |
+				       COMMIT_TREE_ABORT_ON_SAME_MESSAGE;
 	int ret;
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
@@ -721,6 +730,9 @@ static int cmd_history_reword(int argc,
 	if (ret < 0) {
 		ret = error(_("failed writing reworded commit"));
 		goto out;
+	} else if (ret == 1) {
+		ret = 0;
+		goto out;
 	}
 
 	strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
index de7b357685..6e0e278c42 100755
--- a/t/t3451-history-reword.sh
+++ b/t/t3451-history-reword.sh
@@ -396,4 +396,20 @@ test_expect_success 'retains changes in the worktree and index' '
 	)
 '
 
+test_expect_success 'aborts if the commit message is the same' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+
+		git rev-parse HEAD >oid-before &&
+		GIT_EDITOR=true git history reword HEAD 2>err &&
+		git rev-parse HEAD >oid-after &&
+		test_cmp oid-before oid-after &&
+		test_grep "Message unchanged" err
+	)
+'
+
 test_done
diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
index 868298e248..9f9a3c93de 100755
--- a/t/t3453-history-fixup.sh
+++ b/t/t3453-history-fixup.sh
@@ -443,6 +443,28 @@ test_expect_success '--reedit-message opens editor for the commit message' '
 	)
 '
 
+test_expect_success 'fixup --reedit-message does not abort with the same commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		echo content > file.txt &&
+		git add file.txt &&
+		git commit -m "add file" &&
+
+		echo fix >>file.txt &&
+		git add file.txt &&
+		GIT_EDITOR=true git history fixup --reedit-message HEAD &&
+		expect_changes --branches <<-\EOF
+		add file
+		2	0	file.txt
+		initial
+		1	0	initial.t
+		EOF
+	)
+'
+
 test_expect_success 'retains unstaged working tree changes after fixup' '
 	test_when_finished "rm -rf repo" &&
 	git init repo &&

-- 
2.54.0

^ permalink raw reply related

* [PATCH RFC v2 1/2] builtin/history: refactor function signature
From: Pablo Sabater @ 2026-06-09 10:42 UTC (permalink / raw)
  To: git; +Cc: cat, ps, kaartic.sivaraam, pabloosabaterr, ben.knoble, gitster
In-Reply-To: <20260609-ps-history-reword-v2-0-a0e6028ca9b4@gmail.com>

commit_tree_with_edited_message() calls commit_tree_ext() with the flag
COMMIT_TREE_EDIT_MESSAGE hardcoded and we can't set new flags on callers
like cmd_history_reword() to choose their own flags.

This refactor is needed for a subsequent commit.

Refactor commit_tree_with_edited_message() signature to accept flags
which are passed down to commit_tree_ext() instead of the hardcoded one.

Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
---
 builtin/history.c | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/builtin/history.c b/builtin/history.c
index 0fc06fb204..b3e2e5270d 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -160,7 +160,8 @@ static int commit_tree_ext(struct repository *repo,
 static int commit_tree_with_edited_message(struct repository *repo,
 					   const char *action,
 					   struct commit *original,
-					   struct commit **out)
+					   struct commit **out,
+					   enum commit_tree_flags flags)
 {
 	struct object_id parent_tree_oid;
 	const struct object_id *tree_oid;
@@ -181,7 +182,7 @@ static int commit_tree_with_edited_message(struct repository *repo,
 	}
 
 	return commit_tree_ext(repo, action, original, original->parents,
-			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
+			       &parent_tree_oid, tree_oid, out, flags);
 }
 
 enum ref_action {
@@ -692,6 +693,7 @@ static int cmd_history_reword(int argc,
 	struct strbuf reflog_msg = STRBUF_INIT;
 	struct commit *original, *rewritten;
 	struct rev_info revs = { 0 };
+	enum commit_tree_flags flags = COMMIT_TREE_EDIT_MESSAGE;
 	int ret;
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
@@ -714,7 +716,8 @@ static int cmd_history_reword(int argc,
 	if (ret)
 		goto out;
 
-	ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
+	ret = commit_tree_with_edited_message(repo, "reworded", original,
+					      &rewritten, flags);
 	if (ret < 0) {
 		ret = error(_("failed writing reworded commit"));
 		goto out;

-- 
2.54.0

^ permalink raw reply related

* [PATCH RFC v2 0/2] builtin/history: abort reword on same message
From: Pablo Sabater @ 2026-06-09 10:42 UTC (permalink / raw)
  To: git; +Cc: cat, ps, kaartic.sivaraam, pabloosabaterr, ben.knoble, gitster
In-Reply-To: <20260607-ps-history-reword-v1-0-ba43a3cbb81b@gmail.com>

This short series aims to improve the behavior of `git history reword`
to abort when the new commit message is the same as the original,
avoiding unnecessary history rewrites.

`git commit --amend` and `git rebase -i` with reword share this flaw but
changing them faces not just technical challenges but also breaks what
people are used to, so that is not a viable option. Let's take the
opportunity that `git history` is a new command and handle this
correctly from the start.

This is made so any other future subcommand or option that does want
this behavior just has to add the abort flag.

A questions I have is why don't we want this abort behavior on
`git history fixup --reedit-message` it makes more sense on
`git history reword` because if the message is the same then it has
nothing to do while fixup can still have files to update, but
--reedit-message is still a redundant option there.

Signed-off-by: Pablo Sabater <pabloosabaterr@gmail.com>
---
Changes in v2:
- Changed the reason on why is this needed.
- Changed tests with same message to use GIT_EDITOR=true instead of the
  script.
- Abort on same message only happens when its own flag is set so no
  other subcommand that does not want this behavior and depend on
  commit_tree_ext() is affected.
- Dropped the feedback on successful reword for another series.

---
Pablo Sabater (2):
      builtin/history: refactor function signature
      builtin/history: abort reword on same message

 builtin/history.c         | 21 ++++++++++++++++++---
 t/t3451-history-reword.sh | 16 ++++++++++++++++
 t/t3453-history-fixup.sh  | 22 ++++++++++++++++++++++
 3 files changed, 56 insertions(+), 3 deletions(-)
---
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
change-id: 20260607-ps-history-reword-fcb70eaa4aa9

Best regards,
--  
Pablo Sabater <pabloosabaterr@gmail.com>

^ permalink raw reply

* Re: [PATCH v2] ls-files: filter pathspec before lstat
From: Jeff King @ 2026-06-09 10:41 UTC (permalink / raw)
  To: Tamir Duberstein
  Cc: git, René Scharfe, Patrick Steinhardt, Junio C Hamano
In-Reply-To: <20260608-ls-files-pathspec-lstat-v2-1-fb734b28422e@gmail.com>

On Mon, Jun 08, 2026 at 07:37:15PM -0700, Tamir Duberstein wrote:

> +		/*
> +		 * match_pathspec() is linear in pathspec.nr, so prefilter only
> +		 * the single-pathspec case. Only entries shown by show_ce()
> +		 * satisfy --error-unmatch.
> +		 */
> +		if (pathspec.nr == 1 &&
> +		    !match_pathspec(repo->index, &pathspec, fullname.buf,
> +				    fullname.len, max_prefix_len, NULL,
> +				    S_ISDIR(ce->ce_mode) ||
> +				    S_ISGITLINK(ce->ce_mode)))
> +			continue;

This feels...kind of arbitrary, no? Surely it's also faster with
pathspec.nr == 2, and so on up to some nr closer to the size of the
total index. It feels weird to be making an arbitrary cutoff based on
pathspec performance in calling code like this.

It is not wrong, per se, as you are optimizing your case without trying
to hurt any others. But what do we do when somebody profiles it and
comes along trying to bump the number to 2, or 10?

I dunno.

-Peff

^ permalink raw reply

* Re: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Kristoffer Haugsbakk @ 2026-06-09 10:30 UTC (permalink / raw)
  To: Pablo Sabater, Junio C Hamano; +Cc: git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <CAN5EUNRW3gyLKGC7x5BBMTNKtunoQks9AaXJse4PHvCziRF87A@mail.gmail.com>

On Tue, Jun 9, 2026, at 12:14, Pablo Sabater wrote:
> El lun, 8 jun 2026 a las 14:16, Junio C Hamano (<gitster@pobox.com>) escribió:
>>
> [snip]
>>
>> `git rebase -i` may have an excuse that because it, unlike "git
>> commit --amend", operates on multiple commits by design.  A single
>> "--force" option given to the command would not have worked as an
>> escape hatch to allow the user to tell the command "in this reword
>> of this particular commit, I ended up doing nothing, but I still
>> want an updated committer log timestamp".  Perhaps giving the
>> "--force" (or --force-rewrite") option at "rebase --continue" time
>> may work, but in any case, unless we plan to transition to these
>> "better" default behaviour at a big version boundary, speculating
>> what a "better" behaviour would have been may be fun but not very
>> productive.
>>
>>
>> [Footnote]
>>
>>  *1* Besides, doesn't "--update-refs" in "rebase -i" allow you to
>>      adjust the branches?
>>
>>  *2* But it is an established behaviour people _rely_ on, so even
>>      though it may have been better if these commands behaved
>>      differently, it probably is a bit too late to change it now.
>>
>>  *3* This includes the case where the original author is especially
>>      difficult to work with and would complain any change to their
>>      commits, even if the only change you made for them is a
>>      typofix.  Fixing a small typo/grammo may not be worth your time
>>      and unpleasant exchanges with them after touching their commit.
>
>[snip]
>
> About the --force sounds good to me. I could seek to implement it in
> this series if it's ok.

When starting without historical baggage anyway, I have doubts about the
`--force` name in general. This often just begs me to ask what it is
forcing. Why not name the thing that is being forced? Verbosity
shouldn’t be a problem for a “force” option. So `--force-rewrite` if you
are forcing new commits to be created (like already mentioned).

See git-clean(1) which has two levels of `--force`.

> The footnote 3 is indeed a good example haha, but yeah, why rewrite
> the history unnecessarily.

^ permalink raw reply

* Re: [PATCH v1 1/1] environment.c: move 'protect_hfs' and 'protect_ntfs' into 'repo_config_values'
From: Christian Couder @ 2026-06-09 10:20 UTC (permalink / raw)
  To: Tian Yuchen
  Cc: git, christian, phillip.wood123, Ayush Chandekar,
	Olamide Caleb Bello
In-Reply-To: <20260606143412.15443-1-cat@malon.dev>

"environment.c:" in the subject could be replaced by just
"environment:". It's a bit shorter and it better describes the area of
the code where the main changes are made, as changes are not just made
in "environment.c" but also in "environment.h".

On Sat, Jun 6, 2026 at 4:34 PM Tian Yuchen <cat@malon.dev> wrote:
>
> Move the global 'protect_hfs' and 'protect_ntfs' configurations
> into the repository-specific 'repo_config_values' struct.
> This will help with the elimination of 'the_repository'
>
> For now, associated functions access this configuration by
> explicitly falling back to 'the_repository', which needs to
> be addressed in the future.
>
> Note: In 't/helper/test-path-utils.c', there is a function
> 'protect_ntfs_hfs_benchmark()' where these two global
> variables are used as loop iterators. New local variables
> have been created to replace them.


> diff --git a/compat/mingw.c b/compat/mingw.c
> index aa7525f419..c77696ba8a 100644
> --- a/compat/mingw.c
> +++ b/compat/mingw.c
> @@ -3392,7 +3392,7 @@ int is_valid_win32_path(const char *path, int allow_literal_nul)
>         const char *p = path;
>         int preceding_space_or_period = 0, i = 0, periods = 0;
>
> -       if (!protect_ntfs)
> +       if (!(the_repository->gitdir ? repo_config_values(the_repository)->protect_ntfs : 1))
>                 return 1;

I think the code would benefit from functions like:

int repo_protect_ntfs(struct repository *repo)
{
    return repo->gitdir ?
        repo_config_values(repo)->protect_ntfs :
        PROTECT_NTFS_DEFAULT;
}

int repo_protect_hfs(struct repository *repo)
{
    return repo->gitdir ?
        repo_config_values(repo)->protect_hfs :
        PROTECT_HFS_DEFAULT;
}

They could be called by passing `the_repository` for now, but perhaps
later in future commits `istate->repo` or something like that could be
passed instead. Also a code comment could explain that the `gitdir`
check prevents calling `repo_config_values()` before config is loaded.

Thanks.

^ permalink raw reply

* Re: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Pablo Sabater @ 2026-06-09 10:14 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <xmqqmrx5z0po.fsf@gitster.g>

El lun, 8 jun 2026 a las 14:16, Junio C Hamano (<gitster@pobox.com>) escribió:
>
[snip]
>
> `git rebase -i` may have an excuse that because it, unlike "git
> commit --amend", operates on multiple commits by design.  A single
> "--force" option given to the command would not have worked as an
> escape hatch to allow the user to tell the command "in this reword
> of this particular commit, I ended up doing nothing, but I still
> want an updated committer log timestamp".  Perhaps giving the
> "--force" (or --force-rewrite") option at "rebase --continue" time
> may work, but in any case, unless we plan to transition to these
> "better" default behaviour at a big version boundary, speculating
> what a "better" behaviour would have been may be fun but not very
> productive.
>
>
> [Footnote]
>
>  *1* Besides, doesn't "--update-refs" in "rebase -i" allow you to
>      adjust the branches?
>
>  *2* But it is an established behaviour people _rely_ on, so even
>      though it may have been better if these commands behaved
>      differently, it probably is a bit too late to change it now.
>
>  *3* This includes the case where the original author is especially
>      difficult to work with and would complain any change to their
>      commits, even if the only change you made for them is a
>      typofix.  Fixing a small typo/grammo may not be worth your time
>      and unpleasant exchanges with them after touching their commit.

True, after reading it, history being more costly or the in memory are
not good args.
I do agree that these commands that do reword should check if the
reword ends up being the same message, given that history is a new
command we can have it from the start so users do not really expect
other behavior.
About the --force sounds good to me. I could seek to implement it in
this series if it's ok.
The footnote 3 is indeed a good example haha, but yeah, why rewrite
the history unnecessarily.

Thanks,
Pablo

^ permalink raw reply

* [PATCH v14 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.

--dry-run is only meaningful together with --prune-merged and is
rejected otherwise.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  8 ++++++-
 builtin/branch.c              | 13 ++++++++---
 t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++
 3 files changed, 61 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5c43dc55a8..1f49a831fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
 git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
-git branch --prune-merged <branch>...
+git branch [--dry-run] --prune-merged <branch>...
 
 DESCRIPTION
 -----------
@@ -226,6 +226,12 @@ Branches refused by the "fully merged" safety check are listed as
 warnings and skipped; pass them to `git branch -D` explicitly if
 you want them gone.
 
+`--dry-run`::
+	With `--prune-merged`, print which branches would be
+	deleted and exit without touching any ref.  Useful for
+	sanity-checking a wide pattern like `'origin/*'` before
+	committing to the deletion.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 52a0371292..7c52a88af2 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
 }
 
 static int prune_merged_branches(int argc, const char **argv,
-				 int quiet)
+				 int quiet, int dry_run)
 {
 	struct ref_store *refs = get_main_ref_store(the_repository);
 	struct ref_filter filter = REF_FILTER_INIT;
@@ -777,7 +777,8 @@ static int prune_merged_branches(int argc, const char **argv,
 				      FILTER_REFS_BRANCHES,
 				      DELETE_BRANCH_WARN_ONLY |
 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
-				      (quiet ? DELETE_BRANCH_QUIET : 0));
+				      (quiet ? DELETE_BRANCH_QUIET : 0) |
+				      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
 
 	strvec_clear(&deletable);
 	ref_array_clear(&candidates);
@@ -827,6 +828,7 @@ int cmd_branch(int argc,
 	int delete = 0, rename = 0, copy = 0, list = 0,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int prune_merged = 0;
+	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -882,6 +884,8 @@ int cmd_branch(int argc,
 			 N_("edit the description for the branch")),
 		OPT_BOOL(0, "prune-merged", &prune_merged,
 			N_("delete local branches whose upstream matches <branch> and is merged")),
+		OPT_BOOL(0, "dry-run", &dry_run,
+			N_("with --prune-merged, only print which branches would be deleted")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -944,6 +948,9 @@ int cmd_branch(int argc,
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
+	if (dry_run && !prune_merged)
+		die(_("--dry-run requires --prune-merged"));
+
 	if (recurse_submodules_explicit) {
 		if (!submodule_propagate_branches)
 			die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -983,7 +990,7 @@ int cmd_branch(int argc,
 				      (quiet ? DELETE_BRANCH_QUIET : 0));
 		goto out;
 	} else if (prune_merged) {
-		ret = prune_merged_branches(argc, argv, quiet);
+		ret = prune_merged_branches(argc, argv, quiet, dry_run);
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 3f7b1fc3d6..305c0141fc 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
 	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
 '
 
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+	test_when_finished "rm -rf pm-dry" &&
+	git clone pm-upstream pm-dry &&
+	git -C pm-dry remote add fork ../pm-fork &&
+	test_config -C pm-dry remote.pushDefault fork &&
+	test_config -C pm-dry push.default current &&
+	git -C pm-dry branch one one-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next one &&
+	git -C pm-dry branch two two-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next two &&
+
+	git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
+	test_grep "Would delete branch one " actual &&
+	test_grep "Would delete branch two " actual &&
+
+	git -C pm-dry rev-parse --verify refs/heads/one &&
+	git -C pm-dry rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
+	test_when_finished "rm -rf pm-dry-mixed" &&
+	git clone pm-upstream pm-dry-mixed &&
+	git -C pm-dry-mixed remote add fork ../pm-fork &&
+	test_config -C pm-dry-mixed remote.pushDefault fork &&
+	test_config -C pm-dry-mixed push.default current &&
+	git -C pm-dry-mixed checkout -b wip origin/next &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-dry-mixed local-only &&
+	git -C pm-dry-mixed checkout - &&
+	git -C pm-dry-mixed branch merged one-commit &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
+	git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
+	test_grep "Would delete branch merged" out &&
+	test_grep ! "Would delete branch wip" out &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run without --prune-merged is rejected' '
+	test_must_fail git -C forked branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Setting branch.<name>.pruneMerged=false exempts that branch from
"git branch --prune-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.

Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/config/branch.adoc |  7 +++++++
 Documentation/git-branch.adoc    |  5 +++--
 builtin/branch.c                 | 14 ++++++++++++++
 t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++
 4 files changed, 54 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
 	`git branch --edit-description`. Branch description is
 	automatically added to the `format-patch` cover letter or
 	`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+	If set to `false`, branch _<name>_ is exempt from
+	`git branch --prune-merged`.  Useful for a topic branch you
+	intend to develop further after an initial round has been
+	merged upstream.  Defaults to true.  Explicit deletion via
+	`git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index fdaccc9662..5c43dc55a8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -217,9 +217,10 @@ the upstream refs refreshed.
 +
 A branch is left alone if any of the following holds:
 its upstream no longer resolves locally; it is checked out in any
-worktree; or its push destination (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
 upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.pruneMerged` is set to `false`.
 +
 Branches refused by the "fully merged" safety check are listed as
 warnings and skipped; pass them to `git branch -D` explicitly if
diff --git a/builtin/branch.c b/builtin/branch.c
index af37a0ceb7..52a0371292 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -741,6 +741,8 @@ static int prune_merged_branches(int argc, const char **argv,
 		const char *short_name;
 		struct branch *branch;
 		const char *upstream, *push;
+		struct strbuf key = STRBUF_INIT;
+		int opt_out;
 
 		if (!skip_prefix(full_name, "refs/heads/", &short_name))
 			continue;
@@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
 		if (!push || !strcmp(push, upstream))
 			continue;
 
+		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+		    !opt_out) {
+			if (!quiet)
+				fprintf(stderr,
+					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+					short_name, short_name);
+			strbuf_release(&key);
+			continue;
+		}
+		strbuf_release(&key);
+
 		strvec_push(&deletable, short_name);
 	}
 
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 27ea1319bb..3f7b1fc3d6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '
 	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
 '
 
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+	test_when_finished "rm -rf pm-optout" &&
+	git clone pm-upstream pm-optout &&
+	git -C pm-optout remote add fork ../pm-fork &&
+	test_config -C pm-optout remote.pushDefault fork &&
+	test_config -C pm-optout push.default current &&
+	git -C pm-optout branch one one-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next one &&
+	git -C pm-optout branch two two-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next two &&
+	test_config -C pm-optout branch.one.pruneMerged false &&
+
+	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+	git -C pm-optout rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+	test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+	test_when_finished "rm -rf pm-optout-d" &&
+	git clone pm-upstream pm-optout-d &&
+	git -C pm-optout-d branch one one-commit &&
+	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+	test_config -C pm-optout-d branch.one.pruneMerged false &&
+
+	git -C pm-optout-d branch -d one &&
+	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 4/6] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

	git branch --prune-merged <branch>...

deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.

Reachability is read from local refs; nothing is fetched. Run
"git fetch" first if you want fresh upstream refs.

Three kinds of branches are spared:

  * any branch checked out in any worktree;
  * any branch whose upstream no longer resolves locally, since a
    missing upstream is not by itself a sign of integration;
  * any branch whose push destination equals its upstream
    (<branch>@{push} is the same as <branch>@{upstream}), such as
    a local "main" that tracks and pushes to "origin/main". Right
    after a pull it just looks "fully merged", so it is left
    alone. Only branches that push somewhere other than their
    upstream, typically topics in a fork workflow, are candidates.

Branches that are not yet merged into their upstream are reported
as a short warning and skipped, so one unmerged topic does not
abort the whole sweep.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc |  24 ++++
 builtin/branch.c              |  67 +++++++++++-
 t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++
 3 files changed, 290 insertions(+), 2 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 62ebab6051..fdaccc9662 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
 git branch (-c|-C) [<old-branch>] <new-branch>
 git branch (-d|-D) [-r] <branch-name>...
 git branch --edit-description [<branch-name>]
+git branch --prune-merged <branch>...
 
 DESCRIPTION
 -----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
 	Print the name of the current branch. In detached `HEAD` state,
 	nothing is printed.
 
+`--prune-merged <branch>...`::
+	Delete the local branches that `--forked` would list for the
+	given _<branch>_ arguments, but only those whose tip is
+	reachable from their configured upstream. In other words, the
+	work on the branch has already landed on the upstream it
+	tracks, so the local copy is no longer needed. Several
+	_<branch>_ patterns may be given, e.g. `git branch
+	--prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 2cc5a8cde0..af37a0ceb7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
 	N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
 	N_("git branch [<options>] [-r | -a] [--points-at]"),
 	N_("git branch [<options>] [-r | -a] [--format]"),
+	N_("git branch [<options>] --prune-merged <branch>..."),
 	NULL
 };
 
@@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
 	return 0;
 }
 
+static int prune_merged_branches(int argc, const char **argv,
+				 int quiet)
+{
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	struct ref_filter filter = REF_FILTER_INIT;
+	struct ref_array candidates;
+	struct strvec deletable = STRVEC_INIT;
+	int i, ret = 0;
+
+	if (!argc)
+		die(_("--prune-merged requires at least one <branch>"));
+
+	for (i = 0; i < argc; i++)
+		if (ref_filter_forked_add(&filter, argv[i]) < 0)
+			die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+	filter.kind = FILTER_REFS_BRANCHES;
+	memset(&candidates, 0, sizeof(candidates));
+	filter_refs(&candidates, &filter, filter.kind);
+
+	for (i = 0; i < candidates.nr; i++) {
+		const char *full_name = candidates.items[i]->refname;
+		const char *short_name;
+		struct branch *branch;
+		const char *upstream, *push;
+
+		if (!skip_prefix(full_name, "refs/heads/", &short_name))
+			continue;
+		if (branch_checked_out(full_name))
+			continue;
+
+		branch = branch_get(short_name);
+		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+		if (!upstream || !refs_ref_exists(refs, upstream))
+			continue;
+		push = branch ? branch_get_push(branch, NULL) : NULL;
+		if (!push || !strcmp(push, upstream))
+			continue;
+
+		strvec_push(&deletable, short_name);
+	}
+
+	if (deletable.nr)
+		ret = delete_branches(deletable.nr, deletable.v,
+				      FILTER_REFS_BRANCHES,
+				      DELETE_BRANCH_WARN_ONLY |
+				      DELETE_BRANCH_NO_HEAD_FALLBACK |
+				      (quiet ? DELETE_BRANCH_QUIET : 0));
+
+	strvec_clear(&deletable);
+	ref_array_clear(&candidates);
+	ref_filter_clear(&filter);
+	return ret;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -756,6 +812,7 @@ int cmd_branch(int argc,
 	/* possible actions */
 	int delete = 0, rename = 0, copy = 0, list = 0,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
+	int prune_merged = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -809,6 +866,8 @@ int cmd_branch(int argc,
 		OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
 		OPT_BOOL(0, "edit-description", &edit_description,
 			 N_("edit the description for the branch")),
+		OPT_BOOL(0, "prune-merged", &prune_merged,
+			N_("delete local branches whose upstream matches <branch> and is merged")),
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -856,7 +915,8 @@ int cmd_branch(int argc,
 			     0);
 
 	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
-	    !show_current && !unset_upstream && argc == 0)
+	    !show_current && !unset_upstream && !prune_merged &&
+	    argc == 0)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
@@ -866,7 +926,7 @@ int cmd_branch(int argc,
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
 			    !!show_current + !!list + !!edit_description +
-			    !!unset_upstream;
+			    !!unset_upstream + !!prune_merged;
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
@@ -908,6 +968,9 @@ int cmd_branch(int argc,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
 				      (quiet ? DELETE_BRANCH_QUIET : 0));
 		goto out;
+	} else if (prune_merged) {
+		ret = prune_merged_branches(argc, argv, quiet);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..27ea1319bb 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
 	test_grep "requires a value" err
 '
 
+test_expect_success '--prune-merged: setup' '
+	test_create_repo pm-upstream &&
+	test_commit -C pm-upstream base &&
+	git -C pm-upstream checkout -b next &&
+	test_commit -C pm-upstream one-commit &&
+	test_commit -C pm-upstream two-commit &&
+	git -C pm-upstream branch one HEAD~ &&
+	git -C pm-upstream branch two HEAD &&
+	git -C pm-upstream branch wip main &&
+	git -C pm-upstream checkout main &&
+	test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+	test_when_finished "rm -rf pm-merged" &&
+	git clone pm-upstream pm-merged &&
+	git -C pm-merged remote add fork ../pm-fork &&
+	test_config -C pm-merged remote.pushDefault fork &&
+	test_config -C pm-merged push.default current &&
+	git -C pm-merged branch one one-commit &&
+	git -C pm-merged branch --set-upstream-to=origin/next one &&
+	git -C pm-merged branch two two-commit &&
+	git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+	git -C pm-merged branch --prune-merged "origin/*" &&
+
+	test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+	test_when_finished "rm -rf pm-literal" &&
+	git clone pm-upstream pm-literal &&
+	git -C pm-literal remote add fork ../pm-fork &&
+	test_config -C pm-literal remote.pushDefault fork &&
+	test_config -C pm-literal push.default current &&
+	git -C pm-literal branch one one-commit &&
+	git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+	git -C pm-literal branch --prune-merged origin/next &&
+
+	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+	test_when_finished "rm -rf pm-union" &&
+	git clone pm-upstream pm-union &&
+	git -C pm-union remote add fork ../pm-fork &&
+	test_config -C pm-union remote.pushDefault fork &&
+	test_config -C pm-union push.default current &&
+	git -C pm-union branch one one-commit &&
+	git -C pm-union branch --set-upstream-to=origin/next one &&
+	git -C pm-union branch two base &&
+	git -C pm-union branch --set-upstream-to=origin/main two &&
+	git -C pm-union checkout --detach &&
+
+	git -C pm-union branch --prune-merged origin/next origin/main &&
+
+	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+	test_when_finished "rm -rf pm-local" &&
+	git clone pm-upstream pm-local &&
+	git -C pm-local remote add fork ../pm-fork &&
+	test_config -C pm-local remote.pushDefault fork &&
+	test_config -C pm-local push.default current &&
+	git -C pm-local checkout -b trunk &&
+	git -C pm-local branch one one-commit &&
+	git -C pm-local branch --set-upstream-to=trunk one &&
+	git -C pm-local merge --ff-only one-commit &&
+
+	git -C pm-local branch --prune-merged trunk &&
+
+	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+	test_when_finished "rm -rf pm-unmerged" &&
+	git clone pm-upstream pm-unmerged &&
+	git -C pm-unmerged remote add fork ../pm-fork &&
+	test_config -C pm-unmerged remote.pushDefault fork &&
+	test_config -C pm-unmerged push.default current &&
+	git -C pm-unmerged checkout -b wip origin/wip &&
+	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-unmerged local-only &&
+	git -C pm-unmerged checkout - &&
+
+	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+	test_grep "not fully merged" err &&
+	test_grep ! "If you are sure you want to delete it" err &&
+	git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+	test_when_finished "rm -rf pm-nohead" &&
+	git clone pm-upstream pm-nohead &&
+	git -C pm-nohead remote add fork ../pm-fork &&
+	test_config -C pm-nohead remote.pushDefault fork &&
+	test_config -C pm-nohead push.default current &&
+	git -C pm-nohead branch topic one-commit &&
+	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+	test_grep ! "not yet merged to HEAD" err &&
+	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+	test_when_finished "rm -rf pm-upstream-gone" &&
+	git clone pm-upstream pm-upstream-gone &&
+	git -C pm-upstream-gone remote add fork ../pm-fork &&
+	test_config -C pm-upstream-gone remote.pushDefault fork &&
+	test_config -C pm-upstream-gone push.default current &&
+	git -C pm-upstream-gone branch one one-commit &&
+	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+	git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+	git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+	git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+	test_when_finished "rm -rf pm-head" &&
+	git clone pm-upstream pm-head &&
+	git -C pm-head remote add fork ../pm-fork &&
+	test_config -C pm-head remote.pushDefault fork &&
+	test_config -C pm-head push.default current &&
+	git -C pm-head checkout -b one one-commit &&
+	git -C pm-head branch --set-upstream-to=origin/next one &&
+
+	git -C pm-head branch --prune-merged "origin/*" &&
+
+	git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+	test_when_finished "rm -rf pm-push-eq" &&
+	git clone pm-upstream pm-push-eq &&
+	git -C pm-push-eq checkout --detach &&
+
+	git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+	git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+	test_when_finished "rm -rf pm-push-branch" &&
+	git clone pm-upstream pm-push-branch &&
+	git -C pm-push-branch remote add fork ../pm-fork &&
+	test_config -C pm-push-branch remote.pushDefault fork &&
+	test_config -C pm-push-branch push.default current &&
+	test_config -C pm-push-branch branch.main.pushRemote origin &&
+	git -C pm-push-branch checkout --detach &&
+
+	git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+	git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+	test_when_finished "rm -rf pm-push-diff" &&
+	git clone pm-upstream pm-push-diff &&
+	git -C pm-push-diff remote add fork ../pm-fork &&
+	test_config -C pm-push-diff remote.pushDefault fork &&
+	test_config -C pm-push-diff push.default current &&
+	git -C pm-push-diff branch topic one-commit &&
+	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+	git -C pm-push-diff checkout --detach &&
+
+	git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+	test_must_fail git -C forked branch --prune-merged 2>err &&
+	test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--prune-merged takes positional <branch> arguments' '
+	test_when_finished "rm -rf pm-positional" &&
+	git clone pm-upstream pm-positional &&
+	git -C pm-positional remote add fork ../pm-fork &&
+	test_config -C pm-positional remote.pushDefault fork &&
+	test_config -C pm-positional push.default current &&
+	git -C pm-positional branch one one-commit &&
+	git -C pm-positional branch --set-upstream-to=origin/next one &&
+	git -C pm-positional branch two base &&
+	git -C pm-positional branch --set-upstream-to=origin/main two &&
+	git -C pm-positional checkout --detach &&
+
+	git -C pm-positional branch --prune-merged origin/next origin/main &&
+
+	test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Teach delete_branches() two new modes for the upcoming
--prune-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 28 +++++++++++++++++++++-------
 1 file changed, 21 insertions(+), 7 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index 4fb012c7a4..2cc5a8cde0 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,
 	 * upstream, if any, otherwise with HEAD", we should just
 	 * return the result of the repo_in_merge_bases() above without
 	 * any of the following code, but during the transition period,
-	 * a gentle reminder is in order.
+	 * a gentle reminder is in order.  Callers that opt out of the
+	 * HEAD fallback by passing head_rev=NULL are not interested in
+	 * the reminder either: they have already established that the
+	 * branch has an upstream, so HEAD is irrelevant to the decision.
 	 */
-	if (head_rev != reference_rev) {
-		int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+	if (head_rev && head_rev != reference_rev) {
+		int expect = repo_in_merge_bases(the_repository, rev, head_rev);
 		if (expect < 0)
 			exit(128);
 		if (expect == merged)
@@ -193,6 +196,8 @@ enum delete_branch_flags {
 	DELETE_BRANCH_FORCE = (1 << 0),
 	DELETE_BRANCH_QUIET = (1 << 1),
 	DELETE_BRANCH_WARN_ONLY = (1 << 2),
+	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
+	DELETE_BRANCH_DRY_RUN = (1 << 4),
 };
 
 static int check_branch_commit(const char *branchname, const char *refname,
@@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	int i;
 	int ret = 0;
 	int remote_branch = 0;
-	int force, quiet;
+	int force, quiet, dry_run, no_head_fallback;
 	struct strbuf bname = STRBUF_INIT;
 	enum interpret_branch_kind allowed_interpret;
 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -268,8 +273,10 @@ static int delete_branches(int argc, const char **argv, int kinds,
 
 	force = flags & DELETE_BRANCH_FORCE;
 	quiet = flags & DELETE_BRANCH_QUIET;
+	dry_run = flags & DELETE_BRANCH_DRY_RUN;
+	no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
 
-	if (!force)
+	if (!force && !no_head_fallback)
 		head_rev = lookup_commit_reference(the_repository, &head_oid);
 
 	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ -340,13 +347,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		free(target);
 	}
 
-	if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+	if (!dry_run &&
+	    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
 		ret = 1;
 
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
-		if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+		if (dry_run) {
+			if (!quiet)
+				printf(remote_branch
+					? _("Would delete remote-tracking branch %s (was %s).\n")
+					: _("Would delete branch %s (was %s).\n"),
+					name + branch_name_pos, describe_ref);
+		} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
 			char *refname = name + branch_name_pos;
 			if (!quiet)
 				printf(remote_branch
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Add a warn-only mode to delete_branches() and check_branch_commit()
so a bulk caller can report branches that are not fully merged as a
short warning and carry on, rather than erroring with the longer
"use 'git branch -D'" advice that the plain "git branch -d" path
emits. Existing callers are unaffected.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 builtin/branch.c | 54 +++++++++++++++++++++++++++++++++---------------
 1 file changed, 37 insertions(+), 17 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..4fb012c7a4 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,20 +189,33 @@ static int branch_merged(int kind, const char *name,
 	return merged;
 }
 
+enum delete_branch_flags {
+	DELETE_BRANCH_FORCE = (1 << 0),
+	DELETE_BRANCH_QUIET = (1 << 1),
+	DELETE_BRANCH_WARN_ONLY = (1 << 2),
+};
+
 static int check_branch_commit(const char *branchname, const char *refname,
 			       const struct object_id *oid, struct commit *head_rev,
-			       int kinds, int force)
+			       int kinds, unsigned int flags)
 {
+	int force = flags & DELETE_BRANCH_FORCE;
 	struct commit *rev = lookup_commit_reference(the_repository, oid);
 	if (!force && !rev) {
 		error(_("couldn't look up commit object for '%s'"), refname);
 		return -1;
 	}
 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-		error(_("the branch '%s' is not fully merged"), branchname);
-		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-				  _("If you are sure you want to delete it, "
-				  "run 'git branch -D %s'"), branchname);
+		if (flags & DELETE_BRANCH_WARN_ONLY) {
+			warning(_("the branch '%s' is not fully merged"),
+				branchname);
+		} else {
+			error(_("the branch '%s' is not fully merged"),
+			      branchname);
+			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+					  _("If you are sure you want to delete it, "
+					  "run 'git branch -D %s'"), branchname);
+		}
 		return -1;
 	}
 	return 0;
@@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)
 	strbuf_release(&buf);
 }
 
-static int delete_branches(int argc, const char **argv, int force, int kinds,
-			   int quiet)
+static int delete_branches(int argc, const char **argv, int kinds,
+			   unsigned int flags)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -227,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 	int i;
 	int ret = 0;
 	int remote_branch = 0;
+	int force, quiet;
 	struct strbuf bname = STRBUF_INIT;
 	enum interpret_branch_kind allowed_interpret;
 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -241,7 +255,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 		remote_branch = 1;
 		allowed_interpret = INTERPRET_BRANCH_REMOTE;
 
-		force = 1;
+		flags |= DELETE_BRANCH_FORCE;
 		break;
 	case FILTER_REFS_BRANCHES:
 		fmt = "refs/heads/%s";
@@ -252,12 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 	}
 	branch_name_pos = strcspn(fmt, "%");
 
+	force = flags & DELETE_BRANCH_FORCE;
+	quiet = flags & DELETE_BRANCH_QUIET;
+
 	if (!force)
 		head_rev = lookup_commit_reference(the_repository, &head_oid);
 
 	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
 		char *target = NULL;
-		int flags = 0;
+		int ref_flags = 0;
 
 		copy_branchname(&bname, argv[i], allowed_interpret);
 		free(name);
@@ -279,7 +296,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 					     RESOLVE_REF_READING
 					     | RESOLVE_REF_NO_RECURSE
 					     | RESOLVE_REF_ALLOW_BAD_NAME,
-					     &oid, &flags);
+					     &oid, &ref_flags);
 		if (!target) {
 			if (remote_branch) {
 				error(_("remote-tracking branch '%s' not found"), bname.buf);
@@ -291,7 +308,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 									   | RESOLVE_REF_NO_RECURSE
 									   | RESOLVE_REF_ALLOW_BAD_NAME,
 									   &oid,
-									   &flags);
+									   &ref_flags);
 				FREE_AND_NULL(virtual_name);
 
 				if (virtual_target)
@@ -306,16 +323,17 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 			continue;
 		}
 
-		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
 		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
-					force)) {
-			ret = 1;
+					flags)) {
+			if (!(flags & DELETE_BRANCH_WARN_ONLY))
+				ret = 1;
 			goto next;
 		}
 
 		item = string_list_append(&refs_to_delete, name);
-		item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
-				    : (flags & REF_ISSYMREF) ? target
+		item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
+				    : (ref_flags & REF_ISSYMREF) ? target
 				    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
 
 	next:
@@ -872,7 +890,9 @@ int cmd_branch(int argc,
 	if (delete) {
 		if (!argc)
 			die(_("branch name required"));
-		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+		ret = delete_branches(argc, argv, filter.kind,
+				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+				      (quiet ? DELETE_BRANCH_QUIET : 0));
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.

It is an ordinary list filter, so it combines with the others:

    git branch --merged origin/main --forked 'origin/*'

lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.

This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc | 10 +++-
 builtin/branch.c              | 18 ++++++-
 ref-filter.c                  | 70 ++++++++++++++++++++++++++
 ref-filter.h                  | 10 ++++
 t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++
 5 files changed, 197 insertions(+), 3 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..62ebab6051 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
 	   [--column[=<options>] | --no-column] [--sort=<key>]
 	   [--merged [<commit>]] [--no-merged [<commit>]]
 	   [--contains [<commit>]] [--no-contains [<commit>]]
+	   [(--forked <branch>)...]
 	   [--points-at <object>] [--format=<format>]
 	   [(-r|--remotes) | (-a|--all)]
 	   [--list] [<pattern>...]
@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are
 reachable from the named commit) will be listed.  With `--no-merged` only
 branches not merged into the named commit will be listed.  If the _<commit>_
 argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch).  With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
 
 The command's second form creates a new branch head named _<branch-name>_
 which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,12 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
 	Only list branches whose tips are not reachable from
 	_<commit>_ (`HEAD` if not specified). Implies `--list`.
 
+`--forked <branch>`::
+	Only list branches whose configured upstream matches
+	_<branch>_. The argument can be a ref (e.g. `origin/main`,
+	`master`) or a shell-style glob (e.g. `'origin/*'`). The
+	option can be repeated to widen the filter. Implies `--list`.
+
 `--points-at <object>`::
 	Only list branches of _<object>_.
 
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..c159f45b4c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -30,7 +30,7 @@
 #include "commit-reach.h"
 
 static const char * const builtin_branch_usage[] = {
-	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
 	N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
 	N_("git branch [<options>] [-l] [<pattern>...]"),
 	N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
+{
+	struct ref_filter *filter = opt->value;
+
+	BUG_ON_OPT_NEG(unset);
+	if (ref_filter_forked_add(filter, arg) < 0)
+		die(_("'%s' is not a valid branch or pattern"), arg);
+	return 0;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ int cmd_branch(int argc,
 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
+		OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
+			N_("print only branches whose upstream matches <branch> (repeatable)"),
+			PARSE_OPT_NONEG, parse_opt_forked),
 		OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
 		OPT_REF_SORT(&sorting_options),
 		OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -815,7 +828,8 @@ int cmd_branch(int argc,
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
-	    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+	    filter.reachable_from || filter.unreachable_from ||
+	    filter.points_at.nr || filter.forked.nr)
 		list = 1;
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..1ddd5a3f6d 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
 	return match_pattern(filter->exclude.v, refname, filter->ignore_case);
 }
 
+static const char *short_upstream_name(const char *full_ref)
+{
+	const char *short_name = full_ref;
+	(void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+	       skip_prefix(short_name, "refs/remotes/", &short_name));
+	return short_name;
+}
+
+/*
+ * Match the configured upstream of a branch against the registered
+ * --forked patterns. Exact patterns are compared against the full
+ * upstream refname so they are unambiguous; glob patterns are matched
+ * against the abbreviated upstream so that a glob such as origin/...
+ * works as typed.
+ */
+static int filter_forked_match(struct ref_filter *filter, const char *refname)
+{
+	const char *short_name;
+	struct branch *branch;
+	const char *upstream;
+	int i;
+
+	if (!skip_prefix(refname, "refs/heads/", &short_name))
+		return 0;
+	branch = branch_get(short_name);
+	if (!branch)
+		return 0;
+	upstream = branch_get_upstream(branch, NULL);
+	if (!upstream)
+		return 0;
+
+	for (i = 0; i < filter->forked.nr; i++) {
+		const char *pattern = filter->forked.v[i];
+		if (has_glob_specials(pattern)) {
+			if (!wildmatch(pattern, short_upstream_name(upstream),
+				       WM_PATHNAME))
+				return 1;
+		} else if (!strcmp(pattern, upstream)) {
+			return 1;
+		}
+	}
+	return 0;
+}
+
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
+	struct object_id oid;
+	char *full_ref = NULL;
+
+	if (has_glob_specials(arg)) {
+		strvec_push(&filter->forked, arg);
+		return 0;
+	}
+
+	if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+			  &full_ref, 0) == 1 &&
+	    (starts_with(full_ref, "refs/heads/") ||
+	     starts_with(full_ref, "refs/remotes/"))) {
+		strvec_push(&filter->forked, full_ref);
+		free(full_ref);
+		return 0;
+	}
+	free(full_ref);
+	return -1;
+}
+
 /*
  * We need to seek to the reference right after a given marker but excluding any
  * matching references. So we seek to the lexicographically next reference.
@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
 	if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
 		return NULL;
 
+	if (filter->forked.nr && !filter_forked_match(filter, ref->name))
+		return NULL;
+
 	/*
 	 * A merge filter is applied on refs pointing to commits. Hence
 	 * obtain the commit using the 'oid' available and discard all
@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)
 void ref_filter_clear(struct ref_filter *filter)
 {
 	strvec_clear(&filter->exclude);
+	strvec_clear(&filter->forked);
 	oid_array_clear(&filter->points_at);
 	commit_list_free(filter->with_commit);
 	commit_list_free(filter->no_commit);
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..9361296e2a 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -67,6 +67,7 @@ struct ref_filter {
 	const char **name_patterns;
 	const char *start_after;
 	struct strvec exclude;
+	struct strvec forked;
 	struct oid_array points_at;
 	struct commit_list *with_commit;
 	struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {
 #define REF_FILTER_INIT { \
 	.points_at = OID_ARRAY_INIT, \
 	.exclude = STRVEC_INIT, \
+	.forked = STRVEC_INIT, \
 }
 #define REF_FORMAT_INIT {             \
 	.use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
 struct ref_sorting *ref_sorting_options(struct string_list *);
 /*  Function to parse --merged and --no-merged options */
 int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
+/*
+ * Register a --forked <branch> pattern on the filter. The argument is
+ * either a ref, which is resolved to its full refname, or a shell-style
+ * glob. Branches are kept only when their configured upstream matches
+ * one of the registered patterns. Returns -1 if the argument is not a
+ * valid ref or pattern.
+ */
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
 /*  Get the current HEAD's description */
 char *get_head_description(void);
 /*  Set up translated strings in the output. */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
 	test_cmp expect actual
 '
 
+test_expect_success '--forked: setup' '
+	test_create_repo forked-upstream &&
+	test_commit -C forked-upstream base &&
+	git -C forked-upstream branch one base &&
+	git -C forked-upstream branch two base &&
+
+	test_create_repo forked-other &&
+	test_commit -C forked-other other-base &&
+	git -C forked-other branch foreign other-base &&
+
+	git clone forked-upstream forked &&
+	git -C forked remote add other ../forked-other &&
+	git -C forked fetch other &&
+	git -C forked branch local-base &&
+	git -C forked branch --track local-one origin/one &&
+	git -C forked branch --track local-two origin/two &&
+	git -C forked branch --track local-foreign other/foreign &&
+	git -C forked branch detached &&
+	git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+	git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+	git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+	git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+	echo local-trunk >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+	git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+	git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-trunk
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+	git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+	test_when_finished "git -C forked checkout detached" &&
+	git -C forked checkout local-one &&
+	test_commit -C forked local-only &&
+	git -C forked branch --forked "origin/*" --no-merged origin/one \
+		--format="%(refname:short)" >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+	test_must_fail git -C forked branch --forked nope 2>err &&
+	test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+	test_must_fail git -C forked branch --forked 2>err &&
+	test_grep "requires a value" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>

 * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
   check_branch_commit() reads, so it wrongly ran the merge check.
 * Made flags the single source of truth in delete_branches() so the bit and
   the derived locals can't disagree.
 * Works locally, but GitHub CI has problems that are there for other
   branches too, hopefully not related
   (https://github.com/git/git/pull/2285).

Harald Nordgren (6):
  branch: add --forked filter for --list mode
  branch: let delete_branches warn instead of error on bulk refusal
  branch: prepare delete_branches for a bulk caller
  branch: add --prune-merged <branch>
  branch: add branch.<name>.pruneMerged opt-out
  branch: add --dry-run for --prune-merged

 Documentation/config/branch.adoc |   7 +
 Documentation/git-branch.adoc    |  41 +++-
 builtin/branch.c                 | 186 +++++++++++++---
 ref-filter.c                     |  70 ++++++
 ref-filter.h                     |  10 +
 t/t3200-branch.sh                | 367 +++++++++++++++++++++++++++++++
 6 files changed, 653 insertions(+), 28 deletions(-)


base-commit: 600fe743028cbfb640855f659e9851522214bc0b
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v14
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v14
Pull-Request: https://github.com/git/git/pull/2285

Range-diff vs v13:

 1:  ccd07cff25 = 1:  7383872f4b branch: add --forked filter for --list mode
 2:  a7672713f6 ! 2:  7ef9502e01 branch: let delete_branches warn instead of error on bulk refusal
     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
       	int i;
       	int ret = 0;
       	int remote_branch = 0;
     -+	int force = flags & DELETE_BRANCH_FORCE;
     -+	int quiet = flags & DELETE_BRANCH_QUIET;
     ++	int force, quiet;
       	struct strbuf bname = STRBUF_INIT;
       	enum interpret_branch_kind allowed_interpret;
       	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 		remote_branch = 1;
     + 		allowed_interpret = INTERPRET_BRANCH_REMOTE;
     + 
     +-		force = 1;
     ++		flags |= DELETE_BRANCH_FORCE;
     + 		break;
     + 	case FILTER_REFS_BRANCHES:
     + 		fmt = "refs/heads/%s";
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 	}
     + 	branch_name_pos = strcspn(fmt, "%");
     + 
     ++	force = flags & DELETE_BRANCH_FORCE;
     ++	quiet = flags & DELETE_BRANCH_QUIET;
     ++
     + 	if (!force)
     + 		head_rev = lookup_commit_reference(the_repository, &head_oid);
       
       	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
       		char *target = NULL;
 3:  5ee7643d3a ! 3:  259113e304 branch: prepare delete_branches for a bulk caller
     @@ builtin/branch.c: enum delete_branch_flags {
       
       static int check_branch_commit(const char *branchname, const char *refname,
      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
     + 	int i;
     + 	int ret = 0;
       	int remote_branch = 0;
     - 	int force = flags & DELETE_BRANCH_FORCE;
     - 	int quiet = flags & DELETE_BRANCH_QUIET;
     -+	int dry_run = flags & DELETE_BRANCH_DRY_RUN;
     -+	int no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
     +-	int force, quiet;
     ++	int force, quiet, dry_run, no_head_fallback;
       	struct strbuf bname = STRBUF_INIT;
       	enum interpret_branch_kind allowed_interpret;
       	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
     - 	}
     - 	branch_name_pos = strcspn(fmt, "%");
     + 
     + 	force = flags & DELETE_BRANCH_FORCE;
     + 	quiet = flags & DELETE_BRANCH_QUIET;
     ++	dry_run = flags & DELETE_BRANCH_DRY_RUN;
     ++	no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
       
      -	if (!force)
      +	if (!force && !no_head_fallback)
 4:  5f913c445c = 4:  9924373da0 branch: add --prune-merged <branch>
 5:  8e9a735ffe = 5:  d691d5051b branch: add branch.<name>.pruneMerged opt-out
 6:  511de4788e = 6:  ede8c61729 branch: add --dry-run for --prune-merged

-- 
gitgitgadget

^ permalink raw reply

* Re: [PATCH] worktree: record creation time and free-form note
From: Phillip Wood @ 2026-06-09 10:07 UTC (permalink / raw)
  To: Kiesel, Norbert, git, Junio C Hamano
  Cc: phillip.wood, Chris Torek, kristofferhaugsbakk
In-Reply-To: <CAPGaHkv=p62gLwkufc6TWjJR3OdV+DYdmWUZ6Xn0-qgHsw5_4Q@mail.gmail.com>

Hi Norbert

On 08/06/2026 17:12, Kiesel, Norbert wrote:
> Hi team,
> I updated my proposed extension in a couple of ways you suggested, and
> also added some more test code.

It would be much easier to comment on these changes if they were split 
out into three separate patches (adding the description, adding the 
creation time and adding sorting) with commit messages that explained 
the motivation for each change.

Without a descripion of why each change is useful it is hard to comment 
further. At this stage explaining why these changes are useful is much 
more important than the code itself so that could be done without any 
patches.

Thanks

Phillip

> Best,
>    Norbert
> 
> diff --git Documentation/git-worktree.adoc Documentation/git-worktree.adoc
> index fbf8426cd9..1cdbdc8dbe 100644
> --- Documentation/git-worktree.adoc
> +++ Documentation/git-worktree.adoc
> @@ -10,8 +10,11 @@ SYNOPSIS
>   --------
>   [synopsis]
>   git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
> + [--description <string>]
>    [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
> -git worktree list [-v | --porcelain [-z]]
> +git worktree describe <worktree> [<description>]
> +git worktree list [-v | --porcelain [-z]] [--show-created]
> + [--show-updated] [--show-description] [--sort=<key>]
>   git worktree lock [--reason <string>] <worktree>
>   git worktree move <worktree> <new-path>
>   git worktree prune [-n] [-v] [--expire <expire>]
> @@ -106,6 +109,16 @@ passed to the command. In the event the
> repository has a remote and
>   command fails with a warning reminding the user to fetch from their remote
>   first (or override by using `-f`/`--force`).
> 
> +`describe <worktree> [<description>]`::
> +
> +Set, replace, or clear a free-form description on a linked worktree.
> +Useful for recording what a worktree was created for so it can be identified
> +later. With _<description>_, the worktree's description is set or replaced;
> +without a description argument, the existing description is cleared. The
> +description for a worktree may also be set at creation time with
> +`git worktree add --description <description>`. The main worktree cannot be
> +described.
> +
>   `list`::
> 
>   List details of each worktree.  The main worktree is listed first,
> @@ -114,6 +127,28 @@ whether the worktree is bare, the revision
> currently checked out, the
>   branch currently checked out (or "detached HEAD" if none), "locked" if
>   the worktree is locked, "prunable" if the worktree can be pruned by the
>   `prune` command.
> ++
> +Each worktree's creation timestamp is recorded when it is created with
> +`git worktree add`. Worktrees created before this feature existed have no
> +recorded creation timestamp; for them, `list` reports `created: unknown`
> +in human output and omits the `created` line in `--porcelain` output. Pass
> +`--show-created` to include creation timestamps in human output. Worktrees
> +without a recorded timestamp sort last (or first when reversed) with
> +`--sort=created`.
> ++
> +Pass `--show-updated` to include each worktree's last-updated timestamp,
> +which is the modification time of the worktree's `HEAD` file and so
> +reflects checkouts, commits, resets, rebases, and similar Git operations.
> ++
> +Pass `--show-description` to include any user-provided description in human
> +output. In `--porcelain` output, the `created`, `updated`, and
> +`description` lines are emitted whenever the underlying data is available.
> ++
> +Use `--sort=<key>` (where _<key>_ is `path`, `created`, or `updated`,
> +optionally prefixed with `-` to reverse) to order the linked worktrees;
> +the main worktree always remains first. Sorting by `created` or `updated`
> +implies the matching `--show-created` / `--show-updated` flag so the order
> +is visible alongside the data.
> 
>   `lock`::
> 
> @@ -286,6 +321,46 @@ _<time>_.
>    With `lock` or with `add --lock`, an explanation why the worktree
>    is locked.
> 
> +`--description <string>`::
> + With `add`, attach a free-form description to the new worktree.
> + The description is stored alongside the worktree's administrative
> + files and can be displayed with `git worktree list --show-description`
> + or in `--porcelain` output. It can be changed later with
> + `git worktree describe`.
> +
> +`--show-created`::
> + With `list`, include each worktree's creation timestamp in the
> + human-readable output. Worktrees with no recorded creation time are
> + shown as `created: unknown`. In `--porcelain` output, the creation
> + timestamp is always included (when available) on a `created` line.
> +
> +`--show-updated`::
> + With `list`, include each linked worktree's last-updated timestamp in
> + the human-readable output, derived from the modification time of the
> + worktree's `HEAD` file. Linked worktrees whose `HEAD` cannot be read
> + are shown as `updated: unknown`. The main worktree is not annotated
> + with an updated timestamp. In `--porcelain` output, the timestamp is
> + included on an `updated` line whenever it is available (and the
> + worktree is not the main worktree).
> +
> +`--show-description`::
> + With `list`, include each worktree's description (if set) in the
> + human-readable output. In `--porcelain` output, the description is
> + always included (when set) on a `description` line.
> +
> +`--sort=<key>`::
> + With `list`, sort linked worktrees by _<key>_, which is one of
> + `path`, `created`, or `updated`. Prefix with `-` to reverse the order,
> + e.g. `--sort=-created` lists newest first. The main worktree is always
> + listed first regardless of sort order. For `created`, worktrees with no
> + recorded creation timestamp sort after those that have one (or before,
> + when reversed). For `updated`, ordering is by the modification time of
> + each worktree's `HEAD` file (a proxy for when the worktree was last
> + touched by checkout, commit, reset or rebase); worktrees whose `HEAD`
> + cannot be read sort after those that can. Sorting by `created` or
> + `updated` implies the matching `--show-created` / `--show-updated`
> + option so the values driving the order appear in human output.
> +
>   _<worktree>_::
>    Worktrees can be identified by path, either relative or absolute.
>   +
> @@ -462,7 +537,10 @@ are terminated with NUL rather than a newline.
> Attributes are listed with a
>   label and value separated by a single space.  Boolean attributes (like `bare`
>   and `detached`) are listed as a label only, and are present only
>   if the value is true.  Some attributes (like `locked`) can be listed as a label
> -only or with a value depending upon whether a reason is available.  The first
> +only or with a value depending upon whether a reason is available.  Optional
> +valued attributes (like `created`, `updated`, and `description`) appear
> +only when the corresponding metadata has been recorded for that worktree.
> +The first
>   attribute of a worktree is always `worktree`, an empty line indicates the
>   end of the record.  For example:
> 
> @@ -474,10 +552,15 @@ bare
>   worktree /path/to/linked-worktree
>   HEAD abcd1234abcd1234abcd1234abcd1234abcd1234
>   branch refs/heads/master
> +created 2026-06-01T12:34:56Z
> +updated 2026-06-04T17:20:11Z
> +description investigating login bug
> 
>   worktree /path/to/other-linked-worktree
>   HEAD 1234abc1234abc1234abc1234abc1234abc1234a
>   detached
> +created 2026-05-28T08:15:00Z
> +updated 2026-05-30T09:42:08Z
> 
>   worktree /path/to/linked-worktree-locked-no-reason
>   HEAD 5678abc5678abc5678abc5678abc5678abc5678c
> diff --git builtin/worktree.c builtin/worktree.c
> index d21c43fde3..132de668e3 100644
> --- builtin/worktree.c
> +++ builtin/worktree.c
> @@ -27,13 +27,17 @@
>   #include "utf8.h"
>   #include "worktree.h"
>   #include "quote.h"
> +#include "date.h"
> 
>   #define BUILTIN_WORKTREE_ADD_USAGE \
>    N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason
> <string>]]\n" \
> +    "                 [--description <string>]\n" \
>       "                 [--orphan] [(-b | -B) <new-branch>] <path>
> [<commit-ish>]")
> 
>   #define BUILTIN_WORKTREE_LIST_USAGE \
> - N_("git worktree list [-v | --porcelain [-z]]")
> + N_("git worktree list [-v | --porcelain [-z]] [--show-created]\n" \
> +    "                  [--show-updated] [--show-description]\n" \
> +    "                  [--sort=<key>]")
>   #define BUILTIN_WORKTREE_LOCK_USAGE \
>    N_("git worktree lock [--reason <string>] <worktree>")
>   #define BUILTIN_WORKTREE_MOVE_USAGE \
> @@ -46,6 +50,8 @@
>    N_("git worktree repair [<path>...]")
>   #define BUILTIN_WORKTREE_UNLOCK_USAGE \
>    N_("git worktree unlock <worktree>")
> +#define BUILTIN_WORKTREE_DESCRIBE_USAGE \
> + N_("git worktree describe <worktree> [<description>]")
> 
>   #define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
>    _("No possible source branch, inferring '--orphan'")
> @@ -66,6 +72,7 @@
> 
>   static const char * const git_worktree_usage[] = {
>    BUILTIN_WORKTREE_ADD_USAGE,
> + BUILTIN_WORKTREE_DESCRIBE_USAGE,
>    BUILTIN_WORKTREE_LIST_USAGE,
>    BUILTIN_WORKTREE_LOCK_USAGE,
>    BUILTIN_WORKTREE_MOVE_USAGE,
> @@ -116,6 +123,11 @@ static const char * const git_worktree_unlock_usage[] = {
>    NULL
>   };
> 
> +static const char * const git_worktree_describe_usage[] = {
> + BUILTIN_WORKTREE_DESCRIBE_USAGE,
> + NULL
> +};
> +
>   struct add_opts {
>    int force;
>    int detach;
> @@ -124,6 +136,7 @@ struct add_opts {
>    int orphan;
>    int relative_paths;
>    const char *keep_locked;
> + const char *description;
>   };
> 
>   static int show_only;
> @@ -131,6 +144,9 @@ static int verbose;
>   static int guess_remote;
>   static int use_relative_paths;
>   static timestamp_t expire;
> +static int show_created = -1;
> +static int show_updated = -1;
> +static int show_description;
> 
>   static int git_worktree_config(const char *var, const char *value,
>           const struct config_context *ctx, void *cb)
> @@ -544,6 +560,16 @@ static int add_worktree(const char *path, const
> char *refname,
>    strbuf_addf(&sb, "%s/commondir", sb_repo.buf);
>    write_file(sb.buf, "../..");
> 
> + strbuf_reset(&sb);
> + strbuf_addf(&sb, "%s/created", sb_repo.buf);
> + write_file(sb.buf, "%"PRItime, (timestamp_t) time(NULL));
> +
> + if (opts->description && *opts->description) {
> + strbuf_reset(&sb);
> + strbuf_addf(&sb, "%s/description", sb_repo.buf);
> + write_file(sb.buf, "%s", opts->description);
> + }
> +
>    /*
>    * Set up the ref store of the worktree and create the HEAD reference.
>    */
> @@ -815,6 +841,8 @@ static int add(int ac, const char **av, const char *prefix,
>    OPT_BOOL(0, "lock", &keep_locked, N_("keep the new working tree locked")),
>    OPT_STRING(0, "reason", &lock_reason, N_("string"),
>       N_("reason for locking")),
> + OPT_STRING(0, "description", &opts.description, N_("string"),
> +    N_("attach a free-form description to the worktree")),
>    OPT__QUIET(&opts.quiet, N_("suppress progress reporting")),
>    OPT_PASSTHRU(0, "track", &opt_track, NULL,
>         N_("set up tracking mode (see git-branch(1))"),
> @@ -963,6 +991,8 @@ static int add(int ac, const char **av, const char *prefix,
>   static void show_worktree_porcelain(struct worktree *wt, int line_terminator)
>   {
>    const char *reason;
> + const char *description;
> + timestamp_t created;
> 
>    printf("worktree %s%c", wt->path, line_terminator);
>    if (wt->is_bare)
> @@ -975,6 +1005,26 @@ static void show_worktree_porcelain(struct
> worktree *wt, int line_terminator)
>    printf("branch %s%c", wt->head_ref, line_terminator);
>    }
> 
> + created = worktree_created_at(wt);
> + if (created)
> + printf("created %s%c",
> +        show_date(created, 0, DATE_MODE(ISO8601_STRICT)),
> +        line_terminator);
> +
> + {
> + timestamp_t updated = worktree_updated_at(wt);
> + if (updated)
> + printf("updated %s%c",
> +        show_date(updated, 0, DATE_MODE(ISO8601_STRICT)),
> +        line_terminator);
> + }
> +
> + description = worktree_description(wt);
> + if (description && *description) {
> + fputs("description ", stdout);
> + write_name_quoted(description, stdout, line_terminator);
> + }
> +
>    reason = worktree_lock_reason(wt);
>    if (reason) {
>    fputs("locked", stdout);
> @@ -1034,6 +1084,32 @@ static void show_worktree(struct worktree *wt,
> struct worktree_display *display,
>    else if (reason)
>    strbuf_addstr(&sb, " prunable");
> 
> + if (show_created > 0 || verbose) {
> + timestamp_t created = worktree_created_at(wt);
> + struct date_mode mode = { .type = DATE_ISO8601, .local = 1 };
> + if (created)
> + strbuf_addf(&sb, "\n\tcreated: %s",
> +     show_date(created, 0, mode));
> + else if (show_created > 0 && !is_main_worktree(wt))
> + strbuf_addstr(&sb, "\n\tcreated: unknown");
> + }
> +
> + if (show_updated > 0 || verbose) {
> + timestamp_t updated = worktree_updated_at(wt);
> + struct date_mode mode = { .type = DATE_ISO8601, .local = 1 };
> + if (updated)
> + strbuf_addf(&sb, "\n\tupdated: %s",
> +     show_date(updated, 0, mode));
> + else if (show_updated > 0 && !is_main_worktree(wt))
> + strbuf_addstr(&sb, "\n\tupdated: unknown");
> + }
> +
> + if (show_description || verbose) {
> + const char *description = worktree_description(wt);
> + if (description && *description)
> + strbuf_addf(&sb, "\n\tdescription: %s", description);
> + }
> +
>    printf("%s\n", sb.buf);
>    strbuf_release(&sb);
>   }
> @@ -1068,6 +1144,48 @@ static int pathcmp(const void *a_, const void *b_)
>    return fspathcmp((*a)->path, (*b)->path);
>   }
> 
> +static int createdcmp(const void *a_, const void *b_)
> +{
> + struct worktree *const *a = a_;
> + struct worktree *const *b = b_;
> + timestamp_t ta = worktree_created_at(*a);
> + timestamp_t tb = worktree_created_at(*b);
> +
> + /* Worktrees without a recorded timestamp (legacy) sort after those
> with one. */
> + if (!ta && !tb)
> + return fspathcmp((*a)->path, (*b)->path);
> + if (!ta)
> + return 1;
> + if (!tb)
> + return -1;
> + if (ta < tb)
> + return -1;
> + if (ta > tb)
> + return 1;
> + return 0;
> +}
> +
> +static int updatedcmp(const void *a_, const void *b_)
> +{
> + struct worktree *const *a = a_;
> + struct worktree *const *b = b_;
> + timestamp_t ta = worktree_updated_at(*a);
> + timestamp_t tb = worktree_updated_at(*b);
> +
> + /* Worktrees whose HEAD mtime can't be read sort after those that can. */
> + if (!ta && !tb)
> + return fspathcmp((*a)->path, (*b)->path);
> + if (!ta)
> + return 1;
> + if (!tb)
> + return -1;
> + if (ta < tb)
> + return -1;
> + if (ta > tb)
> + return 1;
> + return 0;
> +}
> +
>   static void pathsort(struct worktree **wt)
>   {
>    int n = 0;
> @@ -1078,11 +1196,45 @@ static void pathsort(struct worktree **wt)
>    QSORT(wt, n, pathcmp);
>   }
> 
> +static int sort_worktrees(struct worktree **wt, const char *key)
> +{
> + int n = 0, reverse = 0;
> + struct worktree **p = wt;
> + int (*cmp)(const void *, const void *);
> +
> + if (*key == '-') {
> + reverse = 1;
> + key++;
> + }
> + if (!strcmp(key, "path"))
> + cmp = pathcmp;
> + else if (!strcmp(key, "created"))
> + cmp = createdcmp;
> + else if (!strcmp(key, "updated"))
> + cmp = updatedcmp;
> + else
> + return -1;
> +
> + while (*p++)
> + n++;
> + QSORT(wt, n, cmp);
> + if (reverse) {
> + int i;
> + for (i = 0; i < n / 2; i++) {
> + struct worktree *tmp = wt[i];
> + wt[i] = wt[n - 1 - i];
> + wt[n - 1 - i] = tmp;
> + }
> + }
> + return 0;
> +}
> +
>   static int list(int ac, const char **av, const char *prefix,
>    struct repository *repo UNUSED)
>   {
>    int porcelain = 0;
>    int line_terminator = '\n';
> + const char *sort_key = NULL;
> 
>    struct option options[] = {
>    OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
> @@ -1091,6 +1243,14 @@ static int list(int ac, const char **av, const
> char *prefix,
>    N_("add 'prunable' annotation to missing worktrees older than <time>")),
>    OPT_SET_INT('z', NULL, &line_terminator,
>        N_("terminate records with a NUL character"), '\0'),
> + OPT_BOOL(0, "show-created", &show_created,
> + N_("show worktree creation timestamps")),
> + OPT_BOOL(0, "show-updated", &show_updated,
> + N_("show worktree last-updated timestamps")),
> + OPT_BOOL(0, "show-description", &show_description,
> + N_("show worktree descriptions")),
> + OPT_STRING(0, "sort", &sort_key, N_("key"),
> +    N_("sort worktrees by key (path, created, updated); prefix with -
> to reverse")),
>    OPT_END()
>    };
> 
> @@ -1107,8 +1267,27 @@ static int list(int ac, const char **av, const
> char *prefix,
>    int path_maxwidth = 0, abbrev = DEFAULT_ABBREV, i;
>    struct worktree_display *display = NULL;
> 
> - /* sort worktrees by path but keep main worktree at top */
> - pathsort(worktrees + 1);
> + /* sort worktrees but keep main worktree at top */
> + if (sort_key) {
> + const char *bare_key = sort_key;
> + if (*bare_key == '-')
> + bare_key++;
> + /*
> + * Sorting by a timestamp without showing it would
> + * leave the user guessing why the order is what it
> + * is, so opt in the matching display by default.
> + * An explicit --show-* / --no-show-* still wins.
> + */
> + if (!strcmp(bare_key, "created") && show_created < 0)
> + show_created = 1;
> + else if (!strcmp(bare_key, "updated") && show_updated < 0)
> + show_updated = 1;
> +
> + if (sort_worktrees(worktrees + 1, sort_key))
> + die(_("unknown sort key '%s'"), sort_key);
> + } else {
> + pathsort(worktrees + 1);
> + }
> 
>    if (!porcelain)
>    measure_widths(worktrees, &abbrev,
> @@ -1200,6 +1379,32 @@ static int unlock_worktree(int ac, const char
> **av, const char *prefix,
>    return ret;
>   }
> 
> +static int describe_worktree(int ac, const char **av, const char *prefix,
> +      struct repository *repo UNUSED)
> +{
> + struct option options[] = {
> + OPT_END()
> + };
> + struct worktree **worktrees, *wt;
> + int ret;
> +
> + ac = parse_options(ac, av, prefix, options, git_worktree_describe_usage, 0);
> + if (ac < 1 || ac > 2)
> + usage_with_options(git_worktree_describe_usage, options);
> +
> + worktrees = get_worktrees();
> + wt = find_worktree(worktrees, prefix, av[0]);
> + if (!wt)
> + die(_("'%s' is not a working tree"), av[0]);
> + if (is_main_worktree(wt))
> + die(_("The main working tree cannot be described"));
> +
> + ret = set_worktree_description(wt, ac == 2 ? av[1] : NULL);
> +
> + free_worktrees(worktrees);
> + return ret;
> +}
> +
>   static void validate_no_submodules(const struct worktree *wt)
>   {
>    struct index_state istate = INDEX_STATE_INIT(the_repository);
> @@ -1469,6 +1674,7 @@ int cmd_worktree(int ac,
>    parse_opt_subcommand_fn *fn = NULL;
>    struct option options[] = {
>    OPT_SUBCOMMAND("add", &fn, add),
> + OPT_SUBCOMMAND("describe", &fn, describe_worktree),
>    OPT_SUBCOMMAND("prune", &fn, prune),
>    OPT_SUBCOMMAND("list", &fn, list),
>    OPT_SUBCOMMAND("lock", &fn, lock_worktree),
> diff --git t/meson.build t/meson.build
> index 2af8d01279..7b6e8435d7 100644
> --- t/meson.build
> +++ t/meson.build
> @@ -308,6 +308,7 @@ integration_tests = [
>     't2405-worktree-submodule.sh',
>     't2406-worktree-repair.sh',
>     't2407-worktree-heads.sh',
> +  't2410-worktree-metadata.sh',
>     't2500-untracked-overwriting.sh',
>     't2501-cwd-empty.sh',
>     't3000-ls-files-others.sh',
> diff --git t/t2402-worktree-list.sh t/t2402-worktree-list.sh
> index e0c6abd2f5..fb1f4b1d3c 100755
> --- t/t2402-worktree-list.sh
> +++ t/t2402-worktree-list.sh
> @@ -71,7 +71,8 @@ test_expect_success '"list" all worktrees --porcelain' '
>    echo "HEAD $(git rev-parse HEAD)" >>expect &&
>    echo "detached" >>expect &&
>    echo >>expect &&
> - git worktree list --porcelain >actual &&
> + git worktree list --porcelain >actual.raw &&
> + grep -Ev "^(created|updated) " actual.raw >actual &&
>    test_cmp expect actual
>   '
> 
> @@ -86,7 +87,7 @@ test_expect_success '"list" all worktrees --porcelain -z' '
>    "$(git -C here rev-parse --show-toplevel)" \
>    "$(git rev-parse HEAD)" >>expect &&
>    git worktree list --porcelain -z >_actual &&
> - nul_to_q <_actual >actual &&
> + nul_to_q <_actual | tr Q "\n" | grep -Ev "^(created|updated) " | tr
> "\n" Q >actual &&
>    test_cmp expect actual
>   '
> 
> @@ -220,7 +221,7 @@ test_expect_success '"list" all worktrees from bare main' '
>   '
> 
>   test_expect_success '"list" all worktrees --porcelain from bare main' '
> - test_when_finished "rm -rf there actual expect && git -C bare1
> worktree prune" &&
> + test_when_finished "rm -rf there actual actual.raw expect && git -C
> bare1 worktree prune" &&
>    git -C bare1 worktree add --detach ../there main &&
>    echo "worktree $(pwd)/bare1" >expect &&
>    echo "bare" >>expect &&
> @@ -229,7 +230,8 @@ test_expect_success '"list" all worktrees
> --porcelain from bare main' '
>    echo "HEAD $(git -C there rev-parse HEAD)" >>expect &&
>    echo "detached" >>expect &&
>    echo >>expect &&
> - git -C bare1 worktree list --porcelain >actual &&
> + git -C bare1 worktree list --porcelain >actual.raw &&
> + grep -Ev "^(created|updated) " actual.raw >actual &&
>    test_cmp expect actual
>   '
> 
> diff --git t/t2410-worktree-metadata.sh t/t2410-worktree-metadata.sh
> new file mode 100755
> index 0000000000..e1ecb1c1bf
> --- /dev/null
> +++ t/t2410-worktree-metadata.sh
> @@ -0,0 +1,245 @@
> +#!/bin/sh
> +
> +test_description='git worktree creation timestamp and description metadata'
> +
> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'setup' '
> + test_commit init
> +'
> +
> +test_expect_success 'add writes created file' '
> + test_when_finished "git worktree remove -f wt1 && git worktree prune" &&
> + git worktree add wt1 &&
> + test_path_is_file .git/worktrees/wt1/created &&
> + # contents should be a positive integer (unix timestamp)
> + created=$(cat .git/worktrees/wt1/created) &&
> + test "$created" -gt 0
> +'
> +
> +test_expect_success 'add --description writes description file' '
> + test_when_finished "git worktree remove -f wt2 && git worktree prune" &&
> + git worktree add --description "investigating bug" wt2 &&
> + test_path_is_file .git/worktrees/wt2/description &&
> + echo "investigating bug" >expect &&
> + test_cmp expect .git/worktrees/wt2/description
> +'
> +
> +test_expect_success 'add without --description does not create
> description file' '
> + test_when_finished "git worktree remove -f wt3 && git worktree prune" &&
> + git worktree add wt3 &&
> + test_path_is_missing .git/worktrees/wt3/description
> +'
> +
> +test_expect_success 'describe sets a description on an existing worktree' '
> + test_when_finished "git worktree remove -f wt4 && git worktree prune" &&
> + git worktree add wt4 &&
> + git worktree describe wt4 "later description" &&
> + echo "later description" >expect &&
> + test_cmp expect .git/worktrees/wt4/description
> +'
> +
> +test_expect_success 'describe replaces an existing description' '
> + test_when_finished "git worktree remove -f wt5 && git worktree prune" &&
> + git worktree add --description "old" wt5 &&
> + git worktree describe wt5 "new" &&
> + echo "new" >expect &&
> + test_cmp expect .git/worktrees/wt5/description
> +'
> +
> +test_expect_success 'describe with no text clears the description' '
> + test_when_finished "git worktree remove -f wt6 && git worktree prune" &&
> + git worktree add --description "to delete" wt6 &&
> + test_path_is_file .git/worktrees/wt6/description &&
> + git worktree describe wt6 &&
> + test_path_is_missing .git/worktrees/wt6/description
> +'
> +
> +test_expect_success 'describe refuses to operate on the main worktree' '
> + test_must_fail git worktree describe . "should fail" 2>err &&
> + grep -i "main working tree" err
> +'
> +
> +test_expect_success 'list --show-description displays description in
> human output' '
> + test_when_finished "git worktree remove -f wt7 && git worktree prune" &&
> + git worktree add --description "release branch" wt7 &&
> + git worktree list --show-description >actual &&
> + grep "description: release branch" actual
> +'
> +
> +test_expect_success 'list --show-created displays created timestamp' '
> + test_when_finished "git worktree remove -f wt8 && git worktree prune" &&
> + git worktree add wt8 &&
> + git worktree list --show-created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --show-created shows unknown for legacy worktrees' '
> + test_when_finished "git worktree remove -f wt9 && git worktree prune" &&
> + git worktree add wt9 &&
> + rm .git/worktrees/wt9/created &&
> + git worktree list --show-created >actual &&
> + grep "created: unknown" actual
> +'
> +
> +test_expect_success 'list --show-updated displays updated timestamp' '
> + test_when_finished "git worktree remove -f wt8u && git worktree prune" &&
> + git worktree add wt8u &&
> + git worktree list --show-updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --porcelain always includes created,
> updated, and description' '
> + test_when_finished "git worktree remove -f wtp && git worktree prune" &&
> + git worktree add --description "porcelain test" wtp &&
> + git worktree list --porcelain >actual &&
> + grep "^created " actual &&
> + grep "^updated " actual &&
> + grep "^description porcelain test" actual
> +'
> +
> +test_expect_success 'list --sort=created orders by creation time' '
> + test_when_finished "git worktree remove -f a && git worktree remove
> -f b && git worktree remove -f c && git worktree prune" &&
> + git worktree add a &&
> + git worktree add b &&
> + git worktree add c &&
> + echo 1000 >.git/worktrees/a/created &&
> + echo 2000 >.git/worktrees/b/created &&
> + echo 3000 >.git/worktrees/c/created &&
> + git worktree list --sort=created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/a$" &&
> + awk "NR==2" linked | grep -q "/b$" &&
> + awk "NR==3" linked | grep -q "/c$"
> +'
> +
> +test_expect_success 'list --sort=-created reverses order' '
> + test_when_finished "git worktree remove -f a && git worktree remove
> -f b && git worktree remove -f c && git worktree prune" &&
> + git worktree add a &&
> + git worktree add b &&
> + git worktree add c &&
> + echo 1000 >.git/worktrees/a/created &&
> + echo 2000 >.git/worktrees/b/created &&
> + echo 3000 >.git/worktrees/c/created &&
> + git worktree list --sort=-created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/c$" &&
> + awk "NR==2" linked | grep -q "/b$" &&
> + awk "NR==3" linked | grep -q "/a$"
> +'
> +
> +test_expect_success 'list --sort=created places legacy worktrees last' '
> + test_when_finished "git worktree remove -f early && git worktree
> remove -f legacy && git worktree prune" &&
> + git worktree add early &&
> + echo 1000 >.git/worktrees/early/created &&
> + git worktree add legacy &&
> + rm .git/worktrees/legacy/created &&
> + git worktree list --sort=created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,3p" >linked &&
> + awk "NR==1" linked | grep -q "/early$" &&
> + awk "NR==2" linked | grep -q "/legacy$"
> +'
> +
> +test_expect_success 'list --sort=updated orders by HEAD mtime' '
> + test_when_finished "git worktree remove -f u1 && git worktree remove
> -f u2 && git worktree remove -f u3 && git worktree prune" &&
> + git worktree add u1 &&
> + git worktree add u2 &&
> + git worktree add u3 &&
> + # Force a known ordering: u2 oldest, u1 middle, u3 newest.
> + test-tool chmtime =1000 .git/worktrees/u2/HEAD &&
> + test-tool chmtime =2000 .git/worktrees/u1/HEAD &&
> + test-tool chmtime =3000 .git/worktrees/u3/HEAD &&
> + git worktree list --sort=updated --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/u2$" &&
> + awk "NR==2" linked | grep -q "/u1$" &&
> + awk "NR==3" linked | grep -q "/u3$"
> +'
> +
> +test_expect_success 'list --sort=-updated reverses order' '
> + test_when_finished "git worktree remove -f u1 && git worktree remove
> -f u2 && git worktree remove -f u3 && git worktree prune" &&
> + git worktree add u1 &&
> + git worktree add u2 &&
> + git worktree add u3 &&
> + test-tool chmtime =1000 .git/worktrees/u2/HEAD &&
> + test-tool chmtime =2000 .git/worktrees/u1/HEAD &&
> + test-tool chmtime =3000 .git/worktrees/u3/HEAD &&
> + git worktree list --sort=-updated --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/u3$" &&
> + awk "NR==2" linked | grep -q "/u1$" &&
> + awk "NR==3" linked | grep -q "/u2$"
> +'
> +
> +test_expect_success 'list --sort=created auto-shows created timestamp' '
> + test_when_finished "git worktree remove -f autoc && git worktree prune" &&
> + git worktree add autoc &&
> + git worktree list --sort=created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --sort=-created auto-shows created timestamp' '
> + test_when_finished "git worktree remove -f autocr && git worktree prune" &&
> + git worktree add autocr &&
> + git worktree list --sort=-created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --sort=updated auto-shows updated timestamp' '
> + test_when_finished "git worktree remove -f autou && git worktree prune" &&
> + git worktree add autou &&
> + git worktree list --sort=updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=-updated auto-shows updated timestamp' '
> + test_when_finished "git worktree remove -f autour && git worktree prune" &&
> + git worktree add autour &&
> + git worktree list --sort=-updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=path does not auto-show timestamps' '
> + test_when_finished "git worktree remove -f autop && git worktree prune" &&
> + git worktree add autop &&
> + git worktree list --sort=path >actual &&
> + ! grep "created: " actual &&
> + ! grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort with unknown key fails' '
> + test_must_fail git worktree list --sort=bogus 2>err &&
> + grep -i "unknown sort key" err
> +'
> +
> +test_expect_success 'list --sort=updated --no-show-updated suppresses
> auto-show' '
> + test_when_finished "git worktree remove -f noshowu && git worktree prune" &&
> + git worktree add noshowu &&
> + git worktree list --sort=updated --no-show-updated >actual &&
> + ! grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=created --no-show-created suppresses
> auto-show' '
> + test_when_finished "git worktree remove -f noshowc && git worktree prune" &&
> + git worktree add noshowc &&
> + git worktree list --sort=created --no-show-created >actual &&
> + ! grep "created: " actual
> +'
> +
> +test_expect_success 'list --show-updated formats human output in
> local timezone' '
> + test_when_finished "git worktree remove -f tz && git worktree prune" &&
> + git worktree add tz &&
> + # Pin HEAD mtime to a fixed unix time outside any DST transition
> + # so the rendered offset is deterministic in PST8PDT (-0700 in July).
> + test-tool chmtime =1500000000 .git/worktrees/tz/HEAD &&
> + TZ=PST8PDT git worktree list --show-updated >human &&
> + grep "updated: 2017-07-13 19:40:00 -0700" human &&
> + # Porcelain stays in UTC ISO-8601 strict form regardless of TZ.
> + TZ=PST8PDT git worktree list --porcelain >porcelain &&
> + grep "^updated 2017-07-14T02:40:00Z$" porcelain
> +'
> +
> +test_done
> diff --git worktree.c worktree.c
> index 97eddc3916..4b019a532b 100644
> --- worktree.c
> +++ worktree.c
> @@ -14,6 +14,8 @@
>   #include "dir.h"
>   #include "wt-status.h"
>   #include "config.h"
> +#include "date.h"
> +#include "wrapper.h"
> 
>   void free_worktree(struct worktree *worktree)
>   {
> @@ -24,6 +26,7 @@ void free_worktree(struct worktree *worktree)
>    free(worktree->head_ref);
>    free(worktree->lock_reason);
>    free(worktree->prune_reason);
> + free(worktree->description);
>    free(worktree);
>   }
> 
> @@ -324,6 +327,100 @@ const char *worktree_lock_reason(struct worktree *wt)
>    return wt->lock_reason;
>   }
> 
> +timestamp_t worktree_created_at(struct worktree *wt)
> +{
> + if (is_main_worktree(wt))
> + return 0;
> +
> + if (!wt->created_at_valid) {
> + struct strbuf path = STRBUF_INIT;
> + struct strbuf buf = STRBUF_INIT;
> +
> + strbuf_addstr(&path, worktree_git_path(wt, "created"));
> + if (file_exists(path.buf) &&
> +     strbuf_read_file(&buf, path.buf, 0) >= 0) {
> + char *end;
> + timestamp_t t;
> + strbuf_trim(&buf);
> + t = parse_timestamp(buf.buf, &end, 10);
> + if (end != buf.buf && *end == '\0')
> + wt->created_at = t;
> + }
> + wt->created_at_valid = 1;
> + strbuf_release(&path);
> + strbuf_release(&buf);
> + }
> +
> + return wt->created_at;
> +}
> +
> +timestamp_t worktree_updated_at(struct worktree *wt)
> +{
> + struct stat st;
> + char *git_dir;
> + char *head_path;
> + timestamp_t result = 0;
> +
> + if (is_main_worktree(wt))
> + return 0;
> +
> + git_dir = get_worktree_git_dir(wt);
> + head_path = xstrfmt("%s/HEAD", git_dir);
> + if (!stat(head_path, &st))
> + result = (timestamp_t) st.st_mtime;
> + free(head_path);
> + free(git_dir);
> + return result;
> +}
> +
> +const char *worktree_description(struct worktree *wt)
> +{
> + if (is_main_worktree(wt))
> + return NULL;
> +
> + if (!wt->description_valid) {
> + struct strbuf path = STRBUF_INIT;
> +
> + strbuf_addstr(&path, worktree_git_path(wt, "description"));
> + if (file_exists(path.buf)) {
> + struct strbuf description = STRBUF_INIT;
> + if (strbuf_read_file(&description, path.buf, 0) < 0)
> + die_errno(_("failed to read '%s'"), path.buf);
> + strbuf_trim_trailing_newline(&description);
> + wt->description = strbuf_detach(&description, NULL);
> + } else
> + wt->description = NULL;
> + wt->description_valid = 1;
> + strbuf_release(&path);
> + }
> +
> + return wt->description;
> +}
> +
> +int set_worktree_description(struct worktree *wt, const char *text)
> +{
> + char *path;
> + int ret = 0;
> +
> + if (is_main_worktree(wt))
> + return error(_("cannot set description on the main worktree"));
> +
> + path = repo_common_path(wt->repo, "worktrees/%s/description", wt->id);
> + if (!text || !*text) {
> + if (file_exists(path) && unlink(path))
> + ret = error_errno(_("failed to remove '%s'"), path);
> + } else {
> + write_file(path, "%s", text);
> + }
> +
> + /* invalidate cache so a follow-up worktree_description() re-reads */
> + FREE_AND_NULL(wt->description);
> + wt->description_valid = 0;
> +
> + free(path);
> + return ret;
> +}
> +
>   const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire)
>   {
>    struct strbuf reason = STRBUF_INIT;
> diff --git worktree.h worktree.h
> index 1075409f9a..2568830237 100644
> --- worktree.h
> +++ worktree.h
> @@ -13,12 +13,16 @@ struct worktree {
>    char *head_ref; /* NULL if HEAD is broken or detached */
>    char *lock_reason; /* private - use worktree_lock_reason */
>    char *prune_reason;     /* private - use worktree_prune_reason */
> + char *description; /* private - use worktree_description */
>    struct object_id head_oid;
> + timestamp_t created_at; /* private - use worktree_created_at; 0 if unknown */
>    int is_detached;
>    int is_bare;
>    int is_current; /* does `path` match `repo->worktree` */
>    int lock_reason_valid; /* private */
>    int prune_reason_valid; /* private */
> + int description_valid; /* private */
> + int created_at_valid;  /* private */
>   };
> 
>   /*
> @@ -96,6 +100,34 @@ int is_main_worktree(const struct worktree *wt);
>    */
>   const char *worktree_lock_reason(struct worktree *wt);
> 
> +/*
> + * Return the worktree's recorded creation timestamp, or 0 if no timestamp
> + * was recorded (e.g. a worktree created before this metadata existed, or
> + * the main worktree which never carries the file).
> + */
> +timestamp_t worktree_created_at(struct worktree *wt);
> +
> +/*
> + * Return the modification time of the worktree's HEAD file as an
> + * approximation of "when was this worktree last touched by Git" (checkout,
> + * commit, reset, rebase, etc.). Returns 0 for the main worktree, and 0 if
> + * HEAD cannot be stat'd.
> + */
> +timestamp_t worktree_updated_at(struct worktree *wt);
> +
> +/*
> + * Return the user-supplied description for the given worktree, or NULL
> + * if none was set.
> + */
> +const char *worktree_description(struct worktree *wt);
> +
> +/*
> + * Write or replace the worktree's description. Pass NULL or "" to delete
> + * the description. Returns 0 on success, -1 on failure. Not valid for the
> + * main worktree.
> + */
> +int set_worktree_description(struct worktree *wt, const char *text);
> +
>   /*
>    * Return the reason string if the given worktree should be pruned, otherwise
>    * NULL if it should not be pruned. `expire` defines a grace period to prune
> 
> On Fri, Jun 5, 2026 at 9:57 AM Chris Torek <chris.torek@gmail.com> wrote:
>>
>> On Fri, Jun 5, 2026 at 8:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>>> Isn't "what is the worktree for" a property of the branch that's checked
>>> out, not the worktree itself?
>>
>> I don't think it is.
>>
>> A lot of things within Git have, shall way say, "less than optimal"
>> names, with "branch" (with at least three different meanings),
>> "HEAD", and "index" being examples of this. (This is just an
>> observation, not a complaint: we know from studies that
>> oddities in names don't matter that much after a bit of usage
>> of some system. They're just minor stumbling blocks when
>> getting started.)
>>
>> Work-tree or working tree is not one of them, though. It's
>> concise and pointed: a working tree is where you do work.
>>
>> As such, the *purpose* of a working tree is exactly as general
>> as the purpose of doing work! That's a wide-open set.
>>
>> Git's internal constraint, of requiring each working tree that
>> is using a branch name to have a unique-to-that-tree branch
>> name, is a property specific to branch names, not to branching
>> in general (an example of the ambiguity of "branch" here).
>> And of course, as you note, any working tree can be on
>> a detached HEAD.
>>
>> Exactly what properties any given working tree should
>> have, and the weird entanglement Git has between the
>> "primary" working tree (the one created by any non-bare
>> clone) and all "secondary" working trees, is a mere (ahem)
>> matter of implementation. Descriptions, creation times,
>> modification times, etc., are all potentially useful.
>>
>> I think, had Git initially made all repositories effectively
>> bare, with separate working trees added later, this might
>> all be a little clearer, but of course that ship sailed,
>> crossed *all* the oceans, sank, was refloated and refitted,
>> and sailed for another decade already. :-)
>>
>> Chris
> 
> 
> 


^ permalink raw reply

* Re: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Pablo Sabater @ 2026-06-09 10:03 UTC (permalink / raw)
  To: Ben Knoble; +Cc: Junio C Hamano, git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <3D9034D8-C38F-48A1-B637-4342BE4954AC@gmail.com>

El lun, 8 jun 2026 a las 18:44, Ben Knoble (<ben.knoble@gmail.com>) escribió:
>
>
> > Le 8 juin 2026 à 08:23, Junio C Hamano <gitster@pobox.com> a écrit :
> >
> [snip]
>
> > Having said that, I personally think that the current behaviour of
> > `commit --amend` and `history reword` are both _wrong_ [*2*].
> >
> > You may start `git commit --amend`, and after staring at the
> > existing commit log message for some time in your editor, it is
> > quite natural for you to decide that leaving the commit as-is is the
> > right thing [*3*] in your situation.  It may have been a better
> > design for the system to notice this situation and leave the commit
> > as-is, with an override option `--force` to allow users to forcibly
> > update the committer ident and timestamp in the commit header.  I am
> > not a `history reword` user (yet), but from the motivation you
> > described for this patch, I sense that the story is the same there.
>
> FWIW, in this situation I abort my editor (:cquit in Vim) so that the amend gets an error-valued exit code from the subprocess and aborts itself.
>
> Perhaps there could/should be a better side-channel for communicating that, though? I do not know how easy it is to tell other editors to « quit with errors ».

Well, I didn't know that I could exit with errors (:cq in NeoVim),
can't say much about other editors, but It would be better to abort if
the messages are the same and forget about editors.

>
> > [Footnote]
> >
> > *1* Besides, doesn't "--update-refs" in "rebase -i" allow you to
> >     adjust the branches?
> >
> > *2* But it is an established behaviour people _rely_ on, so even
> >     though it may have been better if these commands behaved
> >     differently, it probably is a bit too late to change it now.
> >
> > *3* This includes the case where the original author is especially
> >     difficult to work with and would complain any change to their
> >     commits, even if the only change you made for them is a
> >     typofix.  Fixing a small typo/grammo may not be worth your time
> >     and unpleasant exchanges with them after touching their commit.

Thanks,
Pablo

^ permalink raw reply


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