* Re: [PATCH v3 2/7] fetch: add --negotiation-restrict option
From: Derrick Stolee @ 2026-05-12 14:23 UTC (permalink / raw)
To: Matthew John Cheetham, Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps
In-Reply-To: <VI0PR03MB116340D42554FA1D08E51910BC0392@VI0PR03MB11634.eurprd03.prod.outlook.com>
On 5/12/26 7:11 AM, Matthew John Cheetham wrote:
> On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
>> From: Derrick Stolee <stolee@gmail.com>
>> --- a/Documentation/fetch-options.adoc
>> +++ b/Documentation/fetch-options.adoc
>> @@ -49,6 +49,7 @@ the current repository has the same history as the source
>> repository.
>> `.git/shallow`. This option updates `.git/shallow` and accepts such
>> refs.
>> +`--negotiation-restrict=(<commit>|<glob>)`::
>> `--negotiation-tip=(<commit>|<glob>)`::
>> By default, Git will report, to the server, commits reachable
>> from all local refs to find common commits in an attempt to
>> @@ -58,6 +59,9 @@ the current repository has the same history as the source
>> repository.
>> local ref is likely to have commits in common with the
>> upstream ref being fetched.
>> +
>> +`--negotiation-restrict` is the preferred name for this option;
>> +`--negotiation-tip` is accepted as a synonym.
>> ++
>> This option may be specified more than once; if so, Git will report
>> commits reachable from any of the given commits.
>> +
>
> By my eyes it looks like two other references to the old name remain and
> could also be updated for consistency (since --negotiation-restrict is
> now the preferred name):
>
> 1. Documentation/fetch-options.adoc, under `--negotiate-only`:
> "ancestors of the provided `--negotiation-tip=` arguments"
>
> 2. Documentation/config/fetch.adoc:
> "See also the `--negotiate-only` and `--negotiation-tip` options"
>
> Of course the old name will still work, so this is more a nit-pick :-)
Thanks for catching these! I will make the correct updates in the
next version.
>> diff --git a/builtin/pull.c b/builtin/pull.c
>> index 7e67fdce97..821cc6699a 100644
>> --- a/builtin/pull.c
>> +++ b/builtin/pull.c
>> @@ -999,6 +999,9 @@ int cmd_pull(int argc,
>> OPT_PASSTHRU_ARGV(0, "negotiation-tip", &opt_fetch, N_("revision"),
>> N_("report that we have only objects reachable from this object"),
>> 0),
>> + OPT_PASSTHRU_ARGV(0, "negotiation-restrict", &opt_fetch, N_("revision"),
>> + N_("report that we have only objects reachable from this object"),
>> + 0),
>> OPT_BOOL(0, "show-forced-updates", &opt_show_forced_updates,
>> N_("check for forced-updates on all updated branches")),
>> OPT_PASSTHRU(0, "set-upstream", &set_upstream, NULL,
>
> It's a shame we don't have a nice way to combine the `OPT_ALIAS` and
> `OPT_PASSTHRU_ARGV` functionality, but it's only a small duplication
> cost of the repeated definition.
Actually, I just missed that I should use OPT_ALIAS in 'pull' as well as
how it's used in 'fetch'. Will fix.
Thanks,
-Stolee
^ permalink raw reply
* Re: [PATCH v3 3/7] transport: rename negotiation_tips
From: Derrick Stolee @ 2026-05-12 14:33 UTC (permalink / raw)
To: Matthew John Cheetham, Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps
In-Reply-To: <VI0PR03MB116342F99CAA8959D2D8638E0C0392@VI0PR03MB11634.eurprd03.prod.outlook.com>
On 5/12/26 7:30 AM, Matthew John Cheetham wrote:
> On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
>> From: Derrick Stolee <stolee@gmail.com>
>>
>> The previous change added the --negotiation-restrict synonym for the
>> --negotiation-tips option for 'git fetch'. In anticipation of adding a
>> new option that behaves similarly but with distinct changes to its
>> behavior, rename the internal representation of this data from
>> 'negotiation_tips' to 'negotiation_restrict_tips'.
>
> Nitpick: s/tips/tip/ .. no trailing s for either the option name, nor
> the (old) variable name. The function names do use the plural however.
Thanks for the close eye!
>> diff --git a/transport-helper.c b/transport-helper.c
>> index 4d95d84f9e..0e5b3b7202 100644
>> --- a/transport-helper.c
>> +++ b/transport-helper.c
>> @@ -754,7 +754,7 @@ static int fetch_refs(struct transport *transport,
>> set_helper_option(transport, "filter", spec);
>> }
>> - if (data->transport_options.negotiation_tips)
>> + if (data->transport_options.negotiation_restrict_tips)
>> warning("Ignoring --negotiation-tip because the protocol does not
>> support it.");
>> if (data->fetch)
>
> Oh! Looks like a place was missed when renaming the preferred option name in
> strings. It probably makes sense to do this rename in this patch
> (rather than in patch 1) since we're already updating the struct field
> name here anyway, but up to you.
>
> Also do we also want to make it translatable like the others?
I will update this as part of the previous patch that handles all the strings,
including making it translatable and dropping the capital "I" at the start.
Good find.
> Aside: I just noticed another '--negotiation-tip' instance in the
> `get_commons_through_negotiation` function in send-pack.c. It still uses
> the 'tip' option name when forming the shell cmdline.
Thanks! I've done a more careful search and confirmed that you found
everything I had missed.
-Stolee
^ permalink raw reply
* Re: [PATCH v3 5/7] fetch: add --negotiation-include option for negotiation
From: Matthew John Cheetham @ 2026-05-12 14:38 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <ae81ef36a1b3ca04e39e891cce827fa55540b9bb.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> Add a new --negotiation-include option to 'git fetch', which ensures
> that certain ref tips are always sent as 'have' lines during fetch
> negotiation, regardless of what the negotiation algorithm selects.
>
> This is useful when the repository has a large number of references, so
> the normal negotiation algorithm truncates the list. This is especially
> important in repositories with long parallel commit histories. For
> example, a repo could have a 'dev' branch for development and a
> 'release' branch for released versions. If the 'dev' branch isn't
> selected for negotiation, then it's not a big deal because there are
> many in-progress development branches with a shared history. However, if
> 'release' is not selected for negotiation, then the server may think
> that this is the first time the client has asked for that reference,
> causing a full download of its parallel commit history (and any extra
> data that may be unique to that branch). This is based on a real example
> where certain fetches would grow to 60+ GB when a release branch
> updated.
>
> This option is a complement to --negotiation-restrict, which reduces the
> negotiation ref set to a specific list. In the earlier example, using
> --negotiation-restrict to focus the negotiation to 'dev' and 'release'
> would avoid those problematic downloads, but would still not allow
> advertising potentially-relevant user brances. In this way, the
> 'include' version solves the problem I mention while allowing
> negotiation to pick other references opportunistically. The two options
> can also be combined to allow the best of both worlds.
Nice explanation and motivation for the need of such as feature.
One small typo: s/brances/branches/
> The argument may be an exact ref name or a glob pattern. Non-existent
> refs are silently ignored. This behavior is also updated in the ref matching
> logic for the related --negotiation-restrict option to match.
Calling out the intent for the behaviour change (non-existent refs are
silently ignored). This is an important point.
> The implementation outputs the requested objects as haves before the
> negotiation algorithm kicks in and performs a priority-queue walk from the
> tip commits. In order to avoid duplicates, we mark the requested objects as
> COMMON so they (and their descendants) are not output by the negotiator. The
> negotiator still outputs at least one have before a round is flushed, when
> the server could ACK to stop the negotiation.
>
> Also add --negotiation-include to 'git pull' passthrough options.
>
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> Documentation/fetch-options.adoc | 19 ++++++
> builtin/fetch.c | 16 ++++-
> builtin/pull.c | 3 +
> fetch-pack.c | 112 +++++++++++++++++++++++++++++--
> fetch-pack.h | 10 ++-
> t/t5510-fetch.sh | 66 ++++++++++++++++++
> transport.c | 4 +-
> transport.h | 6 ++
> 8 files changed, 227 insertions(+), 9 deletions(-)
>
> diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
> index c07b85499f..decc7f6abd 100644
> --- a/Documentation/fetch-options.adoc
> +++ b/Documentation/fetch-options.adoc
> @@ -73,6 +73,25 @@ See also the `fetch.negotiationAlgorithm` and `push.negotiate`
> configuration variables documented in linkgit:git-config[1], and the
> `--negotiate-only` option below.
>
> +`--negotiation-include=<revision>`::
> + Ensure that the given ref tip is always sent as a "have" line
> + during fetch negotiation, regardless of what the negotiation
> + algorithm selects. This is useful to guarantee that common
> + history reachable from specific refs is always considered, even
> + when `--negotiation-restrict` restricts the set of tips or when
> + the negotiation algorithm would otherwise skip them.
> ++
> +This option may be specified more than once; if so, each ref is sent
> +unconditionally.
> ++
> +The argument may be an exact ref name (e.g. `refs/heads/release`) or a
> +glob pattern (e.g. `refs/heads/release/{asterisk}`). The pattern syntax
> +is the same as for `--negotiation-restrict`.
> ++
> +If `--negotiation-restrict` is used, the have set is first restricted by
> +that option and then increased to include the tips specified by
> +`--negotiation-include`.
> +
The placeholder `<revision>` and the description in the body of "ref
name or glob" slightly disagree with each other. The
`--negotiation-restrict` docs use `(<commit>|<glob>)` in the syntax
definition and
"a glob on ref names, a ref, or .. SHA-1 of a commit".
`resolve_negotiation_include()` calls `repo_get_oid()` for non-globs
so bare OIDs and abbreviated SHAs work too. Perhaps consider aligning
the syntaxes, and mention that OIDs work too.
> `--negotiate-only`::
> Do not fetch anything from the server, and instead print the
> ancestors of the provided `--negotiation-tip=` arguments,
> diff --git a/builtin/fetch.c b/builtin/fetch.c
> index a1960e3e0c..ef50e2fbe9 100644
> --- a/builtin/fetch.c
> +++ b/builtin/fetch.c
> @@ -99,6 +99,7 @@ static struct transport *gsecondary;
> static struct refspec refmap = REFSPEC_INIT_FETCH;
> static struct string_list server_options = STRING_LIST_INIT_DUP;
> static struct string_list negotiation_restrict = STRING_LIST_INIT_NODUP;
> +static struct string_list negotiation_include = STRING_LIST_INIT_NODUP;
>
> struct fetch_config {
> enum display_format display_format;
> @@ -1547,10 +1548,14 @@ static void add_negotiation_restrict_tips(struct git_transport_options *smart_op
> int old_nr;
> if (!has_glob_specials(s)) {
> struct object_id oid;
> +
> + /* Ignore missing reference. */
> if (repo_get_oid(the_repository, s, &oid))
> - die(_("%s is not a valid object"), s);
> + continue;
> + /* Fail on missing object pointed by ref. */
> if (!odb_has_object(the_repository->objects, &oid, 0))
> die(_("the object %s does not exist"), s);
> +
> oid_array_append(oids, &oid);
> continue;
> }
This is the change in behaviour - unresolvable revs were a fatal error
and are now silently ignored.
Note that t5510 '--negotiation-tip rejects missing OIDs' still passes
because it uses an all-zero OID, which parses as a valid hex string,
and dies on the second check "object does not exist". Using something
like `--negotiation-tip=notreal` that previously would error will now
silently be ignored.
Is it worth another test? (invalid object vs not exists)?
> @@ -1615,6 +1620,13 @@ static struct transport *prepare_transport(struct remote *remote, int deepen,
> strbuf_release(&config_name);
> }
> }
> + if (negotiation_include.nr) {
> + if (transport->smart_options)
> + transport->smart_options->negotiation_include = &negotiation_include;
> + else
> + warning(_("ignoring %s because the protocol does not support it"),
> + "--negotiation-include");
> + }
> return transport;
> }
There is a difference between the existing `--negotiation-restrict`
option and the new `--negotiation-include` option. Patch 3's commit
message says:
"The 'tips' part is kept because this is an oid_array in the transport
layer. This requires the builtin to handle parsing refs into
collections of oids so the transport layer can handle this cleaner
form of the data."
The new option passes the raw `string_list` to the transport layer and
lets it resolve it instead. If the transport layer now learns how to
resolve refs to oids, why not for tips/restrict?
Would it be easier for future readers for these complementary options
to resolve their inputs at the same layer? Or at least call out why:
"would prefer raw tips but for back-compat we resolve in the built-in"
for example.
> @@ -2582,6 +2594,8 @@ int cmd_fetch(int argc,
> OPT_STRING_LIST(0, "negotiation-restrict", &negotiation_restrict, N_("revision"),
> N_("report that we have only objects reachable from this object")),
> OPT_ALIAS(0, "negotiation-tip", "negotiation-restrict"),
> + OPT_STRING_LIST(0, "negotiation-include", &negotiation_include, N_("revision"),
> + N_("ensure this ref is always sent as a negotiation have")),
> OPT_BOOL(0, "negotiate-only", &negotiate_only,
> N_("do not fetch a packfile; instead, print ancestors of negotiation tips")),
> OPT_PARSE_LIST_OBJECTS_FILTER(&filter_options),
> diff --git a/builtin/pull.c b/builtin/pull.c
> index 821cc6699a..86c85b60ef 100644
> --- a/builtin/pull.c
> +++ b/builtin/pull.c
> @@ -1002,6 +1002,9 @@ int cmd_pull(int argc,
> OPT_PASSTHRU_ARGV(0, "negotiation-restrict", &opt_fetch, N_("revision"),
> N_("report that we have only objects reachable from this object"),
> 0),
> + OPT_PASSTHRU_ARGV(0, "negotiation-include", &opt_fetch, N_("revision"),
> + N_("ensure this ref is always sent as a negotiation have"),
> + 0),
> OPT_BOOL(0, "show-forced-updates", &opt_show_forced_updates,
> N_("check for forced-updates on all updated branches")),
> OPT_PASSTHRU(0, "set-upstream", &set_upstream, NULL,
> diff --git a/fetch-pack.c b/fetch-pack.c
> index baf239adf9..8b080b0080 100644
> --- a/fetch-pack.c
> +++ b/fetch-pack.c
> @@ -25,6 +25,7 @@
> #include "oidset.h"
> #include "packfile.h"
> #include "odb.h"
> +#include "object-name.h"
> #include "path.h"
> #include "connected.h"
> #include "fetch-negotiator.h"
> @@ -332,6 +333,48 @@ static void send_filter(struct fetch_pack_args *args,
> }
> }
>
> +static int add_oid_to_oidset(const struct reference *ref, void *cb_data)
> +{
> + struct oidset *set = cb_data;
> + if (!odb_has_object(the_repository->objects, ref->oid, 0))
> + die(_("the object %s does not exist"), oid_to_hex(ref->oid));
> + oidset_insert(set, ref->oid);
> + return 0;
> +}
> +
> +static void resolve_negotiation_include(const struct string_list *negotiation_include,
> + struct oidset *result)
> +{
> + struct string_list_item *item;
> +
> + if (!negotiation_include || !negotiation_include->nr)
> + return;
> +
> + for_each_string_list_item(item, negotiation_include) {
> + if (!has_glob_specials(item->string)) {
> + struct object_id oid;
> +
> + /* Ignore missing reference. */
> + if (repo_get_oid(the_repository, item->string, &oid))
> + continue;
> +
> + /* Fail on missing object pointed by ref. */
> + if (!odb_has_object(the_repository->objects, &oid, 0))
> + die(_("the object %s does not exist"),
> + item->string);
> +
> + oidset_insert(result, &oid);
> + } else {
> + struct refs_for_each_ref_options opts = {
> + .pattern = item->string,
> + };
> + refs_for_each_ref_ext(
> + get_main_ref_store(the_repository),
> + add_oid_to_oidset, result, &opts);
> + }
> + }
> +}
> +
`resolve_negotiation_include()` is basically doing the same as
`add_negotiation_restrict_tips()` except outputting to an `oidset`
vs `oid_array`. This is a result of the difference in ref resolution
layer between `--negotiation-restrict/tip` and `-include`.
> static int find_common(struct fetch_negotiator *negotiator,
> struct fetch_pack_args *args,
> int fd[2], struct object_id *result_oid,
> @@ -347,6 +390,7 @@ static int find_common(struct fetch_negotiator *negotiator,
> struct strbuf req_buf = STRBUF_INIT;
> size_t state_len = 0;
> struct packet_reader reader;
> + struct oidset negotiation_include_oids = OIDSET_INIT;
>
> if (args->stateless_rpc && multi_ack == 1)
> die(_("the option '%s' requires '%s'"), "--stateless-rpc", "multi_ack_detailed");
> @@ -474,6 +518,33 @@ static int find_common(struct fetch_negotiator *negotiator,
> trace2_region_enter("fetch-pack", "negotiation_v0_v1", the_repository);
> flushes = 0;
> retval = -1;
> +
> + /* Send unconditional haves from --negotiation-include */
> + resolve_negotiation_include(args->negotiation_include,
> + &negotiation_include_oids);
> + if (oidset_size(&negotiation_include_oids)) {
> + struct oidset_iter iter;
> + oidset_iter_init(&negotiation_include_oids, &iter);
> +
> + while ((oid = oidset_iter_next(&iter))) {
> + struct commit *commit;
> + packet_buf_write(&req_buf, "have %s\n",
> + oid_to_hex(oid));
> + print_verbose(args, "have %s", oid_to_hex(oid));
> + count++;
> +
> + /*
> + * If this is a commit, then mark as COMMON to
> + * avoid the negotiator also outputting it as
> + * a have.
> + */
> + commit = lookup_commit(the_repository, oid);
> + if (commit &&
> + !repo_parse_commit(the_repository, commit))
> + commit->object.flags |= COMMON;
> + }
> + }
> +
I want to make sure I understand the COMMON pre-marking before
commenting further on this patch. My understanding is there are actually
two different COMMON bits in the tree, one defined in fetch-pack.c
(bit 6) and one in negotiator/default.c (bit 2):
- fetch-pack.c's COMMON (bit 6) is set after a server ACK confirms an
OID is common with us and is read to decide when we've established
enough common ground to terminate negotiation. This is not consulted
in find_common().
- negotiator/default.c's COMMON (bit 2) is a book-keeping flag used by
`get_rev()` to decide if we skip emitting a commit as a 'have'.
Since we're in fetch-pack.c here, the `commit->object.flags |= COMMON`
line is setting bit 6. The `get_rev()` call in negotiator/default.c
never checks bit 6, only bit 2. As far as I can tell, this mark won't
suppress the negotiator from emitting another 'have' line in the
protocol v0/v1 paths in `find_common()`.
The v2 path doesn't touch the flags.. `add_haves` dedups via
`oidset_contains()`:
while ((oid = negotiator->next(negotiator))) {
if (negotiation_include_oids &&
oidset_contains(negotiation_include_oids, oid))
continue;
packet_buf_write(req_buf, "have %s\n", ...);
}
This works, and is what the new 'avoids duplicates with negotiator' test
runs against, on protocol v2. If we run on protocol v0/v1, and if my
assessment is correct, then we'd see a duplicate I think?
Sorry if I've not understood correctly or am missing something, which is
entirely possible :-)
Thanks,
Matthew
^ permalink raw reply
* Re: [PATCH v3 4/7] remote: add remote.*.negotiationRestrict config
From: Derrick Stolee @ 2026-05-12 14:52 UTC (permalink / raw)
To: Matthew John Cheetham, Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps
In-Reply-To: <VI0PR03MB11634BD90B47B89A7631F5DE5C0392@VI0PR03MB11634.eurprd03.prod.outlook.com>
On 5/12/26 8:29 AM, Matthew John Cheetham wrote:
> On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
>
>> From: Derrick Stolee<stolee@gmail.com>
>>
>> In a previous change, the --negotiation-restrict command-line option of
>> 'git fetch' was added as a synonym of --negotiation-tips. Both of these
>> options restrict the set of 'haves' the client can send as part of
>> negotiation.
>
> s/tips/tip/ as per the previous patch comments. Not important either
> way.
Thanks.
>> +remote.<name>.negotiationRestrict::
>> + When negotiating with this remote during `git fetch` and `git push`,
>> + restrict the commits advertised as "have" lines to only those
>> + reachable from refs matching the given patterns. This multi-valued
>> + config option behaves like `--negotiation-restrict` on the command
>> + line.
>> ++
>> +Each value is either an exact ref name (e.g. `refs/heads/release`) or a
>> +glob pattern (e.g. `refs/heads/release/*`). The pattern syntax is the
>> +same as for `--negotiation-restrict`.
>> ++
>> +These config values are used as defaults for the `--negotiation-restrict`
>> +command-line option. If `--negotiation-restrict` (or its synonym
>> +`--negotiation-tip`) is specified on the command line, then the config
>> +values are not used.
>> ++
>> +Blank values signal to ignore all previous values, allowing a reset of
>> +the list from broader config scenarios.
>> +
>> remote.<name>.followRemoteHEAD::
>> How linkgit:git-fetch[1] should handle updates to `remotes/<name>/HEAD`
>> when fetching using the configured refspecs of a remote.
>
>
> You say "during `git fetch` and `git push`", but does `push` actually
> honour the new config?
>
> When the `push.negotiate` config is on then
> `get_commons_through_negotiation()` from send-pack.c shells out to
> `git fetch --negotiate-only` with one `--negotiation-tip=<oid>` arg per
> ref being pushed, then the URL. This means the CLI restrict list is
> always non-empty in the subprocess so in `prepare_transport()` (in the
> below hunk) the `if (negotiation_restrict.nr)` arm is always taken and the new
> `else if (remote->negotiation_restrict.nr)` arm is never taken.
>
> BUT.. reading ahead I see that patch 7 actually wires up negotiation
> config for push - so my commentary here will be moot! Do we want to drop
> the "and `git push`" part from this until patch 7, when it is wired up
> appropriately?
You're right that this documentation is premature about 'git push'.
> One other suggestion: perhaps we should clarify that `push.negotiate`
> needs to be set for `remote.<name>.negotiationRestrict` to be honoured
> during pushes?
Yes. I'll rewrite this to focus on 'git fetch'. Then in patch 7 I can
add a new detail about how to make this behavior be respected in 'git push'.
>> if (deepen_relative) {
>> if (deepen_relative < 0)
>> die(_("negative depth in --deepen is not supported"));
>> @@ -2749,6 +2758,10 @@ int cmd_fetch(int argc,
>> if (!remote)
>> die(_("must supply remote when using --negotiate-only"));
>> gtransport = prepare_transport(remote, 1, &filter_options);
>> + if (!gtransport->smart_options ||
>> + !gtransport->smart_options->negotiation_restrict_tips)
>> + die(_("%s needs one or more %s"), "--negotiate-only",
>> + "--negotiation-restrict=*");
>> if (gtransport->smart_options) {
>> gtransport->smart_options->acked_commits = &acked_commits;
>> } else {
>
>
> This new condition fires whenever `gtransport->smart_options` is NULL,
> i.e. the transport doesn't support smart options. Before this case was
> handled three lines after this hunk by:
>
> } else {
> warning(_("protocol does not support --negotiate-only, exiting"));
> result = 1;
> trace2_region_leave("fetch", "negotiate-only", the_repository);
> goto cleanup;
> }
>
> What happens now if a user runs --negotiate-only against a non-smart
> transport is they see an odd message:
>
> fatal: --negotiate-only needs one or more --negotiation-restrict=*
>
> ..but they may have specified --negotiation-restrict options.
>
> Do we instead want &&?
>
> if (gtransport->smart_options &&
> !gtransport->smart_options->negotiation_restrict_tips)
> die(_("%s needs one or more %s"), "--negotiate-only",
> "--negotiation-restrict=*");
You are right that we want to say "we have smart options but haven't
specified restrict arguments" so we can leave the later if/else to
handle the null smart_options case. But actually, I think that it
would be better to reorganize the conditions altogether:
if (!gtransport->smart_options) {
warning(_("protocol does not support --negotiate-only, "exiting"));
result = 1;
trace2_region_leave("fetch", "negotiate-only", the_repository);
goto cleanup;
}
if (!gtransport->smart_options->negotiation_restrict_tips)
die(_("%s needs one or more %s"), "--negotiate-only",
"--negotiation-restrict=*");
gtransport->smart_options->acked_commits = &acked_commits;
This is easier to reason about:
* If we don't have smart options, then skip out of the negotiation logic.
* If we don't have restrict tips, then die().
* Do the negotiation logic only if the previous two conditions didn't hold.
>> @@ -562,6 +564,12 @@ static int handle_config(const char *key, const char *value,
>> } else if (!strcmp(subkey, "serveroption")) {
>> return parse_transport_option(key, value,
>> &remote->server_options);
>> + } else if (!strcmp(subkey, "negotiationrestrict")) {
>> + /* reset list on empty value. */
>> + if (!value || !*value)
>> + string_list_clear(&remote->negotiation_restrict, 0);
>> + else
>> + string_list_append(&remote->negotiation_restrict, value);
>> } else if (!strcmp(subkey, "followremotehead")) {
>> const char *no_warn_branch;
>> if (!strcmp(value, "never"))
>
>
> Here we use the 'empty value means reset the list' pattern, but I notice
> that the `parse_transport_option()` function already supports this reset
> pattern (and used by serveroption above), with a small difference:
>
> if (!value)
> return config_error_nonbool(var);
> if (!*value)
> string_list_clear(transport_options, 0);
>
> So NULL is an error, but empty string is 'reset'. Is it worth being
> consistent with other options that use `parse_transport_options`?
Thanks for catching this! Let's be consistent. NULL is likely
impossible in this case, but let's be consistent. It also needs
to return.
Thanks,
-Stolee
^ permalink raw reply
* Re: [PATCH v3 6/7] remote: add remote.*.negotiationInclude config
From: Matthew John Cheetham @ 2026-05-12 14:54 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <a2d15fa12af70367ac6b289f69228cd33dd97d5e.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> Add a new 'remote.<name>.negotiationInclude' multi-valued config option that
> provides default values for --negotiation-include when no
> --negotiation-include arguments are specified over the command line. This
> is a mirror of how 'remote.<name>.negotiationRestrict' specifies defaults
> for the --negotiation-restrict arguments.
>
> Each value is either an exact ref name or a glob pattern whose tips should
> always be sent as 'have' lines during negotiation. The config values are
> resolved through the same resolve_negotiation_include() codepath as the CLI
> options.
>
> This option is additive with the normal negotiation process: the negotiation
> algorithm still runs and advertises its own selected commits, but the refs
> matching the config are sent unconditionally on top of those heuristically
> selected commits.
>
> Similar to the negotiationRestrict config, an empty value resets the value
> list to allow ignoring earlier config values, such as those that might be
> set in system or global config.
>
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> Documentation/config/remote.adoc | 27 ++++++++++++++++++
> Documentation/fetch-options.adoc | 4 +++
> builtin/fetch.c | 10 +++++++
> remote.c | 8 ++++++
> remote.h | 1 +
> t/t5510-fetch.sh | 49 ++++++++++++++++++++++++++++++++
> 6 files changed, 99 insertions(+)
This patch is a mirror of patch 4 that added the remote config for
negotiateRestrict. Some of the same comments apply here too:
- reusing `parse_transport_option()` vs inline resetting the list
- values could be commit SHAs as well as refs/globs
> diff --git a/Documentation/config/remote.adoc b/Documentation/config/remote.adoc
> index f1d889d03e..44de6d3c1f 100644
> --- a/Documentation/config/remote.adoc
> +++ b/Documentation/config/remote.adoc
> @@ -126,6 +126,33 @@ values are not used.
> Blank values signal to ignore all previous values, allowing a reset of
> the list from broader config scenarios.
>
> +remote.<name>.negotiationInclude::
> + When negotiating with this remote during `git fetch` and `git push`,
> + the client advertises a list of commits that exist locally. In
> + repos with many references, this list of "haves" can be truncated.
> + Depending on data shape, dropping certain references may be
> + expensive. This multi-valued config option specifies ref patterns
> + whose tips should always be sent as "have" commits during fetch
> + negotiation with this remote.
> ++
> +Each value is either an exact ref name (e.g. `refs/heads/release`) or a
> +glob pattern (e.g. `refs/heads/release/*`). The pattern syntax is the same
> +as for `--negotiation-restrict`.
Should this say "..same as for `--negotiation-include`"?
This way each `remote.<name>.negotiationX` doc cross-references the
corresponding `--negotiation-X` command line option.
> diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
> index 4316f8d4ea..db73ed5379 100755
> --- a/t/t5510-fetch.sh
> +++ b/t/t5510-fetch.sh
> @@ -1577,6 +1577,55 @@ test_expect_success '--negotiation-include avoids duplicates with negotiator' '
> test_line_count = 1 matches
> '
>
> +test_expect_success 'remote.<name>.negotiationInclude used as default for --negotiation-include' '
> + test_when_finished rm -f trace &&
> + setup_negotiation_tip server server 0 &&
> +
> + # test the reset of the list on an empty value
> + git -C client config --add remote.origin.negotiationInclude refs/tags/alpha_1 &&
> + git -C client config --add remote.origin.negotiationInclude "" &&
> + git -C client config --add remote.origin.negotiationInclude refs/tags/beta_1 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 \
> + origin alpha_s beta_s &&
> +
> + ALPHA_1=$(git -C client rev-parse alpha_1) &&
> + test_grep "fetch> have $ALPHA_1" trace &&
> + BETA_1=$(git -C client rev-parse beta_1) &&
> + test_grep "fetch> have $BETA_1" trace
> +'
This test sets up the include list as [alpha_1, "", beta_1] which after
the reset should become [beta_1], but the assertions in the test only
check that alpha_1 (sent via the --negotiation-restrict option) and
beta_1 (sent via the include) appear. If the reset of the list didn't
work then the test still passes because alpha_1 is sent via the CLI
option.
> +test_expect_success 'remote.<name>.negotiationInclude works with glob patterns' '
> + test_when_finished rm -f trace &&
> + setup_negotiation_tip server server 0 &&
> +
> + git -C client config --add remote.origin.negotiationInclude "refs/tags/beta_*" &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 \
> + origin alpha_s beta_s &&
> +
> + BETA_1=$(git -C client rev-parse beta_1) &&
> + test_grep "fetch> have $BETA_1" trace &&
> + BETA_2=$(git -C client rev-parse beta_2) &&
> + test_grep "fetch> have $BETA_2" trace
> +'
> +
> +test_expect_success 'CLI --negotiation-include overrides remote.<name>.negotiationInclude' '
> + test_when_finished rm -f trace &&
> + setup_negotiation_tip server server 0 &&
> +
> + git -C client config --add remote.origin.negotiationInclude refs/tags/beta_2 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 \
> + --negotiation-include=refs/tags/beta_1 \
> + origin alpha_s beta_s &&
> +
> + BETA_1=$(git -C client rev-parse beta_1) &&
> + test_grep "fetch> have $BETA_1" trace &&
> + BETA_2=$(git -C client rev-parse beta_2) &&
> + test_grep ! "fetch> have $BETA_2" trace
> +'
> +
> test_expect_success SYMLINKS 'clone does not get confused by a D/F conflict' '
> git init df-conflict &&
> (
Thanks,
Matthew
^ permalink raw reply
* Re: [RFC PATCH v5 3/3] push: support pushing to a remote group
From: Kristoffer Haugsbakk @ 2026-05-12 15:05 UTC (permalink / raw)
To: Usman Akinyemi
Cc: Christian Couder, git, Junio C Hamano, Taylor Blau, Phillip Wood,
Patrick Steinhardt
In-Reply-To: <20260503153402.1333220-4-usmanakinyemi202@gmail.com>
On Sun, May 3, 2026, at 17:34, Usman Akinyemi wrote:
> `git fetch` accepts a remote group name (configured via `remotes.<name>`
> in config) and fetches from each member remote. `git push` has no
> equivalent — it only accepts a single remote name.
>
>[snip]
>
> +This means the user is responsible for ensuring that the sequence of
> +individual pushes makes sense. If `git push r1`` would fail for a given
Double backticks (s/`git push r1``/`git push r1`/).
> +set of options and arguments, then `git push all-remotes` will fail in
> +the same way when it reaches r1. The group push does not do anything
s/r1/`r1`/ ? Goes for the others here as well
> +special to make a failing individual push succeed.
> +
> OUTPUT
> ------
>[snip]
^ permalink raw reply
* Re: [PATCH v3 2/2] config: add "worktree" and "worktree/i" includeIf conditions
From: Phillip Wood @ 2026-05-12 15:07 UTC (permalink / raw)
To: Patrick Steinhardt, me; +Cc: git, Kristoffer Haugsbakk, Junio C Hamano
In-Reply-To: <agLTO0amktCWMsiE@pks.im>
On 12/05/2026 08:14, Patrick Steinhardt wrote:
> On Fri, Apr 03, 2026 at 03:02:29PM +0800, Chen Linxuan via B4 Relay wrote:
>> From: Chen Linxuan <me@black-desk.cn>
>>
>> The includeIf mechanism already supports matching on the .git
>> directory path (gitdir) and the currently checked out branch
>> (onbranch). But in multi-worktree setups the .git directory of a
>> linked worktree points into the main repository's .git/worktrees/
>> area, which makes gitdir patterns cumbersome when one wants to
>> include config based on the working tree's checkout path instead.
>>
>> Introduce two new condition keywords:
>>
>> - worktree:<pattern> matches the realpath of the current worktree's
>> working directory (i.e. repo_get_work_tree()) against a glob
>> pattern. This is the path returned by git rev-parse
>> --show-toplevel.
>>
>> - worktree/i:<pattern> is the case-insensitive variant.
>
> Seems sensible.
We already support per-worktree config settings via
extensions.worktreeConfig, so it would be helpful to explain why it is
more convenient to set the config based on the worktree's path, rather
than just running "git config --worktree" inside the worktree. Do you
have multiple repositories with worktrees checked out under a common
prefix that you want to share the same config setting?
Thanks
Phillip
>> The implementation reuses the include_by_path() helper introduced in
>> the previous commit, passing the worktree path in place of the
>> gitdir. The condition never matches in bare repositories (where
>> there is no worktree) or during early config reading (where no
>> repository is available).
>
> Right. This is because `repo_get_work_tree()` would return a NULL
> pointer in these cases, and `include_by_path()` exits early in that
> case.
>
>> diff --git a/Documentation/config.adoc b/Documentation/config.adoc
>> index 62eebe7c5450..a4f3ec905098 100644
>> --- a/Documentation/config.adoc
>> +++ b/Documentation/config.adoc
>> @@ -146,6 +146,48 @@ refer to linkgit:gitignore[5] for details. For convenience:
>> This is the same as `gitdir` except that matching is done
>> case-insensitively (e.g. on case-insensitive file systems)
>>
>> +`worktree`::
>> + The data that follows the keyword `worktree` and a colon is used as a
>> + glob pattern. If the working directory of the current worktree matches
>> + the pattern, the include condition is met.
>> ++
>> +The worktree location is the path where files are checked out (as returned
>> +by `git rev-parse --show-toplevel`). This is different from `gitdir`, which
>> +matches the `.git` directory path. In a linked worktree, the worktree path
>> +is the directory where that worktree's files are located, not the main
>> +repository's `.git` directory.
>> ++
>> +The pattern can contain standard globbing wildcards and two additional
>> +ones, `**/` and `/**`, that can match multiple path components. Please
>> +refer to linkgit:gitignore[5] for details. For convenience:
>> +
>> + * If the pattern starts with `~/`, `~` will be substituted with the
>> + content of the environment variable `HOME`.
>> +
>> + * If the pattern starts with `./`, it is replaced with the directory
>> + containing the current config file.
>> +
>> + * If the pattern does not start with either `~/`, `./` or `/`, `**/`
>> + will be automatically prepended. For example, the pattern `foo/bar`
>> + becomes `**/foo/bar` and would match `/any/path/to/foo/bar`.
>> +
>> + * If the pattern ends with `/`, `**` will be automatically added. For
>> + example, the pattern `foo/` becomes `foo/**`. In other words, it
>> + matches "foo" and everything inside, recursively.
>
> This whole listing here is the exact same as we have for the `gitdir`
> condition. Can we maybe deduplicate these into a common section?
>
>> diff --git a/config.c b/config.c
>> index 7d5dae0e8450..6d0c2d0725e4 100644
>> --- a/config.c
>> +++ b/config.c
>> @@ -400,6 +400,12 @@ static int include_condition_is_true(const struct key_value_info *kvi,
>> return include_by_path(kvi, opts->git_dir, cond, cond_len, 0);
>> else if (skip_prefix_mem(cond, cond_len, "gitdir/i:", &cond, &cond_len))
>> return include_by_path(kvi, opts->git_dir, cond, cond_len, 1);
>> + else if (skip_prefix_mem(cond, cond_len, "worktree:", &cond, &cond_len))
>> + return include_by_path(kvi, inc->repo ? repo_get_work_tree(inc->repo) : NULL,
>> + cond, cond_len, 0);
>> + else if (skip_prefix_mem(cond, cond_len, "worktree/i:", &cond, &cond_len))
>> + return include_by_path(kvi, inc->repo ? repo_get_work_tree(inc->repo) : NULL,
>> + cond, cond_len, 1);
>> else if (skip_prefix_mem(cond, cond_len, "onbranch:", &cond, &cond_len))
>> return include_by_branch(inc, cond, cond_len);
>> else if (skip_prefix_mem(cond, cond_len, "hasconfig:remote.*.url:", &cond,
>
> I feel like this is something that we might eventually want to convert
> to be table-driven. But I think that doesn't have to happen as part of
> this patch series.
>
>> diff --git a/t/t1305-config-include.sh b/t/t1305-config-include.sh
>> index 6e51f892f320..8a5ba4b884d3 100755
>> --- a/t/t1305-config-include.sh
>> +++ b/t/t1305-config-include.sh
>
> Just because it was explicitly mentioned: we might also want to have a
> test that verifies this works with early-config parsing. We already have
> a similar test for "gitdir:" in "conditional include, early config
> reading".
>
> And should we also have a "nongit" branch where we verify outside a
> repository?
>
> Other than that this series looks good to me, thanks!
>
> Patrick
>
^ permalink raw reply
* [PATCH v2 1/3] t0006: add support for approxidate test date adjustment
From: Tuomas Ahola @ 2026-05-12 14:54 UTC (permalink / raw)
To: git; +Cc: Jeff King, Tuomas Ahola
In-Reply-To: <20260512145430.13212-1-taahol@utu.fi>
t0006 uses a hard-coded test date and provides no convenient
way to override it temporarily. Add an optional parameter to
check_approxidate to adjust the time as needed, and demonstrate
the feature with a new test.
Signed-off-by: Tuomas Ahola <taahol@utu.fi>
---
t/t0006-date.sh | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/t/t0006-date.sh b/t/t0006-date.sh
index 53ced36df4..5d66267672 100755
--- a/t/t0006-date.sh
+++ b/t/t0006-date.sh
@@ -156,11 +156,18 @@ check_parse '2100-00-00 00:00:00 +11' bad
REQUIRE_64BIT_TIME=
check_approxidate() {
+ old_date=$GIT_TEST_DATE_NOW
+ GIT_TEST_DATE_NOW=$(($old_date${4:-+0}*60*60)); export GIT_TEST_DATE_NOW
+ caption=$1
+ if [ ! -z $4 ]; then
+ caption="$caption; offset $4h"
+ fi
echo "$1 -> $2 +0000" >expect
- test_expect_${3:-success} "parse approxidate ($1)" "
+ test_expect_${3:-success} "parse approxidate ($caption)" "
test-tool date approxidate '$1' >actual &&
test_cmp expect actual
"
+ GIT_TEST_DATE_NOW=$old_date; export GIT_TEST_DATE_NOW
}
check_approxidate now '2009-08-30 19:20:00'
@@ -182,6 +189,8 @@ check_approxidate 'noon today' '2009-08-30 12:00:00'
check_approxidate 'noon yesterday' '2009-08-29 12:00:00'
check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00'
check_approxidate '10am noon' '2009-08-29 12:00:00'
+check_approxidate 'January 5th yesterday' '2009-01-29 19:20:00'
+check_approxidate 'January 5th yesterday' '2008-12-31 19:20:00' success +48
check_approxidate 'last tuesday' '2009-08-25 19:20:00'
check_approxidate 'July 5th' '2009-07-05 19:20:00'
--
2.30.2
^ permalink raw reply related
* [PATCH v2 0/3] approxidate: tweak special date formats
From: Tuomas Ahola @ 2026-05-12 14:54 UTC (permalink / raw)
To: git; +Cc: Jeff King, Tuomas Ahola
In-Reply-To: <20250318180201.3653-1-taahol@utu.fi>
The approxidate system is an endless source of absurdities. Let's make the
usual "eh, that's crazy, let's do better with this input" type of fix[1], and
tweak some sharp edge cases, including one noticed by Linus back in 2006[2].
After this series, "tea" and "noon" will work predictably with all kinds of
date formats (today, yesterday, last Friday, January 5th, one year ago
yesterday...) regardless of the current time of day.
Links:
1. https://lore.kernel.org/git/20181115144854.GB16450@sigill.intra.peff.net/
2. https://lore.kernel.org/git/Pine.LNX.4.64.0610101102560.3952@g5.osdl.org/
Tuomas Ahola (3):
t0006: add support for approxidate test date adjustment
approxidate: make "specials" respect fixed day-of-month
approxidate: use deferred mday adjustments for "specials"
date.c | 37 +++++++++++++++++++++++++++----------
t/t0006-date.sh | 19 ++++++++++++++++++-
2 files changed, 45 insertions(+), 11 deletions(-)
Intervall-diff mot v1:
1: 9bfff739fd < -: ---------- approxidate: make "specials" respect fixed day-of-month
2: 1a4398e5a1 < -: ---------- approxidate: overwrite tm_mday for `now` and `yesterday`
-: ---------- > 1: 118f1825ac t0006: add support for approxidate test date adjustment
-: ---------- > 2: 21c4858c47 approxidate: make "specials" respect fixed day-of-month
-: ---------- > 3: cf72403102 approxidate: use deferred mday adjustments for "specials"
base-commit: c44beea485f0f2feaf460e2ac87fdd5608d63cf0
--
2.30.2
^ permalink raw reply
* [PATCH v2 2/3] approxidate: make "specials" respect fixed day-of-month
From: Tuomas Ahola @ 2026-05-12 14:54 UTC (permalink / raw)
To: git; +Cc: Jeff King, Tuomas Ahola
In-Reply-To: <20260512145430.13212-1-taahol@utu.fi>
The special approxidate time formats, "noon" and "tea", wrap
to the previous day if the current time is before 12 or 5 pm,
respectively. That holds even when an actual date is supplied;
therefore, "10 May at tea" and "last Friday at noon" can cause
the date to be set to a seemingly wrong day:
now -> 2026-05-12 11:00:00 +0000
10 May at tea -> 2026-05-09 17:00:00 +0000
last Friday at noon -> 2026-05-07 12:00:00 +0000
One year ago yesterday at tea-time -> 2025-05-10 17:00:00 +0000
The last example is from Linus Torvalds who remarked in 2006
that the answer was "just silly and not even correct." [1]
As "last Friday at noon" is mentioned in the documentation
(date-formats.adoc) it would be nice if it worked correctly.
Let's fix the glitch with a simple patch.
Check whether we already have a specified (non-negative) mday
and make date_time() stick to it. Add a suitable time offset
to the relevant test.
While we are at it, add "today" as an alias of "now", so that
"today at noon" will do the right thing, too, and assert that
with a new test.
Links:
1. https://lore.kernel.org/git/Pine.LNX.4.64.0610101102560.3952@g5.osdl.org/
Signed-off-by: Tuomas Ahola <taahol@utu.fi>
---
date.c | 8 +++++++-
t/t0006-date.sh | 4 ++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/date.c b/date.c
index 17a95077cf..e48cc2a4d7 100644
--- a/date.c
+++ b/date.c
@@ -1132,7 +1132,12 @@ static void date_yesterday(struct tm *tm, struct tm *now, int *num)
static void date_time(struct tm *tm, struct tm *now, int hour)
{
- if (tm->tm_hour < hour)
+ /*
+ * By default, "tea" and "noon" refer to last such time in the
+ * past, be it today or yesterday. With a specified mday,
+ * that logic is overridden.
+ */
+ if (tm->tm_mday < 0 && tm->tm_hour < hour)
update_tm(tm, now, 24*60*60);
tm->tm_hour = hour;
tm->tm_min = 0;
@@ -1204,6 +1209,7 @@ static const struct special {
{ "AM", date_am },
{ "never", date_never },
{ "now", date_now },
+ { "today", date_now },
{ NULL }
};
diff --git a/t/t0006-date.sh b/t/t0006-date.sh
index 5d66267672..e01d093514 100755
--- a/t/t0006-date.sh
+++ b/t/t0006-date.sh
@@ -186,8 +186,12 @@ check_approxidate '6pm yesterday' '2009-08-29 18:00:00'
check_approxidate '3:00' '2009-08-30 03:00:00'
check_approxidate '15:00' '2009-08-30 15:00:00'
check_approxidate 'noon today' '2009-08-30 12:00:00'
+check_approxidate 'today at noon' '2009-08-30 12:00:00' success -12
check_approxidate 'noon yesterday' '2009-08-29 12:00:00'
+check_approxidate 'last Friday at noon' '2009-08-28 12:00:00'
+check_approxidate 'last Friday at noon' '2009-08-28 12:00:00' success -12
check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00'
+check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00' success -12
check_approxidate '10am noon' '2009-08-29 12:00:00'
check_approxidate 'January 5th yesterday' '2009-01-29 19:20:00'
check_approxidate 'January 5th yesterday' '2008-12-31 19:20:00' success +48
--
2.30.2
^ permalink raw reply related
* [PATCH v2 3/3] approxidate: use deferred mday adjustments for "specials"
From: Tuomas Ahola @ 2026-05-12 14:54 UTC (permalink / raw)
To: git; +Cc: Jeff King, Tuomas Ahola
In-Reply-To: <20260512145430.13212-1-taahol@utu.fi>
There are cases where the "wrap-to-yesterday" behavior of "tea" and
"noon" should be reverted later on down the line, so that "today tea"
and "tea today" won't yield different results. However, the logic of
approxidate doesn't seem to lend itself particularly well to
such cases.
Start tackling the issue by reusing negative values of `tm->tm_mday`
field for deferred date adjustments which can be easily reverted, so
that the default logic of the special formats only applies if we don't
get any explicit date (mday) specification. In particular, overwrite
the field with -1 in "now" and "yesterday", so that those formats will
be relative to the current date. That makes specifications like "tea
yesterday" behave more sensibly: instead of going backwards to the
last tea-time and then a day back, Git will now understand that as the
tea-time of yesterday.
Replace the call of `update_tm()` in `date_time()` with the assignment
`tm->tm_mday = -2`. Add the corresponding code to handle that in
`update_tm()`, wrapping to the previous day if the field still holds
such assignment, meaning that we haven't seen any better specification
for the day-of-month. On the other hand, `mday=-3` would mean going
two days back and so on. Even though such functionality isn't
actually needed by this patch, it won't add much complexity in the
code and is rather natural way to handle such values.
As `date_time()` won't no longer need the `now` struct, mark the
associated function parameters as unused. The parameters themselves
have to stay, however, as those functions are called through pointers
in `approxidate_alpha`. Add relevant tests to cover the changes.
Signed-off-by: Tuomas Ahola <taahol@utu.fi>
---
date.c | 31 +++++++++++++++++++++----------
t/t0006-date.sh | 4 ++++
2 files changed, 25 insertions(+), 10 deletions(-)
diff --git a/date.c b/date.c
index e48cc2a4d7..795f1ac2e9 100644
--- a/date.c
+++ b/date.c
@@ -1071,13 +1071,22 @@ void datestamp(struct strbuf *out)
/*
* Relative time update (eg "2 days ago"). If we haven't set the time
* yet, we need to set it from current time.
+ *
+ * The tm->tm_mday field has an additional logic of using negative values
+ * for date adjustments: -2 means yesterday and -3 the day before that,
+ * and so on. The idea is to deref such adjustments until we are sure
+ * there's no explicit mday specification in the approxidate string.
*/
static time_t update_tm(struct tm *tm, struct tm *now, time_t sec)
{
time_t n;
- if (tm->tm_mday < 0)
+ if (tm->tm_mday < 0) {
+ int offset = tm->tm_mday + 1;
+ if (sec == 0 && offset < 0)
+ sec = -offset * 24*60*60;
tm->tm_mday = now->tm_mday;
+ }
if (tm->tm_mon < 0)
tm->tm_mon = now->tm_mon;
if (tm->tm_year < 0) {
@@ -1121,45 +1130,47 @@ static void pending_number(struct tm *tm, int *num)
static void date_now(struct tm *tm, struct tm *now, int *num)
{
*num = 0;
+ tm->tm_mday = -1;
update_tm(tm, now, 0);
}
static void date_yesterday(struct tm *tm, struct tm *now, int *num)
{
*num = 0;
+ tm->tm_mday = -1;
update_tm(tm, now, 24*60*60);
}
-static void date_time(struct tm *tm, struct tm *now, int hour)
+static void date_time(struct tm *tm, int hour)
{
/*
* By default, "tea" and "noon" refer to last such time in the
* past, be it today or yesterday. With a specified mday,
- * that logic is overridden.
+ * or e.g. "noon today", that logic is overridden.
*/
if (tm->tm_mday < 0 && tm->tm_hour < hour)
- update_tm(tm, now, 24*60*60);
+ tm->tm_mday = -2; /* eventually handled by update_tm() */
tm->tm_hour = hour;
tm->tm_min = 0;
tm->tm_sec = 0;
}
-static void date_midnight(struct tm *tm, struct tm *now, int *num)
+static void date_midnight(struct tm *tm, struct tm *now UNUSED, int *num)
{
pending_number(tm, num);
- date_time(tm, now, 0);
+ date_time(tm, 0);
}
-static void date_noon(struct tm *tm, struct tm *now, int *num)
+static void date_noon(struct tm *tm, struct tm *now UNUSED, int *num)
{
pending_number(tm, num);
- date_time(tm, now, 12);
+ date_time(tm, 12);
}
-static void date_tea(struct tm *tm, struct tm *now, int *num)
+static void date_tea(struct tm *tm, struct tm *now UNUSED, int *num)
{
pending_number(tm, num);
- date_time(tm, now, 17);
+ date_time(tm, 17);
}
static void date_pm(struct tm *tm, struct tm *now UNUSED, int *num)
diff --git a/t/t0006-date.sh b/t/t0006-date.sh
index e01d093514..2670269bb7 100755
--- a/t/t0006-date.sh
+++ b/t/t0006-date.sh
@@ -187,9 +187,13 @@ check_approxidate '3:00' '2009-08-30 03:00:00'
check_approxidate '15:00' '2009-08-30 15:00:00'
check_approxidate 'noon today' '2009-08-30 12:00:00'
check_approxidate 'today at noon' '2009-08-30 12:00:00' success -12
+check_approxidate 'noon today' '2009-09-01 12:00:00' success +36
check_approxidate 'noon yesterday' '2009-08-29 12:00:00'
check_approxidate 'last Friday at noon' '2009-08-28 12:00:00'
check_approxidate 'last Friday at noon' '2009-08-28 12:00:00' success -12
+check_approxidate 'noon yesterday' '2009-08-29 12:00:00' success -12
+check_approxidate 'tea last saturday' '2009-08-29 17:00:00'
+check_approxidate 'tea last saturday' '2009-08-29 17:00:00' success -12
check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00'
check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00' success -12
check_approxidate '10am noon' '2009-08-29 12:00:00'
--
2.30.2
^ permalink raw reply related
* Re: [PATCH] rebase: ignore non-branch update-refs
From: Phillip Wood @ 2026-05-12 15:10 UTC (permalink / raw)
To: Abhinav Gupta, Junio C Hamano; +Cc: git, Derrick Stolee
In-Reply-To: <63e04828-5895-4deb-a698-5d6f494d23f3@app.fastmail.com>
On 11/05/2026 01:15, Abhinav Gupta wrote:
>
> On Sun, May 10, 2026, at 16:37, Junio C Hamano wrote:
>> it would have failed to work due to the "HEAD" thing, so even though
>> existing versions of Git may have added such local tags to the insn
>> sequence, it would not have been a workable configuration anyway.
>
> Yeah. One additional data point:
> non-interactive rebase is also broken under this configuration.
I assume you mean that rebase.instructionFormat includes "%d" and
rebase.updateRefs is true below.
> Given a branch off main~1, it runs into the same issue:
>
> $ git checkout -b foo main~1
> $ git commit --allow-empty -m 'do things'
> $ git rebase main
> # ...
> error: update-ref requires a fully qualified refname e.g. refs/heads/HEAD
> error: invalid line 2: update-ref HEAD
> You can fix this with 'git rebase --edit-todo' and then run 'git rebase --continue'.
> Or you can abort the rebase with 'git rebase --abort'.
>
> I'm guessing non-interactive rebase works off the same todo list so that makes sense.
Yes, barring some special cases where we don't support updating refs
"git rebase <options>"
is essentially
GIT_SEQUENCE_EDITOR=: git rebase -i <options>
Thanks
Phillip
^ permalink raw reply
* Re: [PATCH v3 7/7] send-pack: pass negotiation config in push
From: Matthew John Cheetham @ 2026-05-12 15:14 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <e6c79f0661b97d0081ae36b17be8ccb3b9ec64e4.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> When push.negotiate is enabled, 'git push' spawns a child 'git fetch
> --negotiate-only' process to find common commits. Pass
> --negotiation-include and --negotiation-restrict options from the
> 'remote.<name>.negotiationInclude' and
> 'remote.<name>.negotiationRestrict' config keys to this child process.
>
> When negotiationRestrict is configured, it replaces the default
> behavior of using all remote refs as negotiation tips. This allows
> the user to control which local refs are used for push negotiation.
>
> When negotiationInclude is configured, the specified ref patterns
> are passed as --negotiation-include to ensure their tips are always
> sent as 'have' lines during push negotiation.
>
> This change also updates the use of --negotiation-tip into
> --negotiation-restrict now that the new synonym exists.
>
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> send-pack.c | 39 +++++++++++++++++++++++++++++++--------
> send-pack.h | 2 ++
> t/t5516-fetch-push.sh | 30 ++++++++++++++++++++++++++++++
> transport.c | 2 ++
> 4 files changed, 65 insertions(+), 8 deletions(-)
This patch wires up the negotiation behaviour with push, added in the
previous patches.
> diff --git a/send-pack.c b/send-pack.c
> index 67d6987b1c..d18e030ce8 100644
> --- a/send-pack.c
> +++ b/send-pack.c
> @@ -433,28 +433,48 @@ static void reject_invalid_nonce(const char *nonce, int len)
>
> static void get_commons_through_negotiation(struct repository *r,
> const char *url,
> + const struct string_list *negotiation_include,
> + const struct string_list *negotiation_restrict,
> const struct ref *remote_refs,
> struct oid_array *commons)
> {
> struct child_process child = CHILD_PROCESS_INIT;
> const struct ref *ref;
> int len = r->hash_algo->hexsz + 1; /* hash + NL */
> - int nr_negotiation_tip = 0;
> + int nr_negotiation = 0;
>
> child.git_cmd = 1;
> child.no_stdin = 1;
> child.out = -1;
> strvec_pushl(&child.args, "fetch", "--negotiate-only", NULL);
> - for (ref = remote_refs; ref; ref = ref->next) {
> - if (!is_null_oid(&ref->new_oid)) {
> - strvec_pushf(&child.args, "--negotiation-tip=%s",
> - oid_to_hex(&ref->new_oid));
> - nr_negotiation_tip++;
> +
> + if (negotiation_restrict && negotiation_restrict->nr) {
> + struct string_list_item *item;
> + for_each_string_list_item(item, negotiation_restrict)
> + strvec_pushf(&child.args, "--negotiation-restrict=%s",
> + item->string);
> + nr_negotiation = negotiation_restrict->nr;
> + } else {
> + for (ref = remote_refs; ref; ref = ref->next) {
> + if (!is_null_oid(&ref->new_oid)) {
> + strvec_pushf(&child.args, "--negotiation-restrict=%s",
> + oid_to_hex(&ref->new_oid));
> + nr_negotiation++;
> + }
> }
> }
> +
> + if (negotiation_include && negotiation_include->nr) {
> + struct string_list_item *item;
> + for_each_string_list_item(item, negotiation_include)
> + strvec_pushf(&child.args, "--negotiation-include=%s",
> + item->string);
> + nr_negotiation += negotiation_include->nr;
> + }
> +
> strvec_push(&child.args, url);
>
> - if (!nr_negotiation_tip) {
> + if (!nr_negotiation) {
> child_process_clear(&child);
> return;
> }
Reads cleanly, and also updates the calls to fetch to use the new
preferred option name `restrict` vs the older `tip`. Nice!
> @@ -528,7 +548,10 @@ int send_pack(struct repository *r,
> repo_config_get_bool(r, "push.negotiate", &push_negotiate);
> if (push_negotiate) {
> trace2_region_enter("send_pack", "push_negotiate", r);
> - get_commons_through_negotiation(r, args->url, remote_refs, &commons);
> + get_commons_through_negotiation(r, args->url,
> + args->negotiation_include,
> + args->negotiation_restrict,
> + remote_refs, &commons);
> trace2_region_leave("send_pack", "push_negotiate", r);
> }
>
> diff --git a/send-pack.h b/send-pack.h
> index c5ded2d200..13850c98bb 100644
> --- a/send-pack.h
> +++ b/send-pack.h
> @@ -18,6 +18,8 @@ struct repository;
>
> struct send_pack_args {
> const char *url;
> + const struct string_list *negotiation_include;
> + const struct string_list *negotiation_restrict;
> unsigned verbose:1,
> quiet:1,
> porcelain:1,
> diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
> index ac8447f21e..177cbc6c75 100755
> --- a/t/t5516-fetch-push.sh
> +++ b/t/t5516-fetch-push.sh
> @@ -254,6 +254,36 @@ test_expect_success 'push with negotiation does not attempt to fetch submodules'
> ! grep "Fetching submodule" err
> '
>
> +test_expect_success 'push with negotiation and remote.<name>.negotiationInclude' '
> + test_when_finished rm -rf negotiation_include &&
> + mk_empty negotiation_include &&
> + git push negotiation_include $the_first_commit:refs/remotes/origin/first_commit &&
> + test_commit -C negotiation_include unrelated_commit &&
> + git -C negotiation_include config receive.hideRefs refs/remotes/origin/first_commit &&
> + test_when_finished "rm event" &&
> + GIT_TRACE2_EVENT="$(pwd)/event" \
> + git -c protocol.version=2 -c push.negotiate=1 \
> + -c remote.negotiation_include.negotiationInclude=refs/heads/main \
> + push negotiation_include refs/heads/main:refs/remotes/origin/main &&
> + test_grep \"key\":\"total_rounds\" event &&
> + grep_wrote 2 event # 1 commit, 1 tree
> +'
> +
> +test_expect_success 'push with negotiation and remote.<name>.negotiationRestrict' '
> + test_when_finished rm -rf negotiation_restrict &&
> + mk_empty negotiation_restrict &&
> + git push negotiation_restrict $the_first_commit:refs/remotes/origin/first_commit &&
> + test_commit -C negotiation_restrict unrelated_commit &&
> + git -C negotiation_restrict config receive.hideRefs refs/remotes/origin/first_commit &&
> + test_when_finished "rm event" &&
> + GIT_TRACE2_EVENT="$(pwd)/event" \
> + git -c protocol.version=2 -c push.negotiate=1 \
> + -c remote.negotiation_restrict.negotiationRestrict=refs/heads/main \
> + push negotiation_restrict refs/heads/main:refs/remotes/origin/main &&
> + test_grep \"key\":\"total_rounds\" event &&
> + grep_wrote 2 event # 1 commit, 1 tree
> +'
> +
> test_expect_success 'push without wildcard' '
> mk_empty testrepo &&
>
> diff --git a/transport.c b/transport.c
> index 8a2d8adffc..60b73feb34 100644
> --- a/transport.c
> +++ b/transport.c
> @@ -921,6 +921,8 @@ static int git_transport_push(struct transport *transport, struct ref *remote_re
> args.atomic = !!(flags & TRANSPORT_PUSH_ATOMIC);
> args.push_options = transport->push_options;
> args.url = transport->url;
> + args.negotiation_include = &transport->remote->negotiation_include;
> + args.negotiation_restrict = &transport->remote->negotiation_restrict;
>
> if (flags & TRANSPORT_PUSH_CERT_ALWAYS)
> args.push_cert = SEND_PACK_PUSH_CERT_ALWAYS;
I've got no other comments on this patch :-)
Thanks,
Matthew
^ permalink raw reply
* Re: [PATCH v3 3/7] odb: update `struct odb_write_stream` read() callback
From: Justin Tobler @ 2026-05-12 15:19 UTC (permalink / raw)
To: Jeff King; +Cc: git, ps, gitster
In-Reply-To: <20260511175835.GA4811@coredump.intra.peff.net>
On 26/05/11 01:58PM, Jeff King wrote:
> On Thu, Apr 02, 2026 at 04:32:16PM -0500, Justin Tobler wrote:
>
> > @@ -1098,9 +1099,16 @@ int odb_source_loose_write_stream(struct odb_source *source,
> > unsigned char *in0 = stream.next_in;
> >
> > if (!stream.avail_in && !in_stream->is_finished) {
> > - const void *in = in_stream->read(in_stream, &stream.avail_in);
> > - stream.next_in = (void *)in;
> > - in0 = (unsigned char *)in;
> > + ssize_t read_len = odb_write_stream_read(in_stream, buf,
> > + sizeof(buf));
> > + if (read_len < 0) {
> > + err = -1;
> > + goto cleanup;
> > + }
> > +
> > + stream.avail_in = read_len;
> > + stream.next_in = buf;
> > + in0 = buf;
>
> If we hit this "goto cleanup", we'll leak the "fd" descriptor opened
> earlier. We either need to close(fd) here, or do so in the cleanup
> handler (but that means consistently setting fd to a sentinel value
> after we close it, which we do not currently do).
>
> Noticed by Coverity (I guess this series just hit "jch", since it's
> "new" as of today's run).
Thanks, I will send another version to fix this leak.
-Justin
^ permalink raw reply
* Re: [PATCH v3 2/2] config: add "worktree" and "worktree/i" includeIf conditions
From: Junio C Hamano @ 2026-05-12 16:09 UTC (permalink / raw)
To: Phillip Wood; +Cc: Patrick Steinhardt, me, git, Kristoffer Haugsbakk
In-Reply-To: <d236de15-f03e-4bac-9eb1-c14e50a271f5@gmail.com>
Phillip Wood <phillip.wood123@gmail.com> writes:
> On 12/05/2026 08:14, Patrick Steinhardt wrote:
> ...
>>> Introduce two new condition keywords:
>>>
>>> - worktree:<pattern> matches the realpath of the current worktree's
>>> working directory (i.e. repo_get_work_tree()) against a glob
>>> pattern. This is the path returned by git rev-parse
>>> --show-toplevel.
>>>
>>> - worktree/i:<pattern> is the case-insensitive variant.
>>
>> Seems sensible.
>
> We already support per-worktree config settings via
> extensions.worktreeConfig, so it would be helpful to explain why it is
> more convenient to set the config based on the worktree's path, rather
> than just running "git config --worktree" inside the worktree. Do you
> have multiple repositories with worktrees checked out under a common
> prefix that you want to share the same config setting?
Excellent point. The documentation for the new feature may have to
be strengthened to suggest which one of the two approaches is more
appropriate with what use cases.
Thanks.
^ permalink raw reply
* [PATCH] pretty: drop strbuf pre-sizing from add_rfc2047()
From: Jeff King @ 2026-05-12 16:20 UTC (permalink / raw)
To: git; +Cc: Luke Martin
At the top of add_rfc2047() we do this:
strbuf_grow(sb, len * 3 + strlen(encoding) + 100);
where "len" is the size of the header (like an author name) we are about
to encode into the buffer. This pre-sizing is purely an optimization; we
use strbuf_addf() and friends to actually write into the buffer, and
they will grow the buffer as necessary.
But there's a problem with the code above: the input can be arbitrarily
large, so we might overflow a size_t while doing that computation,
ending up with a too-small allocation request. Overflowing requires an
impractically large input on a 64-bit system, but is easy to demonstrate
on a 32-bit system with a commit whose author name is ~1.4GB.
Because this pre-sizing is just an optimization, there's no real harm.
We'll start with a smaller buffer and grow it as necessary. But it
_looks_ like a vulnerability, since some other code may pre-size a
strbuf and then write directly into its buffer. So it's worth avoiding
the overflow in the first place.
The obvious way to do that is via checked operations like st_add() and
friends. But taking a step back, is this pre-sizing actually helping
anything?
The computation goes all the way back to 4234a76167 (Extend
--pretty=oneline to cover the first paragraph,, 2007-06-11), but back
then we really were sizing the array to write into directly! In
674d172730 (Rework pretty_print_commit to use strbufs instead of custom
buffers., 2007-09-10) that switched to a strbuf, and at that point it
was a pure optimization.
Is the optimization helping? I don't think so. Even for a gigantic case
like the 1.4GB author name, I couldn't measure any slowdown when
removing it. And most input will be much smaller, and added to a running
strbuf containing the rest of the email-header output. We can just rely
on strbuf's usual amortized-linear growth.
So deleting the line seems like the best way to go. It eliminates the
integer overflow and makes the code a tiny bit simpler.
Reported-by: Luke Martin <lmartin@paramenoeng.com>
Signed-off-by: Jeff King <peff@peff.net>
---
pretty.c | 1 -
1 file changed, 1 deletion(-)
diff --git a/pretty.c b/pretty.c
index 814803980b..7328aecf5d 100644
--- a/pretty.c
+++ b/pretty.c
@@ -399,7 +399,6 @@ static void add_rfc2047(struct strbuf *sb, const char *line, size_t len,
int i;
int line_len = last_line_length(sb);
- strbuf_grow(sb, len * 3 + strlen(encoding) + 100);
strbuf_addf(sb, "=?%s?q?", encoding);
line_len += strlen(encoding) + 5; /* 5 for =??q? */
--
2.54.0.420.gf0bcdff42b
^ permalink raw reply related
* [PATCH] http: handle absolute-path alternates from server root
From: Jeff King @ 2026-05-12 16:26 UTC (permalink / raw)
To: git; +Cc: slonkazoid
When a dumb http server reports alternates with an absolute path, we try
to paste that onto the root of the URL we're trying to fetch from. So if
we go to "http://example.com/path/to/child.git" and it tells us about an
alternate at "/parent.git", we'll hit "http://example.com/parent.git".
But there's a bug in computing the base when the URL does not have any
path component at all, like "http://example.com". When looking for the
first slash after the host, strchr() returns NULL, and we compute a
nonsense value for the length of the host portion. And then when we use
that length to copy the base of the URL into a strbuf, we're likely to
fail.
The security implications are minimal here. We store the nonsense length
("serverlen") as an int, so on a 64-bit system it may effectively be
anything (it is zero minus a 64-bit heap pointer, then truncated to
32-bits and stuffed into a signed value). When we feed that length to
strbuf_add(), it is cast into a size_t and one of four things will
happen:
1. If serverlen was negative, it will turn into a very large positive
value and strbuf_add() will fail to allocate, ending the program.
Ditto if serverlen was positive but just very large.
This doesn't really get an attacker anything; the victim will just
fail to clone their evil repo.
2. If serverlen was small enough, we'll successfully extend the target
strbuf, and then copy an arbitrary set of bytes from "base". And
then one of these is true:
a. That set of bytes is much larger than the length of the "base"
string. This is an out-of-bounds read, but there's no
out-of-bounds write, since the strbuf code both allocates and
copies using the same size_t. This is likely to cause a
segfault as we try to read unmapped pages of memory.
b. Like (2a), but if the set of bytes is small enough we might
not segfault. We might read random memory from the process and
copy it into the "target" strbuf.
What happens then? We know that "base" ends with a NUL
terminator, which will be copied into "target" as well. So
even though target.len might be 1000 bytes (or whatever), when
interpreted as a NUL-terminated string, target.buf is still
the exact same string as "base".
And that's all we ever do with target: pass it around as a C
string, and then eventually strbuf_detach() it to become a C
string. So even though there was arbitrary memory copied into
the strbuf, we never access it.
c. The other interesting case is when serverlen is actually
_shorter_ than the length of base. And there we truncate the
string. Probably in a way that makes it totally invalid, but
if you were very unlucky you could turn something like:
http://victim.com.evil.domain:8000
into:
http://victim.com
Which looks like the start of a redirect attack, except that
the attacker could just have written "http://victim.com" in
the first place! Either way we feed it to
is_alternate_allowed(), which is where we check redirect and
protocol rules.
I think we can just treat this like a regular bug.
And it's quite a weird setup in the first place, as it implies that the
root of the web server is serving a repository (i.e., that you can get
something useful from "http://example.com/info/refs"). The bug has been
there since b3661567cf ([PATCH] Add support for alternates in HTTP,
2005-09-14) without anybody noticing.
I kind of doubt anybody really cares about making this work, but it's
easy enough to do so: the host-portion of the URL ends at either the
first slash or the end-of-string. So we can just replace strchr() with
strchrnul().
The test setup is a little gross, as we take over the httpd document
root by shoving our bare-repo components into it. But it demonstrates
the problem and shows that our solution actually allows the alternate to
function, if the server is configured to allow it.
Reported-by: slonkazoid <slonkazoid@slonk.ing>
Signed-off-by: Jeff King <peff@peff.net>
---
http-walker.c | 2 +-
t/t5550-http-fetch-dumb.sh | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/http-walker.c b/http-walker.c
index 1b6d496548..f252de089f 100644
--- a/http-walker.c
+++ b/http-walker.c
@@ -268,7 +268,7 @@ static void process_alternates_response(void *callback_data)
*/
const char *colon_ss = strstr(base,"://");
if (colon_ss) {
- serverlen = (strchr(colon_ss + 3, '/')
+ serverlen = (strchrnul(colon_ss + 3, '/')
- base);
okay = 1;
}
diff --git a/t/t5550-http-fetch-dumb.sh b/t/t5550-http-fetch-dumb.sh
index 9d0a7f5c4b..b0080bf204 100755
--- a/t/t5550-http-fetch-dumb.sh
+++ b/t/t5550-http-fetch-dumb.sh
@@ -555,4 +555,24 @@ test_expect_success 'dumb http can fetch index v1' '
git -C idx-v1 fsck
'
+test_expect_success 'absolute-path alternate when url has no path' '
+ src=$HTTPD_DOCUMENT_ROOT_PATH/repo.git &&
+ alt=absolute-alt.git &&
+ git clone --bare --shared "$src" "$alt" &&
+
+ # Our repo has an alternate pointing to the absolute filesystem path,
+ # but that will not make any sense to an http client. So we will
+ # manually give it the equivalent path that the http server will
+ # understand.
+ echo "/dumb/repo.git/objects" >"$alt/objects/info/http-alternates" &&
+
+ # Now make our alt repository available at the root of the http
+ # server without any path (i.e., just http://localhost:1234).
+ git -C "$alt" update-server-info &&
+ mv absolute-alt.git/* "$HTTPD_DOCUMENT_ROOT_PATH" &&
+
+ git -c http.followRedirects=true clone "$HTTPD_URL" alt-clone.git 2>err &&
+ test_grep "adding alternate object store: $HTTPD_URL/dumb/repo.git" err
+'
+
test_done
--
2.54.0.420.gf0bcdff42b
^ permalink raw reply related
* Re: [PATCH v2 1/3] t0006: add support for approxidate test date adjustment
From: Junio C Hamano @ 2026-05-12 16:34 UTC (permalink / raw)
To: Tuomas Ahola; +Cc: git, Jeff King
In-Reply-To: <20260512145430.13212-2-taahol@utu.fi>
Tuomas Ahola <taahol@utu.fi> writes:
> check_approxidate() {
> + old_date=$GIT_TEST_DATE_NOW
> + GIT_TEST_DATE_NOW=$(($old_date${4:-+0}*60*60)); export GIT_TEST_DATE_NOW
> + caption=$1
> + if [ ! -z $4 ]; then
> + caption="$caption; offset $4h"
> + fi
As you are not doing the test-date-now adjustment when $4 is not
given, wouldn't it be a lot easier to read if you did something like
old_date=$GIT_TEST_DATE_NOW
if test -n "$4"
then
# the convention for $4 is a bit weird in that it
# comes with its own +/- operator in front.
GIT_TEST_DATE_NOW=$(( $old_date $4 * 60 * 60 ))
caption="$1; offset $4h"
else
caption=$1
fi
instead? Other two minor points are
- Documentation/SubmittingPatches prefers an explicit "test" over
"[ ... ]", and have "then", "else", etc. on their own lines.
- As you never "unset" GIT_TEST_DATE_NOW, you do not have to keep
exporting it. It is not like there are two variables (one for
shell, the other for environment) and every time you set the
shell one you need to export to reflect the value to the
environment one. Rather, a single "export" marks a shell
variable and every time it changes value, it is updated in the
environment as well.
> echo "$1 -> $2 +0000" >expect
> - test_expect_${3:-success} "parse approxidate ($1)" "
> + test_expect_${3:-success} "parse approxidate ($caption)" "
> test-tool date approxidate '$1' >actual &&
> test_cmp expect actual
> "
> + GIT_TEST_DATE_NOW=$old_date; export GIT_TEST_DATE_NOW
> }
>
> check_approxidate now '2009-08-30 19:20:00'
> @@ -182,6 +189,8 @@ check_approxidate 'noon today' '2009-08-30 12:00:00'
> check_approxidate 'noon yesterday' '2009-08-29 12:00:00'
> check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00'
> check_approxidate '10am noon' '2009-08-29 12:00:00'
> +check_approxidate 'January 5th yesterday' '2009-01-29 19:20:00'
> +check_approxidate 'January 5th yesterday' '2008-12-31 19:20:00' success +48
>
> check_approxidate 'last tuesday' '2009-08-25 19:20:00'
> check_approxidate 'July 5th' '2009-07-05 19:20:00'
^ permalink raw reply
* Re: [PATCH v2 2/3] approxidate: make "specials" respect fixed day-of-month
From: Junio C Hamano @ 2026-05-12 16:52 UTC (permalink / raw)
To: Tuomas Ahola; +Cc: git, Jeff King
In-Reply-To: <20260512145430.13212-3-taahol@utu.fi>
Tuomas Ahola <taahol@utu.fi> writes:
> The special approxidate time formats, "noon" and "tea", wrap
> to the previous day if the current time is before 12 or 5 pm,
> respectively. That holds even when an actual date is supplied;
> therefore, "10 May at tea" and "last Friday at noon" can cause
> the date to be set to a seemingly wrong day:
>
> now -> 2026-05-12 11:00:00 +0000
> 10 May at tea -> 2026-05-09 17:00:00 +0000
> last Friday at noon -> 2026-05-07 12:00:00 +0000
It would help readers to say that Friday of that week was May 8th to
make it easier for them to see why this is a wrong answer.
> One year ago yesterday at tea-time -> 2025-05-10 17:00:00 +0000
>
> The last example is from Linus Torvalds who remarked in 2006
> that the answer was "just silly and not even correct." [1]
It may be just me, but it was hard for me to guess if you are
justifying how these answers are correct, or you are presenting
examples of wrong output. Perhaps starting the paragraph with cases
where the "wrap to the previous" gets right to set the stage may
make it easier to understand? Let's see...
The ... 'noon' and 'tea', wrap to the ... before 12 or 5 pm,
respectively. So for example if it is 11am on 2026-05-12, then
now -> 2026-05-12 11:00:00 +0000
noon -> 2026-05-11 11:00:00 +0000
tea -> 2026-05-11 17:00:00 +0000
which would work well when you ask for "git log --since=tea",
for example.
That hold even when ... seemingly wrong day:
> As "last Friday at noon" is mentioned in the documentation
> (date-formats.adoc) it would be nice if it worked correctly.
> Let's fix the glitch with a simple patch.
>
> Check whether we already have a specified (non-negative) mday
> and make date_time() stick to it. Add a suitable time offset
> to the relevant test.
>
> While we are at it, add "today" as an alias of "now", so that
> "today at noon" will do the right thing, too, and assert that
> with a new test.
>
> Links:
> 1. https://lore.kernel.org/git/Pine.LNX.4.64.0610101102560.3952@g5.osdl.org/
>
> Signed-off-by: Tuomas Ahola <taahol@utu.fi>
> ---
> date.c | 8 +++++++-
> t/t0006-date.sh | 4 ++++
> 2 files changed, 11 insertions(+), 1 deletion(-)
>
> diff --git a/date.c b/date.c
> index 17a95077cf..e48cc2a4d7 100644
> --- a/date.c
> +++ b/date.c
> @@ -1132,7 +1132,12 @@ static void date_yesterday(struct tm *tm, struct tm *now, int *num)
>
> static void date_time(struct tm *tm, struct tm *now, int hour)
> {
> - if (tm->tm_hour < hour)
> + /*
> + * By default, "tea" and "noon" refer to last such time in the
> + * past, be it today or yesterday. With a specified mday,
> + * that logic is overridden.
> + */
Again, this may be just me, but I happen to find the version of
comment in Peff's review on the earlier iteration of this series
much easier to understand.
> + if (tm->tm_mday < 0 && tm->tm_hour < hour)
> update_tm(tm, now, 24*60*60);
> tm->tm_hour = hour;
> tm->tm_min = 0;
> @@ -1204,6 +1209,7 @@ static const struct special {
> { "AM", date_am },
> { "never", date_never },
> { "now", date_now },
> + { "today", date_now },
Hmph, this may not work very well for "git log --since=today", which
you may want to stop immediately when the traversal reaches a patch
written before the most recent midnight, instead of stopping without
giving anything back.
> { NULL }
> };
>
> diff --git a/t/t0006-date.sh b/t/t0006-date.sh
> index 5d66267672..e01d093514 100755
> --- a/t/t0006-date.sh
> +++ b/t/t0006-date.sh
> @@ -186,8 +186,12 @@ check_approxidate '6pm yesterday' '2009-08-29 18:00:00'
> check_approxidate '3:00' '2009-08-30 03:00:00'
> check_approxidate '15:00' '2009-08-30 15:00:00'
> check_approxidate 'noon today' '2009-08-30 12:00:00'
> +check_approxidate 'today at noon' '2009-08-30 12:00:00' success -12
> check_approxidate 'noon yesterday' '2009-08-29 12:00:00'
> +check_approxidate 'last Friday at noon' '2009-08-28 12:00:00'
> +check_approxidate 'last Friday at noon' '2009-08-28 12:00:00' success -12
> check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00'
> +check_approxidate 'January 5th noon pm' '2009-01-05 12:00:00' success -12
> check_approxidate '10am noon' '2009-08-29 12:00:00'
> check_approxidate 'January 5th yesterday' '2009-01-29 19:20:00'
> check_approxidate 'January 5th yesterday' '2008-12-31 19:20:00' success +48
^ permalink raw reply
* Re: [PATCH v3 5/7] fetch: add --negotiation-include option for negotiation
From: Derrick Stolee @ 2026-05-12 16:54 UTC (permalink / raw)
To: Matthew John Cheetham, Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps
In-Reply-To: <VI0PR03MB1163403743E62E0FDC4AFE52DC0392@VI0PR03MB11634.eurprd03.prod.outlook.com>
On 5/12/26 10:38 AM, Matthew John Cheetham wrote:
> On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
>> From: Derrick Stolee <stolee@gmail.com>
>>
>> Add a new --negotiation-include option to 'git fetch', which ensures
>> that certain ref tips are always sent as 'have' lines during fetch
>> negotiation, regardless of what the negotiation algorithm selects.
>>
>> This is useful when the repository has a large number of references, so
>> the normal negotiation algorithm truncates the list. This is especially
>> important in repositories with long parallel commit histories. For
>> example, a repo could have a 'dev' branch for development and a
>> 'release' branch for released versions. If the 'dev' branch isn't
>> selected for negotiation, then it's not a big deal because there are
>> many in-progress development branches with a shared history. However, if
>> 'release' is not selected for negotiation, then the server may think
>> that this is the first time the client has asked for that reference,
>> causing a full download of its parallel commit history (and any extra
>> data that may be unique to that branch). This is based on a real example
>> where certain fetches would grow to 60+ GB when a release branch
>> updated.
>>
>> This option is a complement to --negotiation-restrict, which reduces the
>> negotiation ref set to a specific list. In the earlier example, using
>> --negotiation-restrict to focus the negotiation to 'dev' and 'release'
>> would avoid those problematic downloads, but would still not allow
>> advertising potentially-relevant user brances. In this way, the
>> 'include' version solves the problem I mention while allowing
>> negotiation to pick other references opportunistically. The two options
>> can also be combined to allow the best of both worlds.
>
> Nice explanation and motivation for the need of such as feature.
>
> One small typo: s/brances/branches/
Thanks.
>> --- a/Documentation/fetch-options.adoc
>> +++ b/Documentation/fetch-options.adoc
>> @@ -73,6 +73,25 @@ See also the `fetch.negotiationAlgorithm` and `push.negotiate`
>> configuration variables documented in linkgit:git-config[1], and the
>> `--negotiate-only` option below.
>> +`--negotiation-include=<revision>`::
>> + Ensure that the given ref tip is always sent as a "have" line
>> + during fetch negotiation, regardless of what the negotiation
>> + algorithm selects. This is useful to guarantee that common
>> + history reachable from specific refs is always considered, even
>> + when `--negotiation-restrict` restricts the set of tips or when
>> + the negotiation algorithm would otherwise skip them.
>> ++
>> +This option may be specified more than once; if so, each ref is sent
>> +unconditionally.
>> ++
>> +The argument may be an exact ref name (e.g. `refs/heads/release`) or a
>> +glob pattern (e.g. `refs/heads/release/{asterisk}`). The pattern syntax
>> +is the same as for `--negotiation-restrict`.
>> ++
>> +If `--negotiation-restrict` is used, the have set is first restricted by
>> +that option and then increased to include the tips specified by
>> +`--negotiation-include`.
>> +
>
> The placeholder `<revision>` and the description in the body of "ref
> name or glob" slightly disagree with each other. The `--negotiation-restrict`
> docs use `(<commit>|<glob>)` in the syntax definition and
> "a glob on ref names, a ref, or .. SHA-1 of a commit".
Good eye.
> `resolve_negotiation_include()` calls `repo_get_oid()` for non-globs
> so bare OIDs and abbreviated SHAs work too. Perhaps consider aligning the
> syntaxes, and mention that OIDs work too.
Will do.
>> @@ -1547,10 +1548,14 @@ static void add_negotiation_restrict_tips(struct
>> git_transport_options *smart_op
>> int old_nr;
>> if (!has_glob_specials(s)) {
>> struct object_id oid;
>> +
>> + /* Ignore missing reference. */
>> if (repo_get_oid(the_repository, s, &oid))
>> - die(_("%s is not a valid object"), s);
>> + continue;
>> + /* Fail on missing object pointed by ref. */
>> if (!odb_has_object(the_repository->objects, &oid, 0))
>> die(_("the object %s does not exist"), s);
>> +
>> oid_array_append(oids, &oid);
>> continue;
>> }
>
> This is the change in behaviour - unresolvable revs were a fatal error
> and are now silently ignored.
>
> Note that t5510 '--negotiation-tip rejects missing OIDs' still passes
> because it uses an all-zero OID, which parses as a valid hex string,
> and dies on the second check "object does not exist". Using something
> like `--negotiation-tip=notreal` that previously would error will now
> silently be ignored.
>
> Is it worth another test? (invalid object vs not exists)?
Yes, let's add a test to guarantee this behavior works.
>> @@ -1615,6 +1620,13 @@ static struct transport *prepare_transport(struct
>> remote *remote, int deepen,
>> strbuf_release(&config_name);
>> }
>> }
>> + if (negotiation_include.nr) {
>> + if (transport->smart_options)
>> + transport->smart_options->negotiation_include =
>> &negotiation_include;
>> + else
>> + warning(_("ignoring %s because the protocol does not support it"),
>> + "--negotiation-include");
>> + }
>> return transport;
>> }
>
> There is a difference between the existing `--negotiation-restrict`
> option and the new `--negotiation-include` option. Patch 3's commit
> message says:
>
> "The 'tips' part is kept because this is an oid_array in the transport
> layer. This requires the builtin to handle parsing refs into
> collections of oids so the transport layer can handle this cleaner
> form of the data."
>
> The new option passes the raw `string_list` to the transport layer and
> lets it resolve it instead. If the transport layer now learns how to
> resolve refs to oids, why not for tips/restrict?
>
> Would it be easier for future readers for these complementary options
> to resolve their inputs at the same layer? Or at least call out why:
> "would prefer raw tips but for back-compat we resolve in the built-in"
> for example.
This is a really key observation. It's a bit of work to unravel, but I
think it's better for unifying these things. Look forward to a better
organization in the next version.
>> +static void resolve_negotiation_include(const struct string_list
>> *negotiation_include,
>> + struct oidset *result)
>> +{
>> + struct string_list_item *item;
>> +
>> + if (!negotiation_include || !negotiation_include->nr)
>> + return;
>> +
>> + for_each_string_list_item(item, negotiation_include) {
>> + if (!has_glob_specials(item->string)) {
>> + struct object_id oid;
>> +
>> + /* Ignore missing reference. */
>> + if (repo_get_oid(the_repository, item->string, &oid))
>> + continue;
>> +
>> + /* Fail on missing object pointed by ref. */
>> + if (!odb_has_object(the_repository->objects, &oid, 0))
>> + die(_("the object %s does not exist"),
>> + item->string);
>> +
>> + oidset_insert(result, &oid);
>> + } else {
>> + struct refs_for_each_ref_options opts = {
>> + .pattern = item->string,
>> + };
>> + refs_for_each_ref_ext(
>> + get_main_ref_store(the_repository),
>> + add_oid_to_oidset, result, &opts);
>> + }
>> + }
>> +}
>> +
>
> `resolve_negotiation_include()` is basically doing the same as
> `add_negotiation_restrict_tips()` except outputting to an `oidset`
> vs `oid_array`. This is a result of the difference in ref resolution
> layer between `--negotiation-restrict/tip` and `-include`.
Yes, this code will be replaced with a unified approach in the
next version.
>> static int find_common(struct fetch_negotiator *negotiator,
>> struct fetch_pack_args *args,
>> int fd[2], struct object_id *result_oid,
>> @@ -347,6 +390,7 @@ static int find_common(struct fetch_negotiator *negotiator,
>> struct strbuf req_buf = STRBUF_INIT;
>> size_t state_len = 0;
>> struct packet_reader reader;
>> + struct oidset negotiation_include_oids = OIDSET_INIT;
>> if (args->stateless_rpc && multi_ack == 1)
>> die(_("the option '%s' requires '%s'"), "--stateless-rpc",
>> "multi_ack_detailed");
>> @@ -474,6 +518,33 @@ static int find_common(struct fetch_negotiator *negotiator,
>> trace2_region_enter("fetch-pack", "negotiation_v0_v1", the_repository);
>> flushes = 0;
>> retval = -1;
>> +
>> + /* Send unconditional haves from --negotiation-include */
>> + resolve_negotiation_include(args->negotiation_include,
>> + &negotiation_include_oids);
>> + if (oidset_size(&negotiation_include_oids)) {
>> + struct oidset_iter iter;
>> + oidset_iter_init(&negotiation_include_oids, &iter);
>> +
>> + while ((oid = oidset_iter_next(&iter))) {
>> + struct commit *commit;
>> + packet_buf_write(&req_buf, "have %s\n",
>> + oid_to_hex(oid));
>> + print_verbose(args, "have %s", oid_to_hex(oid));
>> + count++;
>> +
>> + /*
>> + * If this is a commit, then mark as COMMON to
>> + * avoid the negotiator also outputting it as
>> + * a have.
>> + */
>> + commit = lookup_commit(the_repository, oid);
>> + if (commit &&
>> + !repo_parse_commit(the_repository, commit))
>> + commit->object.flags |= COMMON;
>> + }
>> + }
>> +
>
> I want to make sure I understand the COMMON pre-marking before
> commenting further on this patch. My understanding is there are actually
> two different COMMON bits in the tree, one defined in fetch-pack.c
> (bit 6) and one in negotiator/default.c (bit 2):
>
> - fetch-pack.c's COMMON (bit 6) is set after a server ACK confirms an
> OID is common with us and is read to decide when we've established
> enough common ground to terminate negotiation. This is not consulted
> in find_common().
>
> - negotiator/default.c's COMMON (bit 2) is a book-keeping flag used by
> `get_rev()` to decide if we skip emitting a commit as a 'have'.
>
> Since we're in fetch-pack.c here, the `commit->object.flags |= COMMON`
> line is setting bit 6. The `get_rev()` call in negotiator/default.c
> never checks bit 6, only bit 2. As far as I can tell, this mark won't
> suppress the negotiator from emitting another 'have' line in the
> protocol v0/v1 paths in `find_common()`.
>
> The v2 path doesn't touch the flags.. `add_haves` dedups via `oidset_contains()`:
>
> while ((oid = negotiator->next(negotiator))) {
> if (negotiation_include_oids &&
> oidset_contains(negotiation_include_oids, oid))
> continue;
> packet_buf_write(req_buf, "have %s\n", ...);
> }
>
> This works, and is what the new 'avoids duplicates with negotiator' test
> runs against, on protocol v2. If we run on protocol v0/v1, and if my
> assessment is correct, then we'd see a duplicate I think?
>
> Sorry if I've not understood correctly or am missing something, which is
> entirely possible :-)
This is a great catch! It shows that I'm breaking some abstractions here,
and thus it's easy to make such a mistake. It's worse that I don't catch
this problem in the tests that I am adding. I'll add a test that
demonstrates the difference.
But beyond that, I think the biggest issue is that the consumer of an
abstract 'negotiator' is assuming something about its implementation. This
means that I should update the negotiator struct to have a function
pointer dedicated to "I chose to send this 'have'" and then the negotiator
can control how to prevent sending more 'have's reachable from those tips.
Thanks,
-Stolee
^ permalink raw reply
* [PATCH] fetch: add fetch.pruneLocalBranches config
From: Harald Nordgren @ 2026-05-12 17:00 UTC (permalink / raw)
To: gitster; +Cc: git, gitgitgadget, haraldnordgren, j6t, kristofferhaugsbakk
In-Reply-To: <xmqq5x4sn393.fsf@gitster.g>
> But the above description uses quite different condition. You want
> to notice that _they_ removed 'next' (for that, the second-to-last
> fetch may need to be run with --prune) and then remove our local
> hn/topic, but to me, that sounds nonsense for two reasons.
I wanted to be able to be aggresice with deleting, but maybe this went a
bit overboard. Would still be nice to have a nuclear option.
Harald
^ permalink raw reply
* [PATCH v8 0/5] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-12 17:07 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com>
* Delete only when the branch's work is actually reachable from its
upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Harald Nordgren (5):
branch: add --forked <remote>
branch: let delete_branches warn instead of error on bulk refusal
branch: add --prune-merged <remote>
branch: add branch.<name>.pruneMerged opt-out
branch: add --all-remotes flag
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 38 ++++
builtin/branch.c | 291 +++++++++++++++++++++++++++++--
t/t3200-branch.sh | 235 +++++++++++++++++++++++++
4 files changed, 555 insertions(+), 16 deletions(-)
base-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v8
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v8
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v7:
1: 22fa8515df = 1: 22fa8515df branch: add --forked <remote>
2: b443f0f367 = 2: b443f0f367 branch: let delete_branches warn instead of error on bulk refusal
3: a245009893 ! 3: 3032e9c39a branch: add --prune-merged <remote>
@@ Metadata
## Commit message ##
branch: add --prune-merged <remote>
- Delete the local branches that --forked <remote> would list,
- refusing any whose tip is not reachable from the remote's default
- branch. With --force, delete unconditionally. The currently
- checked-out branch in any worktree is always preserved.
+ Delete the local branches that --forked <remote> would list, but
+ only those whose tip is reachable from their configured upstream
+ remote-tracking branch (branch.<name>.merge): the work has already
+ landed on the upstream it tracks, so the local copy is no longer
+ needed.
+
+ A branch whose upstream no longer resolves locally is left alone --
+ its disappearance is not, on its own, evidence that the work was
+ integrated. With --force, skip the reachability check and delete
+ every branch in the candidate set. The currently checked-out
+ branch in any worktree is always preserved, as is the local branch
+ that mirrors <remote>'s default branch.
+
+ Reachability is read from whatever the remote-tracking refs say
+ locally, so the natural workflow is
+
+ git fetch <remote>
+ git branch --prune-merged <remote>
+
+ with no implicit cleanup driven by fetch itself.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi
+`--prune-merged`::
+ Delete the local branches that `--forked` would list for
-+ the same _<remote>_ arguments, but only when the branch's
-+ push destination remote-tracking branch (the branch `git push`
-+ would update; see `branch_get_push` semantics) no longer
-+ resolves locally. In other words: the branch was pushed
-+ under some name on _<remote>_, and that name has since
-+ been pruned upstream.
++ the same _<remote>_ arguments, but only those whose tip is
++ reachable from their configured upstream remote-tracking
++ branch (`branch.<name>.merge`). In other words: the work on
++ the branch has already landed on the upstream it tracks, so
++ the local copy is no longer needed.
+++
++Run `git fetch` first so the upstream remote-tracking branches
++reflect the current state of _<remote>_; reachability is checked
++against whatever the remote-tracking refs say locally.
++
-+As a safety check, branches with commits not yet integrated into
-+their upstream remote-tracking branch are refused; if the upstream
-+itself is gone, the remote's default branch is consulted instead.
-+With `--force` (or `-f`), delete refused branches regardless. The
-+currently checked-out branch in any worktree is always preserved.
++A branch whose upstream no longer resolves locally is left alone
++(its disappearance is not, on its own, evidence that the work was
++integrated). With `--force` (or `-f`), the reachability check is
++skipped and every branch in the candidate set is deleted. The
++currently checked-out branch in any worktree is always preserved,
++as is the local branch that mirrors _<remote>_'s default branch.
+
`-v`::
`-vv`::
@@ builtin/branch.c
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
-@@ builtin/branch.c: static const char *branch_get_color(enum color_branch ix)
- }
-
- static int branch_merged(int kind, const char *name,
-- struct commit *rev, struct commit *head_rev)
-+ struct commit *rev, struct commit *head_rev,
-+ int no_head_fallback)
- {
- /*
- * This checks whether the merge bases of branch and HEAD (or
-@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
- &oid, NULL)) != NULL)
- reference_rev = lookup_commit_reference(the_repository,
- &oid);
-+
-+ if (!reference_rev && no_head_fallback && upstream &&
-+ starts_with(upstream, "refs/remotes/")) {
-+ const char *remote_name = upstream + strlen("refs/remotes/");
-+ const char *slash = strchr(remote_name, '/');
-+ if (slash) {
-+ struct strbuf head_ref = STRBUF_INIT;
-+ strbuf_add(&head_ref, "refs/remotes/", strlen("refs/remotes/"));
-+ strbuf_add(&head_ref, remote_name, slash - remote_name);
-+ strbuf_addstr(&head_ref, "/HEAD");
-+ if (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
-+ head_ref.buf,
-+ RESOLVE_REF_READING,
-+ &oid, NULL))
-+ reference_rev = lookup_commit_reference(the_repository,
-+ &oid);
-+ strbuf_release(&head_ref);
-+ }
-+ }
- }
-- if (!reference_rev)
-+ if (!reference_rev) {
-+ if (no_head_fallback) {
-+ free(reference_name_to_free);
-+ return 0;
-+ }
- reference_rev = head_rev;
-+ }
-
- merged = reference_rev ? repo_in_merge_bases(the_repository, rev,
- reference_rev) : 0;
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
* any of the following code, but during the transition period,
* a gentle reminder is in order.
*/
- if (head_rev != reference_rev) {
-+ if (!no_head_fallback && head_rev != reference_rev) {
- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+- 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);
-@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
-
- static int check_branch_commit(const char *branchname, const char *refname,
- const struct object_id *oid, struct commit *head_rev,
-+ int no_head_fallback,
- int kinds, int force, int warn_only,
- int *n_not_merged)
- {
-@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
- error(_("couldn't look up commit object for '%s'"), refname);
- return -1;
- }
-- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-+ if (!force && !branch_merged(kinds, branchname, rev, head_rev,
-+ no_head_fallback)) {
- if (warn_only) {
- warning(_("the branch '%s' is not fully merged"),
- branchname);
+ if (expect == merged)
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
int quiet, int warn_only, int *n_not_merged)
{
struct commit *head_rev = NULL;
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ }
+ branch_name_pos = strcspn(fmt, "%");
+
+- 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)) {
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
}
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force, warn_only, n_not_merged)) {
+ check_branch_commit(bname.buf, name, &oid, head_rev,
-+ no_head_fallback,
+ kinds, force, warn_only, n_not_merged)) {
if (!warn_only)
ret = 1;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ const char *short_name = item->string;
+ struct strbuf full = STRBUF_INIT;
+ struct branch *branch;
-+ const char *push_ref;
+ const char *upstream;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+
+ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
-+ if (upstream &&
-+ string_list_has_string(&protected_default_refs, upstream)) {
++ if (!upstream ||
++ !refs_ref_exists(get_main_ref_store(the_repository),
++ upstream))
++ continue;
++ if (string_list_has_string(&protected_default_refs, upstream)) {
+ const char *leaf = strrchr(upstream, '/');
+ if (leaf && !strcmp(leaf + 1, short_name))
+ continue;
+ }
+
-+ push_ref = branch ? branch_get_push(branch, NULL) : NULL;
-+ if (!push_ref)
-+ continue;
-+ if (refs_ref_exists(get_main_ref_store(the_repository),
-+ push_ref))
-+ continue;
-+ if (string_list_has_string(&protected_default_refs, push_ref))
-+ continue;
-+
+ strvec_push(&deletable, short_name);
+ }
+
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
-+ git -C pm-upstream branch one base &&
-+ git -C pm-upstream branch two base
-+'
-+
-+test_expect_success '--prune-merged deletes branches whose push ref is gone' '
-+ test_when_finished "rm -rf pm-clean" &&
-+ git clone pm-upstream pm-clean &&
-+ git -C pm-clean branch one --track origin/one &&
-+ git -C pm-clean branch two --track origin/two &&
-+
-+ git -C pm-clean update-ref -d refs/remotes/origin/one &&
-+ git -C pm-clean branch --prune-merged origin &&
-+
-+ test_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&
-+ git -C pm-clean rev-parse --verify refs/heads/two
++ 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_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '
-+ test_when_finished "rm -rf pm-inflight" &&
-+ git clone pm-upstream pm-inflight &&
-+ git -C pm-inflight branch one --track origin/one &&
++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 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-inflight branch --prune-merged origin &&
++ git -C pm-merged branch --prune-merged origin &&
+
-+ git -C pm-inflight rev-parse --verify refs/heads/one
++ 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 skips branches with unpushed commits' '
++test_expect_success '--prune-merged spares branches with un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
-+ git -C pm-unmerged checkout -b one --track origin/one &&
-+ test_commit -C pm-unmerged unpushed &&
++ 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 update-ref -d refs/remotes/origin/one &&
+ git -C pm-unmerged branch --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep "Skipped 1 branch" err &&
+ test_grep "re-run with --force" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
-+ git -C pm-unmerged rev-parse --verify refs/heads/one
++ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
-+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '
++test_expect_success '--prune-merged --force deletes branches regardless of reachability' '
+ test_when_finished "rm -rf pm-force" &&
+ git clone pm-upstream pm-force &&
-+ git -C pm-force checkout -b one --track origin/one &&
-+ test_commit -C pm-force unpushed &&
++ git -C pm-force checkout -b wip origin/wip &&
++ git -C pm-force branch --set-upstream-to=origin/next wip &&
++ test_commit -C pm-force local-only &&
+ git -C pm-force checkout - &&
+
-+ git -C pm-force update-ref -d refs/remotes/origin/one &&
+ git -C pm-force branch --force --prune-merged origin &&
+
-+ test_must_fail git -C pm-force rev-parse --verify refs/heads/one
++ test_must_fail git -C pm-force rev-parse --verify refs/heads/wip
+'
+
-+test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '
-+ test_when_finished "rm -rf pm-fallback" &&
-+ git clone pm-upstream pm-fallback &&
-+ git -C pm-fallback branch one --track origin/one &&
-+ git -C pm-fallback update-ref -d refs/remotes/origin/one &&
-+ # Detach HEAD to an unrelated commit so the candidate is not
-+ # reachable from local HEAD. The upstream origin/one is now
-+ # gone; the merged-ness check should fall back to
-+ # refs/remotes/origin/HEAD, against which "one" is reachable.
-+ git -C pm-fallback commit --allow-empty -m unrelated &&
-+ git -C pm-fallback checkout --detach &&
-+ git -C pm-fallback reset --hard HEAD^ &&
-+
-+ git -C pm-fallback branch --prune-merged origin &&
-+
-+ test_must_fail git -C pm-fallback rev-parse --verify refs/heads/one
-+'
++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 branch one one-commit &&
++ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
-+test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '
-+ test_when_finished "rm -rf pm-both-gone" &&
-+ git clone pm-upstream pm-both-gone &&
-+ git -C pm-both-gone checkout -b one --track origin/one &&
-+ test_commit -C pm-both-gone unpushed &&
-+ git -C pm-both-gone checkout - &&
++ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
++ git -C pm-upstream-gone branch --prune-merged origin &&
+
-+ git -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&
-+ git -C pm-both-gone update-ref -d refs/remotes/origin/one &&
-+ git -C pm-both-gone branch --prune-merged origin 2>err &&
-+ test_grep "not fully merged" err &&
-+
-+ git -C pm-both-gone rev-parse --verify refs/heads/one
++ 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 checkout -b one --track origin/one &&
++ git -C pm-head checkout -b one one-commit &&
++ git -C pm-head branch --set-upstream-to=origin/next one &&
+
-+ git -C pm-head update-ref -d refs/remotes/origin/one &&
+ git -C pm-head branch --force --prune-merged origin &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
-+test_expect_success '--prune-merged deletes when push ref differs from upstream' '
-+ test_when_finished "rm -rf pm-pushdiff" &&
-+ git clone pm-upstream pm-pushdiff &&
-+ git -C pm-pushdiff config push.default current &&
-+ git -C pm-pushdiff branch --track topic-a origin/one &&
-+
-+ git -C pm-pushdiff branch --force --prune-merged origin &&
-+
-+ test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
-+'
-+
+test_expect_success '--prune-merged spares the local default branch' '
+ test_when_finished "rm -rf pm-default" &&
+ git clone pm-upstream pm-default &&
-+ git -C pm-default config push.default current &&
+ git -C pm-default checkout --detach &&
-+ git -C pm-default branch --prune-merged origin &&
++ git -C pm-default branch --force --prune-merged origin &&
+ git -C pm-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
+ test_when_finished "rm -rf pm-default-alias" &&
+ git clone pm-upstream pm-default-alias &&
-+ git -C pm-default-alias config push.default current &&
+ git -C pm-default-alias branch --track trunk origin/main &&
+ git -C pm-default-alias checkout --detach &&
-+ git -C pm-default-alias branch --force --prune-merged origin &&
++ git -C pm-default-alias branch --prune-merged origin &&
+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
+'
-+
-+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '
-+ test_when_finished "rm -rf pm-pushdefault" &&
-+ git clone pm-upstream pm-pushdefault &&
-+ git -C pm-pushdefault branch --track topic origin/one &&
-+ git -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&
-+ git -C pm-pushdefault update-ref -d refs/remotes/origin/one &&
-+ git -C pm-pushdefault update-ref -d refs/remotes/origin/main &&
-+ git -C pm-pushdefault checkout --detach &&
-+ git -C pm-pushdefault branch --prune-merged origin &&
-+ git -C pm-pushdefault rev-parse --verify refs/heads/topic
-+'
+
test_done
4: 2c3f751569 ! 4: dd33309344 branch: add branch.<name>.pruneMerged opt-out
@@ Commit message
branch: add branch.<name>.pruneMerged opt-out
Setting branch.<name>.pruneMerged=false exempts that branch from
- --prune-merged (and from fetch --prune-merged), even with --force.
- Useful for keeping a topic branch around between rounds.
+ --prune-merged, even with --force. Useful for keeping a topic
+ branch around between rounds.
Explicit deletion via 'git branch -d' is unaffected.
@@ Documentation/config/branch.adoc: for details).
+ Explicit deletion via `git branch -d` is unaffected.
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote
- Delete the local branches that `--forked` would list for
- the same _<remote>_ arguments, but only when the branch's
- push destination remote-tracking branch (the branch `git push`
-- would update; see `branch_get_push` semantics) no longer
-- resolves locally. In other words: the branch was pushed
-- under some name on _<remote>_, and that name has since
-- been pruned upstream.
-+ would update) no longer resolves locally. In other words:
-+ the branch was pushed under some name on _<remote>_, and
-+ that name has since been pruned upstream.
- +
- As a safety check, branches with commits not yet integrated into
- their upstream remote-tracking branch are refused; if the upstream
- itself is gone, the remote's default branch is consulted instead.
- With `--force` (or `-f`), delete refused branches regardless. The
--currently checked-out branch in any worktree is always preserved.
-+currently checked-out branch in any worktree is always preserved,
-+as is any branch with `branch.<name>.pruneMerged` set to `false`.
+@@ Documentation/git-branch.adoc: A branch whose upstream no longer resolves locally is left alone
+ integrated). With `--force` (or `-f`), the reachability check is
+ skipped and every branch in the candidate set is deleted. The
+ currently checked-out branch in any worktree is always preserved,
+-as is the local branch that mirrors _<remote>_'s default branch.
++as is any branch with `branch.<name>.pruneMerged` set to `false`,
++and the local branch that mirrors _<remote>_'s default branch.
`-v`::
`-vv`::
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
struct strbuf full = STRBUF_INIT;
+ struct strbuf key = STRBUF_INIT;
struct branch *branch;
- const char *push_ref;
const char *upstream;
+ int opt_out = 0;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
}
strbuf_release(&full);
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
- if (upstream &&
- string_list_has_string(&protected_default_refs, upstream)) {
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream ||
+ !refs_ref_exists(get_main_ref_store(the_repository),
+- upstream))
++ upstream)) {
++ strbuf_release(&key);
+ continue;
++ }
+ if (string_list_has_string(&protected_default_refs, upstream)) {
const char *leaf = strrchr(upstream, '/');
- if (leaf && !strcmp(leaf + 1, short_name))
+ if (leaf && !strcmp(leaf + 1, short_name)) {
+ strbuf_release(&key);
continue;
+ }
- }
-
- push_ref = branch ? branch_get_push(branch, NULL) : NULL;
-- if (!push_ref)
-+ if (!push_ref) {
-+ strbuf_release(&key);
- continue;
-+ }
- if (refs_ref_exists(get_main_ref_store(the_repository),
-- push_ref))
-+ push_ref)) {
-+ strbuf_release(&key);
-+ continue;
-+ }
-+ if (string_list_has_string(&protected_default_refs, push_ref)) {
-+ strbuf_release(&key);
- continue;
-- if (string_list_has_string(&protected_default_refs, push_ref))
+ }
+
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ "(branch.%s.pruneMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
- continue;
-+ }
++ continue;
+ }
+ strbuf_release(&key);
strvec_push(&deletable, short_name);
}
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul
- git -C pm-pushdefault rev-parse --verify refs/heads/topic
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default branch by name, no
+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
'
+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 branch one --track origin/one &&
-+ git -C pm-optout branch two --track origin/two &&
++ 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 &&
+ git -C pm-optout config branch.one.pruneMerged false &&
+
-+ git -C pm-optout update-ref -d refs/remotes/origin/one &&
-+ git -C pm-optout update-ref -d refs/remotes/origin/two &&
+ git -C pm-optout branch --prune-merged origin 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose pus
+test_expect_success '--prune-merged --force still honours pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout-force" &&
+ git clone pm-upstream pm-optout-force &&
-+ git -C pm-optout-force checkout -b one --track origin/one &&
-+ test_commit -C pm-optout-force unpushed &&
++ git -C pm-optout-force checkout -b wip origin/wip &&
++ git -C pm-optout-force branch --set-upstream-to=origin/next wip &&
++ test_commit -C pm-optout-force local-only &&
+ git -C pm-optout-force checkout - &&
-+ git -C pm-optout-force config branch.one.pruneMerged false &&
++ git -C pm-optout-force config branch.wip.pruneMerged false &&
+
-+ git -C pm-optout-force update-ref -d refs/remotes/origin/one &&
+ git -C pm-optout-force branch --force --prune-merged origin &&
+
-+ git -C pm-optout-force rev-parse --verify refs/heads/one
++ git -C pm-optout-force rev-parse --verify refs/heads/wip
+'
+
+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 --track origin/one &&
++ git -C pm-optout-d branch one one-commit &&
++ git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+ git -C pm-optout-d config branch.one.pruneMerged false &&
+
+ git -C pm-optout-d branch -d one &&
5: f79707ce7c ! 5: 6e81ed3147 branch: add --all-remotes flag
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete refused branches regardless. The
- currently checked-out branch in any worktree is always preserved,
- as is any branch with `branch.<name>.pruneMerged` set to `false`.
+@@ Documentation/git-branch.adoc: currently checked-out branch in any worktree is always preserved,
+ as is any branch with `branch.<name>.pruneMerged` set to `false`,
+ and the local branch that mirrors _<remote>_'s default branch.
+`--all-remotes`::
+ With `--forked` or `--prune-merged`, act on every
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
'
+test_expect_success '--prune-merged --all-remotes covers every configured remote' '
-+ test_when_finished "rm -rf pm-allremotes" &&
++ test_when_finished "rm -rf pm-allremotes pm-other" &&
+ git clone pm-upstream pm-allremotes &&
+ test_create_repo pm-other &&
+ test_commit -C pm-other other-base &&
-+ git -C pm-other branch foreign other-base &&
++ git -C pm-other checkout -b stable &&
++ test_commit -C pm-other foreign-commit &&
++ git -C pm-other branch foreign HEAD &&
++ git -C pm-other checkout main &&
++
+ git -C pm-allremotes remote add other ../pm-other &&
+ git -C pm-allremotes fetch other &&
-+ git -C pm-allremotes branch one --track origin/one &&
-+ git -C pm-allremotes branch foreign --track other/foreign &&
++ git -C pm-allremotes branch one one-commit &&
++ git -C pm-allremotes branch --set-upstream-to=origin/next one &&
++ git -C pm-allremotes branch foreign other/foreign &&
++ git -C pm-allremotes branch --set-upstream-to=other/stable foreign &&
+
-+ git -C pm-allremotes update-ref -d refs/remotes/origin/one &&
-+ git -C pm-allremotes update-ref -d refs/remotes/other/foreign &&
-+ git -C pm-allremotes branch --force --prune-merged --all-remotes &&
++ git -C pm-allremotes branch --prune-merged --all-remotes &&
+
+ test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign
--
gitgitgadget
^ permalink raw reply
* [PATCH v8 1/5] branch: add --forked <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-12 17:07 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
List local branches whose configured upstream falls within any of
the given <remote> arguments. <remote> may be either a configured
remote name (matching all of its remote-tracking branches) or a
single remote-tracking branch. Multiple <remote> arguments are
unioned.
This is the building block for --prune-merged, which deletes the
listed branches.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 12 ++++
builtin/branch.c | 110 +++++++++++++++++++++++++++++++++-
t/t3200-branch.sh | 54 +++++++++++++++++
3 files changed, 174 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..5773104cd3 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,6 +24,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 --forked <remote>...
DESCRIPTION
-----------
@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--forked`::
+ List local branches that fork from any of the given _<remote>_
+ arguments, that is, those whose configured upstream
+ (`branch.<name>.merge`) is one of those remotes' remote-tracking
+ branches.
++
+Each _<remote>_ may be either the name of a configured remote
+(e.g. `origin`, meaning any branch tracking a
+`refs/remotes/origin/*` ref) or a specific remote-tracking branch
+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..b3289a8875 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>] --forked <remote>..."),
NULL
};
@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static void parse_forked_args(int argc, const char **argv,
+ struct string_list *remote_names,
+ struct string_list *tracking_refs)
+{
+ int i;
+
+ for (i = 0; i < argc; i++) {
+ const char *arg = argv[i];
+ struct remote *remote;
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ remote = remote_get(arg);
+ if (remote && remote_is_configured(remote, 0)) {
+ string_list_insert(remote_names, remote->name);
+ continue;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ starts_with(full_ref, "refs/remotes/")) {
+ string_list_insert(tracking_refs, full_ref);
+ free(full_ref);
+ continue;
+ }
+ free(full_ref);
+
+ die(_("'%s' is neither a configured remote nor a "
+ "remote-tracking branch"), arg);
+ }
+}
+
+static int branch_is_forked(const char *short_name,
+ const struct string_list *remote_names,
+ const struct string_list *tracking_refs)
+{
+ struct branch *branch = branch_get(short_name);
+ const char *upstream;
+
+ if (!branch || !branch->remote_name)
+ return 0;
+
+ if (string_list_has_string(remote_names, branch->remote_name))
+ return 1;
+
+ upstream = branch_get_upstream(branch, NULL);
+ if (upstream && string_list_has_string(tracking_refs, upstream))
+ return 1;
+
+ return 0;
+}
+
+struct forked_cb {
+ const struct string_list *remote_names;
+ const struct string_list *tracking_refs;
+ struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+ struct forked_cb *cb = cb_data;
+
+ if (ref->flags & REF_ISSYMREF)
+ return 0;
+ if (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))
+ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list remote_names = STRING_LIST_INIT_NODUP;
+ struct string_list tracking_refs = STRING_LIST_INIT_DUP;
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ struct forked_cb cb = {
+ .remote_names = &remote_names,
+ .tracking_refs = &tracking_refs,
+ .out = &out,
+ };
+
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
+ parse_forked_args(argc, argv, &remote_names, &tracking_refs);
+
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ collect_forked_branch, &cb);
+
+ string_list_sort(&out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
+ string_list_clear(&remote_names, 0);
+ string_list_clear(&tracking_refs, 0);
+ string_list_clear(&out, 0);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -714,6 +814,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 forked = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -767,6 +868,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, "forked", &forked,
+ N_("list local branches forked from the given <remote>s")),
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")),
@@ -811,7 +914,7 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !forked && argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -820,7 +923,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!forked;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -860,6 +963,9 @@ int cmd_branch(int argc,
die(_("branch name required"));
ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
goto out;
+ } else if (forked) {
+ ret = list_forked_branches(argc, argv);
+ 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 e7829c2c4b..24a3ec44ee 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,58 @@ 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 --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
+'
+
+test_expect_success '--forked <remote-name> lists branches tracking that remote' '
+ git -C forked branch --forked origin >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '
+ git -C forked branch --forked origin/one >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <remote> arguments' '
+ git -C forked branch --forked origin/one other >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown remote/ref' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "neither a configured remote nor a remote-tracking branch" err
+'
+
+test_expect_success '--forked requires at least one <remote>' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "at least one <remote>" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v8 2/5] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-05-12 17:07 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add two new parameters to delete_branches() and the helper
check_branch_commit():
* warn_only switches the per-branch refusal from a hard error
("error: the branch 'X' is not fully merged" plus a four-line
hint about 'git branch -D X') to a one-line warning, and
causes the function to skip those branches without setting its
exit code. Each refused branch is still skipped from deletion.
* n_not_merged, when non-NULL, is incremented for each branch
refused on the not-merged path, so a bulk caller can summarize
rather than print per-branch advice.
All existing call sites pass 0 / NULL and so are unaffected. Both
parameters are wired up so a bulk-deletion caller can suppress
the noise normally appropriate for a one-shot 'git branch -d'.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 29 ++++++++++++++++++++---------
1 file changed, 20 insertions(+), 9 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index b3289a8875..1941f8a9ad 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,
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, int force, int warn_only,
+ int *n_not_merged)
{
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *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 (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);
+ }
+ if (n_not_merged)
+ (*n_not_merged)++;
return -1;
}
return 0;
@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)
}
static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
+ int quiet, int warn_only, int *n_not_merged)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
- ret = 1;
+ force, warn_only, n_not_merged)) {
+ if (!warn_only)
+ ret = 1;
goto next;
}
@@ -961,7 +971,8 @@ 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, delete > 1, filter.kind,
+ quiet, 0, NULL);
goto out;
} else if (forked) {
ret = list_forked_branches(argc, argv);
--
gitgitgadget
^ permalink raw reply related
* [PATCH v8 3/5] branch: add --prune-merged <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-12 17:07 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Delete the local branches that --forked <remote> would list, but
only those whose tip is reachable from their configured upstream
remote-tracking branch (branch.<name>.merge): the work has already
landed on the upstream it tracks, so the local copy is no longer
needed.
A branch whose upstream no longer resolves locally is left alone --
its disappearance is not, on its own, evidence that the work was
integrated. With --force, skip the reachability check and delete
every branch in the candidate set. The currently checked-out
branch in any worktree is always preserved, as is the local branch
that mirrors <remote>'s default branch.
Reachability is read from whatever the remote-tracking refs say
locally, so the natural workflow is
git fetch <remote>
git branch --prune-merged <remote>
with no implicit cleanup driven by fetch itself.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 20 +++++
builtin/branch.c | 144 +++++++++++++++++++++++++++++-----
t/t3200-branch.sh | 96 +++++++++++++++++++++++
3 files changed, 241 insertions(+), 19 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5773104cd3..c3f5150f03 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <remote>...
+git branch [-f] --prune-merged <remote>...
DESCRIPTION
-----------
@@ -211,6 +212,25 @@ Each _<remote>_ may be either the name of a configured remote
`refs/remotes/origin/*` ref) or a specific remote-tracking branch
(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
+`--prune-merged`::
+ Delete the local branches that `--forked` would list for
+ the same _<remote>_ arguments, but only those whose tip is
+ reachable from their configured upstream remote-tracking
+ branch (`branch.<name>.merge`). In other words: the work on
+ the branch has already landed on the upstream it tracks, so
+ the local copy is no longer needed.
++
+Run `git fetch` first so the upstream remote-tracking branches
+reflect the current state of _<remote>_; reachability is checked
+against whatever the remote-tracking refs say locally.
++
+A branch whose upstream no longer resolves locally is left alone
+(its disappearance is not, on its own, evidence that the work was
+integrated). With `--force` (or `-f`), the reachability check is
+skipped and every branch in the candidate set is deleted. The
+currently checked-out branch in any worktree is always preserved,
+as is the local branch that mirrors _<remote>_'s default branch.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1941f8a9ad..50bf9774a8 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -21,6 +21,7 @@
#include "branch.h"
#include "path.h"
#include "string-list.h"
+#include "strvec.h"
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
@@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,
* any of the following code, but during the transition period,
* a gentle reminder is in order.
*/
- 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)
@@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
-static int delete_branches(int argc, const char **argv, int force, int kinds,
+static int delete_branches(int argc, const char **argv,
+ int no_head_fallback,
+ int force, int kinds,
int quiet, int warn_only, int *n_not_merged)
{
struct commit *head_rev = NULL;
@@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
- 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)) {
@@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force, warn_only, n_not_merged)) {
+ check_branch_commit(bname.buf, name, &oid, head_rev,
+ kinds, force, warn_only, n_not_merged)) {
if (!warn_only)
ret = 1;
goto next;
@@ -753,36 +756,132 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
return 0;
}
-static int list_forked_branches(int argc, const char **argv)
+static void collect_default_branch_refs(const struct string_list *remote_names,
+ struct string_list *out)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list_item *item;
+
+ for_each_string_list_item(item, remote_names) {
+ struct strbuf head = STRBUF_INIT;
+ const char *target;
+
+ strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
+ target = refs_resolve_ref_unsafe(refs, head.buf,
+ RESOLVE_REF_NO_RECURSE,
+ NULL, NULL);
+ if (target && starts_with(target, "refs/remotes/"))
+ string_list_insert(out, target);
+ strbuf_release(&head);
+ }
+}
+
+static void collect_forked_set(int argc, const char **argv,
+ struct string_list *protected_default_refs,
+ struct string_list *out)
{
struct string_list remote_names = STRING_LIST_INIT_NODUP;
struct string_list tracking_refs = STRING_LIST_INIT_DUP;
- struct string_list out = STRING_LIST_INIT_DUP;
- struct string_list_item *item;
struct forked_cb cb = {
.remote_names = &remote_names,
.tracking_refs = &tracking_refs,
- .out = &out,
+ .out = out,
};
- if (!argc)
- die(_("--forked requires at least one <remote>"));
-
parse_forked_args(argc, argv, &remote_names, &tracking_refs);
refs_for_each_branch_ref(get_main_ref_store(the_repository),
collect_forked_branch, &cb);
- string_list_sort(&out);
- for_each_string_list_item(item, &out)
- puts(item->string);
+ string_list_sort(out);
+
+ if (protected_default_refs)
+ collect_default_branch_refs(&remote_names, protected_default_refs);
string_list_clear(&remote_names, 0);
string_list_clear(&tracking_refs, 0);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, NULL, &out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
string_list_clear(&out, 0);
return 0;
}
+static int prune_merged_branches(int argc, const char **argv, int force,
+ int quiet)
+{
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int n_not_merged = 0;
+ int ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, &protected_default_refs, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
+ struct strbuf full = STRBUF_INIT;
+ struct branch *branch;
+ const char *upstream;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ if (branch_checked_out(full.buf)) {
+ strbuf_release(&full);
+ continue;
+ }
+ strbuf_release(&full);
+
+ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream ||
+ !refs_ref_exists(get_main_ref_store(the_repository),
+ upstream))
+ continue;
+ if (string_list_has_string(&protected_default_refs, upstream)) {
+ const char *leaf = strrchr(upstream, '/');
+ if (leaf && !strcmp(leaf + 1, short_name))
+ continue;
+ }
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ 1, force,
+ FILTER_REFS_BRANCHES, quiet,
+ 1, &n_not_merged);
+
+ if (n_not_merged && !quiet)
+ fprintf(stderr,
+ Q_("Skipped %d branch that is not fully merged; "
+ "re-run with --force to delete it anyway.\n",
+ "Skipped %d branches that are not fully merged; "
+ "re-run with --force to delete them anyway.\n",
+ n_not_merged),
+ n_not_merged);
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
+ string_list_clear(&protected_default_refs, 0);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -825,6 +924,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 forked = 0;
+ int prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -880,6 +980,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "forked", &forked,
N_("list local branches forked from the given <remote>s")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
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")),
@@ -924,7 +1026,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && !forked && argc == 0)
+ !show_current && !unset_upstream && !forked && !prune_merged &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -933,7 +1036,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream + !!forked;
+ !!unset_upstream + !!forked + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -971,12 +1074,15 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
quiet, 0, NULL);
goto out;
} else if (forked) {
ret = list_forked_branches(argc, argv);
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, force, 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 24a3ec44ee..ca071338d3 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,4 +1771,100 @@ test_expect_success '--forked requires at least one <remote>' '
test_grep "at least one <remote>" 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_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 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 spares branches with un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ 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 "Skipped 1 branch" err &&
+ test_grep "re-run with --force" 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 --force deletes branches regardless of reachability' '
+ test_when_finished "rm -rf pm-force" &&
+ git clone pm-upstream pm-force &&
+ git -C pm-force checkout -b wip origin/wip &&
+ git -C pm-force branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-force local-only &&
+ git -C pm-force checkout - &&
+
+ git -C pm-force branch --force --prune-merged origin &&
+
+ test_must_fail git -C pm-force rev-parse --verify refs/heads/wip
+'
+
+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 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 checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --force --prune-merged origin &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares the local default branch' '
+ test_when_finished "rm -rf pm-default" &&
+ git clone pm-upstream pm-default &&
+ git -C pm-default checkout --detach &&
+ git -C pm-default branch --force --prune-merged origin &&
+ git -C pm-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
+ test_when_finished "rm -rf pm-default-alias" &&
+ git clone pm-upstream pm-default-alias &&
+ git -C pm-default-alias branch --track trunk origin/main &&
+ git -C pm-default-alias checkout --detach &&
+ git -C pm-default-alias branch --prune-merged origin &&
+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox