* Re: [PATCH v2 1/3] Documentation/MyFirstContribution: recommend shallow threading
From: Tuomas Ahola @ 2026-06-03 10:01 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Junio C Hamano, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-1-a8aea0aa2c23@pks.im>
Patrick Steinhardt <ps@pks.im> wrote:
> The "MyFirstContribution" document recommends the use of deep threading:
> every cover letter of subsequent iterations shall be linked to the cover
> letter of the preceding version. The result of this is that eventually,
> threads with many versions are getting nested so deep that it becomes
> hard to follow.
>
> Adapt the recommendation to instead propose shallow threading: instead
> of linking the cover letter to the previous cover letter, the user is
> supposed to always link it to the first cover letter. This still makes
> it easy to follow the iterations, but has the benefit of nesting to a
> much shallower level.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
> Documentation/MyFirstContribution.adoc | 4 ++--
> 1 file changed, 2 insertions(+), 2 deletions(-)
>
> diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
> index b9fdefce02..069020196c 100644
> --- a/Documentation/MyFirstContribution.adoc
> +++ b/Documentation/MyFirstContribution.adoc
> @@ -1227,8 +1227,8 @@ Message-ID: <foo.12345.author@example.com>
>
> Your Message-ID is `<foo.12345.author@example.com>`. This example will be used
> below as well; make sure to replace it with the correct Message-ID for your
> -**previous cover letter** - that is, if you're sending v2, use the Message-ID
> -from v1; if you're sending v3, use the Message-ID from v2.
> +**first cover letter** - that is, for any subsequent version that you send,
> +always use the Message-ID from v1.
>
> While you're looking at the email, you should also note who is CC'd, as it's
> common practice in the mailing list to keep all CCs on a thread. You can add
>
> --
> 2.54.0.1064.gd145956f57.dirty
If we adapt this change to the guidance, let's fix also other places of the
document that talk about replying to the previous cover letter.
-----8<-----
diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
index 069020196c..bf64a211bd 100644
--- a/Documentation/MyFirstContribution.adoc
+++ b/Documentation/MyFirstContribution.adoc
@@ -790,7 +790,7 @@ We can note a few things:
v3", etc. in place of "PATCH". For example, "[PATCH v2 1/3]" would be the first of
three patches in the second iteration. Each iteration is sent with a new cover
letter (like "[PATCH v2 0/3]" above), itself a reply to the cover letter of the
- previous iteration (more on that below).
+ first iteration (more on that below).
NOTE: A single-patch topic is sent with "[PATCH]", "[PATCH v2]", etc. without
_i_/_n_ numbering (in the above thread overview, no single-patch topic appears,
@@ -1214,7 +1214,7 @@ between your last version and now, if it's something significant. You do not
need the exact same body in your second cover letter; focus on explaining to
reviewers the changes you've made that may not be as visible.
-You will also need to go and find the Message-ID of your previous cover letter.
+You will also need to go and find the Message-ID of your original cover letter.
You can either note it when you send the first series, from the output of `git
send-email`, or you can look it up on the
https://lore.kernel.org/git[mailing list]. Find your cover letter in the
^ permalink raw reply related
* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Weijie Yuan @ 2026-06-03 9:51 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <ah_c3kgmfRh3bXns@wyuan.org>
On Wed, Jun 03, 2026 at 03:51:21PM +0800, Weijie Yuan wrote:
> On Wed, Jun 03, 2026 at 08:55:04AM +0200, Patrick Steinhardt wrote:
> > So this quote is definitely at odds with the configuration I have
> > proposed. It's actually quite surprising to me that we recommend deep
> > threading -- I personally find it extremely hard to navigate as the
> > nesting eventually gets way too deep.
>
> Sorry I'm a little confused. The example thread at git-scm.com:
>
> https://git-scm.com/docs/MyFirstContribution#ready-to-share
>
> Isn't this actually supporting shallow nesting?
>
> > It's actually quite surprising to me that we recommend deep
> > threading -- I personally find it extremely hard to navigate as the
> > nesting eventually gets way too deep.
>
> In my understanding, deep threading == --chain-reply-to, so can you
> point out where do Git recommend deep threading? I always thought Git
> supports shallow threading.
>
> Thanks! And please forgive me if I am wrong :-)
Ah, I know you mean the deep nesting of cover letters, sorry, now I
know.
Thanks!
^ permalink raw reply
* Re: [PATCH v2 0/3] contrib/subtree: reduce recursion during split
From: Ian Jackson @ 2026-06-03 9:12 UTC (permalink / raw)
To: Colin Stagner
Cc: Junio C Hamano, git, Christian Heusel, george, Christian Hesse,
Phillip Wood
In-Reply-To: <0915b5cc-5cbb-4cce-a832-147f85d4ff1f@howdoi.land>
Colin Stagner writes ("Re: [PATCH v2 0/3] contrib/subtree: reduce recursion during split"):
> On 6/1/26 17:13, Junio C Hamano wrote:
> > While I do agree that avoiding bash-isms in the main part of Git and
> > sticking to vanilla POSIX has merit, this particular one seems more
> > like an artificial limit imposed by dash than sticking to the POSIX
> > as the common denoninator, at least to me.
>
> Correct, this topic is a workaround for an artificial limit. The limit
> is Debian-specific and was introduced as a downstream patch in 2018 [1],
> [2].
I don't think it is correct to say that this is Debian-specific. The
limit is baked into dash, which is a non-distro-specific minimal POSIX
shell derived from NetBSD's ash:
http://gondor.apana.org.au/~herbert/dash/
I don't know what other distros use it (or can use it) as their
/bin/sh. I also haven't checked POSIX to see if the question of
maximum recursion level is discussed.
Ian.
--
Ian Jackson <ijackson@chiark.greenend.org.uk> These opinions are my own.
Pronouns: they/he. If I emailed you from @fyvzl.net or @evade.org.uk,
that is a private address which bypasses my fierce spamfilter.
^ permalink raw reply
* [PATCH v12 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" line per candidate, and
exits without touching any ref.
The @{push}-vs-@{upstream} and unmerged filtering still applies,
so the dry-run output is exactly the set that the live run would
delete.
--dry-run is only meaningful in combination with --prune-merged
and is rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 12 +++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 69878549fc..c579df4fe0 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch (--prune-merged <branch>)...
+git branch [--dry-run] (--prune-merged <branch>)...
DESCRIPTION
-----------
@@ -230,6 +230,12 @@ Branches refused by the "fully merged" safety check are listed as
warnings and skipped; pass them to `git branch -D` explicitly if
you want them gone.
+`--dry-run`::
+ With `--prune-merged`, print which branches would be
+ deleted and exit without touching any ref. Useful for
+ sanity-checking a wide pattern like `'origin/*'` before
+ committing to the deletion.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index e03805a8a7..1811511b9e 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -860,7 +860,7 @@ static void collect_forked_set(const struct string_list *upstreams,
}
static int prune_merged_branches(const struct string_list *upstreams,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -917,7 +917,7 @@ static int prune_merged_branches(const struct string_list *upstreams,
quiet,
1, /* warn_only */
1, /* no_head_fallback */
- 0 /* dry_run */);
+ dry_run);
strvec_clear(&deletable);
string_list_clear(&candidates, 0);
@@ -967,6 +967,7 @@ int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -1024,6 +1025,8 @@ int cmd_branch(int argc,
N_("list local branches whose upstream matches <branch> (repeatable)")),
OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --prune-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -1083,6 +1086,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged_upstreams.nr)
+ die(_("--dry-run requires --prune-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -1124,7 +1130,7 @@ int cmd_branch(int argc,
if (argc)
die(_("--prune-merged does not take positional arguments; "
"repeat --prune-merged for each <branch>"));
- ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+ ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 9e33179590..29bfd0e109 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2027,4 +2027,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+ test_when_finished "rm -rf pm-dry" &&
+ git clone pm-upstream pm-dry &&
+ git -C pm-dry remote add fork ../pm-fork &&
+ test_config -C pm-dry remote.pushDefault fork &&
+ test_config -C pm-dry push.default current &&
+ git -C pm-dry branch one one-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next one &&
+ git -C pm-dry branch two two-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next two &&
+
+ git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
+ test_grep "Would delete branch one " actual &&
+ test_grep "Would delete branch two " actual &&
+
+ git -C pm-dry rev-parse --verify refs/heads/one &&
+ git -C pm-dry rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
+ test_when_finished "rm -rf pm-dry-mixed" &&
+ git clone pm-upstream pm-dry-mixed &&
+ git -C pm-dry-mixed remote add fork ../pm-fork &&
+ test_config -C pm-dry-mixed remote.pushDefault fork &&
+ test_config -C pm-dry-mixed push.default current &&
+ git -C pm-dry-mixed checkout -b wip origin/next &&
+ git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-dry-mixed local-only &&
+ git -C pm-dry-mixed checkout - &&
+ git -C pm-dry-mixed branch merged one-commit &&
+ git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
+ git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
+ test_grep "Would delete branch merged" out &&
+ test_grep ! "Would delete branch wip" out &&
+ git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
+ git -C pm-dry-mixed rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run without --prune-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
+ test_grep "requires --prune-merged" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
"git branch --prune-merged". Useful for a topic branch you want
to develop further after an initial round has been merged
upstream.
Unless --quiet is given, the skip is reported per branch so the
user knows why their topic was preserved.
Explicit deletion via "git branch -d" continues to consult the
normal merge check and is not affected by this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/branch.adoc | 7 +++++++
Documentation/git-branch.adoc | 5 +++--
builtin/branch.c | 14 ++++++++++++++
t/t3200-branch.sh | 30 ++++++++++++++++++++++++++++++
4 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
`git branch --edit-description`. Branch description is
automatically added to the `format-patch` cover letter or
`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ is exempt from
+ `git branch --prune-merged`. Useful for a topic branch you
+ intend to develop further after an initial round has been
+ merged upstream. Defaults to true. Explicit deletion via
+ `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index f7942fcd7d..69878549fc 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -221,9 +221,10 @@ the upstream refs refreshed.
+
A branch is left alone if any of the following holds:
its upstream no longer resolves locally; it is checked out in any
-worktree; or its push destination (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.pruneMerged` is set to `false`.
+
Branches refused by the "fully merged" safety check are listed as
warnings and skipped; pass them to `git branch -D` explicitly if
diff --git a/builtin/branch.c b/builtin/branch.c
index 736480b002..e03805a8a7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -878,7 +878,9 @@ static int prune_merged_branches(const struct string_list *upstreams,
struct branch *branch = branch_get(short_name);
const char *upstream, *push;
struct strbuf full = STRBUF_INIT;
+ struct strbuf key = STRBUF_INIT;
int skip;
+ int opt_out;
strbuf_addf(&full, "refs/heads/%s", short_name);
skip = !!branch_checked_out(full.buf);
@@ -893,6 +895,18 @@ static int prune_merged_branches(const struct string_list *upstreams,
if (!push || !strcmp(push, upstream))
continue;
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!quiet)
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
+ continue;
+ }
+ strbuf_release(&key);
+
strvec_push(&deletable, short_name);
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index beb86987ad..9e33179590 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1997,4 +1997,34 @@ test_expect_success '--prune-merged rejects positional arguments' '
test_grep "does not take positional arguments" err
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+ test_when_finished "rm -rf pm-optout-d" &&
+ git clone pm-upstream pm-optout-d &&
+ git -C pm-optout-d branch one one-commit &&
+ git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+ test_config -C pm-optout-d branch.one.pruneMerged false &&
+
+ git -C pm-optout-d branch -d one &&
+ test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 4/6] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
restricted to those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from local refs; nothing is fetched. Users
who want fresh upstream refs run "git fetch" first.
Three classes of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally (its
disappearance is not, on its own, evidence of integration);
* any branch whose push destination equals its upstream
(<branch>@{push} == <branch>@{upstream}). Such a branch
cannot be distinguished from a freshly pulled trunk that
just looks "fully merged", e.g. local "main" tracking and
pushing to "origin/main" right after a pull. Only branches
that push somewhere other than their upstream (typically
topics in a fork-based workflow) are treated as candidates.
Deletion goes through the existing delete_branches() in warn-only
mode and with the HEAD-fallback disabled: a branch that is not
yet fully merged to its upstream is reported as a one-line warning
and skipped, so a single un-mergeable topic does not abort the
whole sweep. We only act on upstream-merged status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 23 +++++
builtin/branch.c | 117 +++++++++++++++++++--
t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
3 files changed, 318 insertions(+), 10 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 8002d7f38c..f7942fcd7d 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch (--prune-merged <branch>)...
DESCRIPTION
-----------
@@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
`master`) or a shell-style glob (e.g. `'origin/*'`). The
option can be repeated to widen the filter.
+`--prune-merged <branch>`::
+ Delete the local branches that `--forked` would list for the
+ same _<branch>_, but only those whose tip is reachable from
+ their configured upstream. In other words, the work on the
+ branch has already landed on the upstream it tracks, so the
+ local copy is no longer needed. May be given more than once to
+ union the matches; positional arguments are not accepted.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 09afdd9257..736480b002 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] (--prune-merged <branch>)..."),
NULL
};
@@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,
return 0;
}
-static int branch_upstream_matches(const char *full_refname,
+static int branch_upstream_matches(const char *short_branch_name,
const struct upstream_pattern *patterns,
size_t nr_patterns)
{
- const char *short_name;
- struct branch *branch;
+ struct branch *branch = branch_get(short_branch_name);
const char *upstream;
- if (!skip_prefix(full_refname, "refs/heads/", &short_name))
- return 0;
- branch = branch_get(short_name);
if (!branch)
return 0;
upstream = branch_get_upstream(branch, NULL);
@@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,
for (i = 0; i < array->nr; i++) {
struct ref_array_item *item = array->items[i];
- if (branch_upstream_matches(item->refname,
- patterns, nr_patterns))
+ const char *short_name;
+ if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
+ branch_upstream_matches(short_name, patterns, nr_patterns))
array->items[kept++] = item;
else
free_ref_array_item(item);
@@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,
upstream_pattern_list_clear(patterns, nr_patterns);
}
+struct forked_cb {
+ const struct upstream_pattern *patterns;
+ size_t nr_patterns;
+ 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_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
+ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
+static void collect_forked_set(const struct string_list *upstreams,
+ struct string_list *out)
+{
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
+ struct forked_cb cb;
+
+ parse_forked_args(upstreams, &patterns, &nr_patterns);
+ cb.patterns = patterns;
+ cb.nr_patterns = nr_patterns;
+ cb.out = out;
+
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ collect_forked_branch, &cb);
+
+ string_list_sort(out);
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
+static int prune_merged_branches(const struct string_list *upstreams,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int ret = 0;
+
+ if (!upstreams->nr)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ collect_forked_set(upstreams, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
+ struct branch *branch = branch_get(short_name);
+ const char *upstream, *push;
+ struct strbuf full = STRBUF_INIT;
+ int skip;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ skip = !!branch_checked_out(full.buf);
+ strbuf_release(&full);
+ if (skip)
+ continue;
+
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
+ push = branch ? branch_get_push(branch, NULL) : NULL;
+ if (!push || !strcmp(push, upstream))
+ continue;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ 0, /* force */
+ FILTER_REFS_BRANCHES,
+ quiet,
+ 1, /* warn_only */
+ 1, /* no_head_fallback */
+ 0 /* dry_run */);
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -866,6 +952,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
+ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -921,6 +1008,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
N_("list local branches whose upstream matches <branch> (repeatable)")),
+ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
+ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
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")),
@@ -965,7 +1054,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -975,7 +1065,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!prune_merged_upstreams.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
ret = delete_branches(argc, argv, delete > 1, filter.kind,
quiet, 0, 0, 0);
goto out;
+ } else if (prune_merged_upstreams.nr) {
+ if (argc)
+ die(_("--prune-merged does not take positional arguments; "
+ "repeat --prune-merged for each <branch>"));
+ ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
@@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
out:
string_list_clear(&sorting_options, 0);
string_list_clear(&forked_upstreams, 0);
+ string_list_clear(&prune_merged_upstreams, 0);
return ret;
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..beb86987ad 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires a value' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "requires a value" err
+'
+
+test_expect_success '--prune-merged rejects positional arguments' '
+ test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
+ test_grep "does not take positional arguments" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH] git-gui: silence install recipes under "make -s"
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git; +Cc: Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
The split install/uninstall recipes embed "echo" calls that fire
even under "make -s", so install still prints "DEST /path" and
"INSTALL 644 about.tcl" banners. The existing "-s" block only
clears QUIET_GEN.
Wrap the whole "ifndef V" block in the canonical "-s" guard from
shared.mak, and drop the now-redundant narrow block.
Signed-off-by: Harald Nordgren <harald.nordgren@kostdoktorn.se>
---
git-gui: silence install recipes under "make -s"
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2318%2FHaraldNordgren%2Fgit-gui-respect-silent-flag-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2318/HaraldNordgren/git-gui-respect-silent-flag-v1
Pull-Request: https://github.com/git/git/pull/2318
git-gui/Makefile | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/git-gui/Makefile b/git-gui/Makefile
index ca01068810..d33204e875 100644
--- a/git-gui/Makefile
+++ b/git-gui/Makefile
@@ -64,6 +64,7 @@ REMOVE_F0 = $(RM_RF) # space is required here
REMOVE_F1 =
CLEAN_DST = true
+ifneq ($(findstring s,$(firstword -$(MAKEFLAGS))),s)
ifndef V
QUIET = @
QUIET_GEN = $(QUIET)echo ' ' GEN '$@' &&
@@ -89,6 +90,7 @@ ifndef V
REMOVE_F0 = dst=
REMOVE_F1 = && echo ' ' REMOVE `basename "$$dst"` && $(RM_RF) "$$dst"
endif
+endif
TCLTK_PATH ?= wish
ifeq (./,$(dir $(TCLTK_PATH)))
@@ -97,10 +99,6 @@ else
TCL_PATH ?= $(dir $(TCLTK_PATH))$(notdir $(subst wish,tclsh,$(TCLTK_PATH)))
endif
-ifeq ($(findstring $(firstword -$(MAKEFLAGS)),s),s)
-QUIET_GEN =
-endif
-
-include config.mak
DESTDIR_SQ = $(subst ','\'',$(DESTDIR))
base-commit: 1666c1265231b0bc5f613fbbf3f0a9896cdef76e
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add no_head_fallback and dry_run flags to delete_branches() so a
bulk caller (the upcoming --prune-merged) can ask strictly about
merged-into-upstream without a silent fallback to HEAD, and
rehearse deletions with the same "Would delete branch ..." wording
as the live run. Existing callers pass 0 for both and keep current
behavior.
When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
reminder otherwise compares against HEAD. For the bulk caller
every candidate is known to have an upstream, so HEAD is
irrelevant. Guard the block on head_rev so the NULL case skips
it instead of treating "NULL != reference_rev" as "diverges from
HEAD" and emitting a spurious warning.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 27 +++++++++++++++++++--------
1 file changed, 19 insertions(+), 8 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 93d8eae891..09afdd9257 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -169,10 +169,13 @@ static int branch_merged(int kind, const char *name,
* upstream, if any, otherwise with HEAD", we should just
* return the result of the repo_in_merge_bases() above without
* any of the following code, but during the transition period,
- * a gentle reminder is in order.
+ * a gentle reminder is in order. Callers that opt out of the
+ * HEAD fallback by passing head_rev=NULL are not interested in
+ * the reminder either: they have already established that the
+ * branch has an upstream, so HEAD is irrelevant to the decision.
*/
- if (head_rev != reference_rev) {
- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+ if (head_rev && head_rev != reference_rev) {
+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
if (expect < 0)
exit(128);
if (expect == merged)
@@ -225,7 +228,8 @@ static void delete_branch_config(const char *branchname)
}
static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet, int warn_only)
+ int quiet, int warn_only, int no_head_fallback,
+ int dry_run)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -259,7 +263,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)) {
@@ -330,13 +334,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
free(target);
}
- if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+ if (!dry_run &&
+ refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
ret = 1;
for_each_string_list_item(item, &refs_to_delete) {
char *describe_ref = item->util;
char *name = item->string;
- if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+ if (dry_run) {
+ if (!quiet)
+ printf(remote_branch
+ ? _("Would delete remote-tracking branch %s (was %s).\n")
+ : _("Would delete branch %s (was %s).\n"),
+ name + branch_name_pos, describe_ref);
+ } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
if (!quiet)
printf(remote_branch
@@ -1003,7 +1014,7 @@ int cmd_branch(int argc,
if (!argc)
die(_("branch name required"));
ret = delete_branches(argc, argv, delete > 1, filter.kind,
- quiet, 0);
+ quiet, 0, 0, 0);
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a warn_only flag to delete_branches() and check_branch_commit()
so a bulk caller can report not-fully-merged branches as one-line
warnings and continue, instead of erroring with the four-line "use
'git branch -D'" advice that the standalone "git branch -d" path
emits. Default callers pass 0 and are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 +++++++++++++++++---------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 12711b29cf..93d8eae891 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -192,7 +192,7 @@ 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)
{
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
@@ -200,10 +200,16 @@ 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);
+ }
return -1;
}
return 0;
@@ -219,7 +225,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)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -309,8 +315,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)) {
+ if (!warn_only)
+ ret = 1;
goto next;
}
@@ -995,7 +1002,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);
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that keeps only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell-style
glob (e.g. "origin/*"). The option can be repeated to widen the
filter.
Because it is a filter on list mode, --forked composes with the
existing list-mode filters, so
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that have already been
integrated into origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 7 ++
builtin/branch.c | 147 +++++++++++++++++++++++++++++++++-
ref-filter.c | 10 +--
ref-filter.h | 2 +
t/t3200-branch.sh | 92 +++++++++++++++++++++
5 files changed, 249 insertions(+), 9 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..8002d7f38c 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
[--points-at <object>] [--format=<format>]
+ [(--forked <branch>)...]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
git branch [--track[=(direct|inherit)] | --no-track] [-f]
@@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--forked <branch>`::
+ List only branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..12711b29cf 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -28,9 +28,10 @@
#include "help.h"
#include "advice.h"
#include "commit-reach.h"
+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
return strbuf_detach(&fmt, NULL);
}
+static void filter_array_by_forked(struct ref_array *array,
+ const struct string_list *upstreams);
+
static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
- struct ref_format *format, struct string_list *output)
+ struct ref_format *format, struct string_list *output,
+ const struct string_list *forked_upstreams)
{
int i;
struct ref_array array;
@@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
filter_refs(&array, filter, filter->kind);
+ if (forked_upstreams->nr)
+ filter_array_by_forked(&array, forked_upstreams);
+
if (filter->verbose)
maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
@@ -673,6 +681,131 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+struct upstream_pattern {
+ char *name;
+ int is_wildcard;
+};
+
+static void upstream_pattern_list_clear(struct upstream_pattern *items,
+ size_t nr)
+{
+ size_t i;
+ for (i = 0; i < nr; i++)
+ free(items[i].name);
+ free(items);
+}
+
+static const char *short_upstream_name(const char *full_ref)
+{
+ const char *short_name = full_ref;
+ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+ skip_prefix(short_name, "refs/remotes/", &short_name));
+ return short_name;
+}
+
+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
+{
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ if (has_glob_specials(arg)) {
+ out->name = xstrdup(arg);
+ out->is_wildcard = 1;
+ return 0;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
+ starts_with(full_ref, "refs/remotes/"))) {
+ out->name = xstrdup(short_upstream_name(full_ref));
+ out->is_wildcard = 0;
+ free(full_ref);
+ return 0;
+ }
+ free(full_ref);
+ return -1;
+}
+
+static void parse_forked_args(const struct string_list *args,
+ struct upstream_pattern **patterns_out,
+ size_t *nr_out)
+{
+ struct upstream_pattern *patterns;
+ size_t i;
+
+ ALLOC_ARRAY(patterns, args->nr);
+ for (i = 0; i < args->nr; i++) {
+ const char *arg = args->items[i].string;
+ if (parse_one_forked_arg(arg, &patterns[i]) < 0) {
+ upstream_pattern_list_clear(patterns, i);
+ die(_("'%s' is not a valid branch or pattern"), arg);
+ }
+ }
+ *patterns_out = patterns;
+ *nr_out = args->nr;
+}
+
+static int upstream_matches(const char *short_upstream,
+ const struct upstream_pattern *patterns,
+ size_t nr)
+{
+ size_t i;
+
+ for (i = 0; i < nr; i++) {
+ const struct upstream_pattern *p = &patterns[i];
+ if (p->is_wildcard) {
+ if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
+ return 1;
+ } else if (!strcmp(p->name, short_upstream)) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static int branch_upstream_matches(const char *full_refname,
+ const struct upstream_pattern *patterns,
+ size_t nr_patterns)
+{
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+
+ if (!skip_prefix(full_refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
+ return upstream_matches(short_upstream_name(upstream),
+ patterns, nr_patterns);
+}
+
+static void filter_array_by_forked(struct ref_array *array,
+ const struct string_list *upstreams)
+{
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
+ int i, kept = 0;
+
+ parse_forked_args(upstreams, &patterns, &nr_patterns);
+
+ for (i = 0; i < array->nr; i++) {
+ struct ref_array_item *item = array->items[i];
+ if (branch_upstream_matches(item->refname,
+ patterns, nr_patterns))
+ array->items[kept++] = item;
+ else
+ free_ref_array_item(item);
+ }
+ array->nr = kept;
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -714,6 +847,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;
+ struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -767,6 +901,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_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
+ N_("list local branches whose upstream matches <branch> (repeatable)")),
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")),
@@ -815,7 +951,8 @@ int cmd_branch(int argc,
list = 1;
if (filter.with_commit || filter.no_commit ||
- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+ filter.reachable_from || filter.unreachable_from ||
+ filter.points_at.nr || forked_upstreams.nr)
list = 1;
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
@@ -880,7 +1017,8 @@ int cmd_branch(int argc,
ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
ref_sorting_set_sort_flags_all(
sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
- print_ref_list(&filter, sorting, &format, &output);
+ print_ref_list(&filter, sorting, &format, &output,
+ &forked_upstreams);
print_columns(&output, colopts, NULL);
string_list_clear(&output, 0);
ref_sorting_release(sorting);
@@ -1020,5 +1158,6 @@ int cmd_branch(int argc,
out:
string_list_clear(&sorting_options, 0);
+ string_list_clear(&forked_upstreams, 0);
return ret;
}
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..65e7bc6785 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)
}
/* Free memory allocated for a ref_array_item */
-static void free_array_item(struct ref_array_item *item)
+void free_ref_array_item(struct ref_array_item *item)
{
free((char *)item->symref);
if (item->value) {
@@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)
strbuf_release(&output);
strbuf_release(&err);
- free_array_item(item);
+ free_ref_array_item(item);
/*
* Increment the running count of refs that match the filter. If
@@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)
int i;
for (i = 0; i < array->nr; i++)
- free_array_item(array->items[i]);
+ free_ref_array_item(array->items[i]);
FREE_AND_NULL(array->items);
array->nr = array->alloc = 0;
@@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,
if (is_merged == include_reached)
array->items[array->nr++] = array->items[i];
else
- free_array_item(item);
+ free_ref_array_item(item);
}
clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
@@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,
strbuf_release(&err);
strbuf_release(&output);
- free_array_item(ref_item);
+ free_ref_array_item(ref_item);
}
static int parse_sorting_atom(const char *atom)
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..3883b9dc62 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
struct ref_format *format);
/* Clear all memory allocated to ref_array */
void ref_array_clear(struct ref_array *array);
+/* Free a single item from a ref_array */
+void free_ref_array_item(struct ref_array_item *item);
/* Used to verify if the given format is correct and to parse out the used atoms */
int verify_ref_format(struct ref_format *format);
/* Sort the given ref_array as per the ref_sorting provided */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ test_when_finished "git -C forked checkout detached" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v12 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
In-Reply-To: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com>
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Harald Nordgren (6):
branch: add --forked filter for --list mode
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 37 ++++
builtin/branch.c | 317 +++++++++++++++++++++++++--
ref-filter.c | 10 +-
ref-filter.h | 2 +
t/t3200-branch.sh | 354 +++++++++++++++++++++++++++++++
6 files changed, 701 insertions(+), 26 deletions(-)
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v12
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v12
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v11:
1: b9fddd124a ! 1: 8834c424fb branch: add --forked <branch>
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: add --forked <branch>
+ branch: add --forked filter for --list mode
- List local branches whose configured upstream
- (branch.<name>.merge resolved against branch.<name>.remote)
- matches any of the given <branch> arguments.
+ Add a --forked option to "git branch" list mode that keeps only
+ branches whose configured upstream matches <branch>. The argument
+ can be a ref (e.g. "origin/main", "master") or a shell-style
+ glob (e.g. "origin/*"). The option can be repeated to widen the
+ filter.
- Each <branch> is interpreted against the local repository, not
- against any specific remote:
+ Because it is a filter on list mode, --forked composes with the
+ existing list-mode filters, so
- * a literal upstream short name, e.g. "origin/main" or "master"
- for a branch whose upstream is local;
- * a wildmatch pattern, e.g. "origin/*";
- * a bare configured-remote name, e.g. "origin", which resolves
- to whatever refs/remotes/origin/HEAD points at, matching how
- "git checkout -b topic origin" picks a starting point.
+ git branch --merged origin/main --forked 'origin/*'
- The literal-vs-wildcard distinction is settled at parse time so
- the per-branch matching loop calls wildmatch() only for genuine
- wildcards. Multiple <branch> arguments are unioned. Output is
- sorted by branch name.
+ lists branches forked from origin that have already been
+ integrated into origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
@@ Commit message
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: 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 <branch>...
-
- DESCRIPTION
- -----------
+@@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]
+ [--merged [<commit>]] [--no-merged [<commit>]]
+ [--contains [<commit>]] [--no-contains [<commit>]]
+ [--points-at <object>] [--format=<format>]
++ [(--forked <branch>)...]
+ [(-r|--remotes) | (-a|--all)]
+ [--list] [<pattern>...]
+ git branch [--track[=(direct|inherit)] | --no-track] [-f]
@@ Documentation/git-branch.adoc: 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 whose configured upstream
-+ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
-+ matches any of the given _<branch>_ arguments.
-++
-+Each _<branch>_ is interpreted against the local repository: a literal
-+upstream like `origin/main` or a local branch like `master`, or a
-+wildmatch pattern like `'origin/*'`. A bare configured-remote name
-+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
-+to match the way `git checkout -b topic origin` picks a starting
-+point. Multiple _<branch>_ arguments are unioned.
++`--forked <branch>`::
++ List only branches whose configured upstream matches
++ _<branch>_. The argument can be a ref (e.g. `origin/main`,
++ `master`) or a shell-style glob (e.g. `'origin/*'`). The
++ option can be repeated to widen the filter.
+
`-v`::
`-vv`::
@@ builtin/branch.c
+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
-@@ builtin/branch.c: 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 <branch>..."),
- NULL
- };
+- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
++ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
+ N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
+ N_("git branch [<options>] [-l] [<pattern>...]"),
+ N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
+@@ builtin/branch.c: static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
+ return strbuf_detach(&fmt, NULL);
+ }
+
++static void filter_array_by_forked(struct ref_array *array,
++ const struct string_list *upstreams);
++
+ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
+- struct ref_format *format, struct string_list *output)
++ struct ref_format *format, struct string_list *output,
++ const struct string_list *forked_upstreams)
+ {
+ int i;
+ struct ref_array array;
+@@ builtin/branch.c: static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
+
+ filter_refs(&array, filter, filter->kind);
+
++ if (forked_upstreams->nr)
++ filter_array_by_forked(&array, forked_upstreams);
++
+ if (filter->verbose)
+ maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+
+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
+{
-+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct remote *remote;
+ struct object_id oid;
+ char *full_ref = NULL;
-+ struct strbuf head_ref = STRBUF_INIT;
-+ const char *resolved;
+
+ if (has_glob_specials(arg)) {
+ out->name = xstrdup(arg);
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return 0;
+ }
+
-+ remote = remote_get(arg);
-+ if (remote && remote_is_configured(remote, 0)) {
-+ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
-+ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
-+ RESOLVE_REF_NO_RECURSE,
-+ NULL, NULL);
-+ if (resolved && starts_with(resolved, "refs/remotes/")) {
-+ out->name = xstrdup(short_upstream_name(resolved));
-+ out->is_wildcard = 0;
-+ strbuf_release(&head_ref);
-+ return 0;
-+ }
-+ strbuf_release(&head_ref);
-+ }
-+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return -1;
+}
+
-+static void parse_forked_args(int argc, const char **argv,
++static void parse_forked_args(const struct string_list *args,
+ struct upstream_pattern **patterns_out,
+ size_t *nr_out)
+{
+ struct upstream_pattern *patterns;
-+ int i;
++ size_t i;
+
-+ ALLOC_ARRAY(patterns, argc);
-+ for (i = 0; i < argc; i++) {
-+ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
++ ALLOC_ARRAY(patterns, args->nr);
++ for (i = 0; i < args->nr; i++) {
++ const char *arg = args->items[i].string;
++ if (parse_one_forked_arg(arg, &patterns[i]) < 0) {
+ upstream_pattern_list_clear(patterns, i);
-+ die(_("'%s' is not a valid branch or pattern"),
-+ argv[i]);
++ die(_("'%s' is not a valid branch or pattern"), arg);
+ }
+ }
+ *patterns_out = patterns;
-+ *nr_out = argc;
++ *nr_out = args->nr;
+}
+
+static int upstream_matches(const char *short_upstream,
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return 0;
+}
+
-+struct forked_cb {
-+ const struct upstream_pattern *patterns;
-+ size_t nr_patterns;
-+ struct string_list *out;
-+};
-+
-+static int collect_forked_branch(const struct reference *ref, void *cb_data)
++static int branch_upstream_matches(const char *full_refname,
++ const struct upstream_pattern *patterns,
++ size_t nr_patterns)
+{
-+ struct forked_cb *cb = cb_data;
++ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+
-+ if (ref->flags & REF_ISSYMREF)
++ if (!skip_prefix(full_refname, "refs/heads/", &short_name))
+ return 0;
-+ branch = branch_get(ref->name);
++ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
-+ if (upstream_matches(short_upstream_name(upstream),
-+ cb->patterns, cb->nr_patterns))
-+ string_list_append(cb->out, ref->name);
-+ return 0;
++ return upstream_matches(short_upstream_name(upstream),
++ patterns, nr_patterns);
+}
+
-+static int list_forked_branches(int argc, const char **argv)
++static void filter_array_by_forked(struct ref_array *array,
++ const struct string_list *upstreams)
+{
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
-+ struct string_list out = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
-+ struct forked_cb cb;
-+
-+ if (!argc)
-+ die(_("--forked requires at least one <branch>"));
++ int i, kept = 0;
+
-+ parse_forked_args(argc, argv, &patterns, &nr_patterns);
-+ cb.patterns = patterns;
-+ cb.nr_patterns = nr_patterns;
-+ cb.out = &out;
++ parse_forked_args(upstreams, &patterns, &nr_patterns);
+
-+ 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);
++ for (i = 0; i < array->nr; i++) {
++ struct ref_array_item *item = array->items[i];
++ if (branch_upstream_matches(item->refname,
++ patterns, nr_patterns))
++ array->items[kept++] = item;
++ else
++ free_ref_array_item(item);
++ }
++ array->nr = kept;
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
-+ string_list_clear(&out, 0);
-+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
@@ builtin/branch.c: 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;
++ struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: 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 whose upstream matches the given <branch>...")),
++ OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
++ N_("list local branches whose upstream matches <branch> (repeatable)")),
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")),
@@ builtin/branch.c: 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 ||
-@@ builtin/branch.c: int cmd_branch(int argc,
+- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
++ filter.reachable_from || filter.unreachable_from ||
++ filter.points_at.nr || forked_upstreams.nr)
+ list = 1;
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);
-
@@ builtin/branch.c: 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;
+ ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
+ ref_sorting_set_sort_flags_all(
+ sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
+- print_ref_list(&filter, sorting, &format, &output);
++ print_ref_list(&filter, sorting, &format, &output,
++ &forked_upstreams);
+ print_columns(&output, colopts, NULL);
+ string_list_clear(&output, 0);
+ ref_sorting_release(sorting);
+@@ builtin/branch.c: int cmd_branch(int argc,
+
+ out:
+ string_list_clear(&sorting_options, 0);
++ string_list_clear(&forked_upstreams, 0);
+ return ret;
+ }
+
+ ## ref-filter.c ##
+@@ ref-filter.c: static int filter_one(const struct reference *ref, void *cb_data)
+ }
+
+ /* Free memory allocated for a ref_array_item */
+-static void free_array_item(struct ref_array_item *item)
++void free_ref_array_item(struct ref_array_item *item)
+ {
+ free((char *)item->symref);
+ if (item->value) {
+@@ ref-filter.c: static int filter_and_format_one(const struct reference *ref, void *cb_data)
+
+ strbuf_release(&output);
+ strbuf_release(&err);
+- free_array_item(item);
++ free_ref_array_item(item);
+
+ /*
+ * Increment the running count of refs that match the filter. If
+@@ ref-filter.c: void ref_array_clear(struct ref_array *array)
+ int i;
+
+ for (i = 0; i < array->nr; i++)
+- free_array_item(array->items[i]);
++ free_ref_array_item(array->items[i]);
+ FREE_AND_NULL(array->items);
+ array->nr = array->alloc = 0;
+
+@@ ref-filter.c: static void reach_filter(struct ref_array *array,
+ if (is_merged == include_reached)
+ array->items[array->nr++] = array->items[i];
+ else
+- free_array_item(item);
++ free_ref_array_item(item);
+ }
+
+ clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
+@@ ref-filter.c: void pretty_print_ref(const char *name, const struct object_id *oid,
+
+ strbuf_release(&err);
+ strbuf_release(&output);
+- free_array_item(ref_item);
++ free_ref_array_item(ref_item);
+ }
+
+ static int parse_sorting_atom(const char *atom)
+
+ ## ref-filter.h ##
+@@ ref-filter.h: void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
+ struct ref_format *format);
+ /* Clear all memory allocated to ref_array */
+ void ref_array_clear(struct ref_array *array);
++/* Free a single item from a ref_array */
++void free_ref_array_item(struct ref_array_item *item);
+ /* Used to verify if the given format is correct and to parse out the used atoms */
+ int verify_ref_format(struct ref_format *format);
+ /* Sort the given ref_array as per the ref_sorting provided */
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ git -C forked branch --track local-trunk local-base
+'
+
-+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
-+ git -C forked branch --forked origin/one >actual &&
++test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
++ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked <glob> matches by wildmatch' '
-+ git -C forked branch --forked "origin/*" >actual &&
++test_expect_success '--forked <glob> filters by wildmatch' '
++ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
-+ git -C forked branch --forked local-base >actual &&
++ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
-+ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
-+ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
-+ git -C forked branch --forked origin >actual &&
-+ echo local-one >expect &&
-+ test_cmp expect actual
-+'
-+
-+test_expect_success '--forked unions multiple <branch> arguments' '
-+ git -C forked branch --forked origin/one other/foreign >actual &&
++test_expect_success '--forked can be repeated to widen the filter' '
++ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
-+ git -C forked branch --forked local-base "other/*" >actual &&
++ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
-+ git -C forked branch --forked "*/*" >actual &&
++ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ test_cmp expect actual
+'
+
++test_expect_success '--forked composes with --no-merged' '
++ test_when_finished "git -C forked checkout detached" &&
++ git -C forked checkout local-one &&
++ test_commit -C forked local-only &&
++ git -C forked branch --forked "origin/*" --no-merged origin/one \
++ --format="%(refname:short)" >actual &&
++ echo local-one >expect &&
++ test_cmp expect actual
++'
++
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
-+test_expect_success '--forked requires at least one <branch>' '
++test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
-+ test_grep "at least one <branch>" err
++ test_grep "requires a value" err
+'
+
test_done
2: b666d09bf5 ! 2: 6c95e4e77c branch: let delete_branches warn instead of error on bulk refusal
@@ Commit message
so a bulk caller can report not-fully-merged branches as one-line
warnings and continue, instead of erroring with the four-line "use
'git branch -D'" advice that the standalone "git branch -d" path
- emits. Default callers pass 0 and are unaffected.
+ emits. Default callers pass 0 and are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: int cmd_branch(int argc,
+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ quiet, 0);
goto out;
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ } else if (show_current) {
+ print_current_branch_name();
3: 6e6580270e ! 3: 004a96f7a4 branch: prepare delete_branches for a bulk caller
@@ Metadata
## Commit message ##
branch: prepare delete_branches for a bulk caller
- Add no_head_fallback and dry_run flags to delete_branches() so a bulk
- caller (the upcoming --prune-merged) can ask strictly about
- merged-into-upstream without a silent fallback to HEAD, and rehearse
- deletions with the same "Would delete branch ..." wording as the live
- run. Existing callers pass 0 for both and keep current behavior.
+ Add no_head_fallback and dry_run flags to delete_branches() so a
+ bulk caller (the upcoming --prune-merged) can ask strictly about
+ merged-into-upstream without a silent fallback to HEAD, and
+ rehearse deletions with the same "Would delete branch ..." wording
+ as the live run. Existing callers pass 0 for both and keep current
+ behavior.
When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
- reminder otherwise compares against HEAD. That reminder is only
- meaningful when the caller actually cares about HEAD; for the
- bulk caller every candidate is known to have an upstream and HEAD
- is irrelevant to the decision. Guard the block on head_rev so the
- NULL case skips it instead of treating "NULL != reference_rev" as
- "diverges from HEAD" and emitting a spurious warning.
+ reminder otherwise compares against HEAD. For the bulk caller
+ every candidate is known to have an upstream, so HEAD is
+ irrelevant. Guard the block on head_rev so the NULL case skips
+ it instead of treating "NULL != reference_rev" as "diverges from
+ HEAD" and emitting a spurious warning.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: int cmd_branch(int argc,
- quiet, 0);
+ quiet, 0, 0, 0);
goto out;
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ } else if (show_current) {
+ print_current_branch_name();
4: e7e03c1338 ! 4: cccfdb831c branch: add --prune-merged <branch>
@@ Commit message
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
- Reachability is read from the local refs only -- nothing is
- fetched. Users who want fresh upstream refs run "git fetch" first;
- the deletion path stays a separate, idempotent step that also
- works offline.
+ Reachability is read from local refs; nothing is fetched. Users
+ who want fresh upstream refs run "git fetch" first.
Three classes of branches are spared:
@@ Commit message
* any branch whose push destination equals its upstream
(<branch>@{push} == <branch>@{upstream}). Such a branch
cannot be distinguished from a freshly pulled trunk that
- just looks "fully merged" -- e.g. local "main" tracking and
+ just looks "fully merged", e.g. local "main" tracking and
pushing to "origin/main" right after a pull. Only branches
that push somewhere other than their upstream (typically
topics in a fork-based workflow) are treated as candidates.
@@ Commit message
mode and with the HEAD-fallback disabled: a branch that is not
yet fully merged to its upstream is reported as a one-line warning
and skipped, so a single un-mergeable topic does not abort the
- whole sweep, and there is no fallback to "merged into the
- currently checked out branch" -- we only act on upstream-merged
- status.
+ whole sweep. We only act on upstream-merged status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
+@@ Documentation/git-branch.adoc: 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 <branch>...
-+git branch --prune-merged <branch>...
++git branch (--prune-merged <branch>)...
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare configured-remote name
- to match the way `git checkout -b topic origin` picks a starting
- point. Multiple _<branch>_ arguments are unioned.
+@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter.
-+`--prune-merged`::
++`--prune-merged <branch>`::
+ Delete the local branches that `--forked` would list for the
-+ same _<branch>_ arguments, but only those whose tip is
-+ reachable from their configured upstream. In other words,
-+ the work on the branch has already landed on the upstream it
-+ tracks, so the local copy is no longer needed.
++ same _<branch>_, but only those whose tip is reachable from
++ their configured upstream. In other words, the work on the
++ branch has already landed on the upstream it tracks, so the
++ local copy is no longer needed. May be given more than once to
++ union the matches; positional arguments are not accepted.
++
+Reachability is checked against whatever the upstream refs say
-+locally; nothing is fetched. Run `git fetch` first if you want
++locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare conf
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
+@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
+ N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
+ N_("git branch [<options>] [-r | -a] [--points-at]"),
+ N_("git branch [<options>] [-r | -a] [--format]"),
++ N_("git branch [<options>] (--prune-merged <branch>)..."),
+ NULL
+ };
+
+@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
return 0;
}
--static int list_forked_branches(int argc, const char **argv)
-+static void collect_forked_set(int argc, const char **argv,
-+ struct string_list *out)
+-static int branch_upstream_matches(const char *full_refname,
++static int branch_upstream_matches(const char *short_branch_name,
+ const struct upstream_pattern *patterns,
+ size_t nr_patterns)
{
- struct upstream_pattern *patterns = NULL;
- size_t nr_patterns = 0;
-- struct string_list out = STRING_LIST_INIT_DUP;
-- struct string_list_item *item;
- struct forked_cb cb;
+- const char *short_name;
+- struct branch *branch;
++ struct branch *branch = branch_get(short_branch_name);
+ const char *upstream;
-- if (!argc)
-- die(_("--forked requires at least one <branch>"));
--
- parse_forked_args(argc, argv, &patterns, &nr_patterns);
- cb.patterns = patterns;
- cb.nr_patterns = nr_patterns;
-- cb.out = &out;
-+ cb.out = out;
+- if (!skip_prefix(full_refname, "refs/heads/", &short_name))
+- return 0;
+- branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
- refs_for_each_branch_ref(get_main_ref_store(the_repository),
- collect_forked_branch, &cb);
+ for (i = 0; i < array->nr; i++) {
+ struct ref_array_item *item = array->items[i];
+- if (branch_upstream_matches(item->refname,
+- patterns, nr_patterns))
++ const char *short_name;
++ if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
++ branch_upstream_matches(short_name, patterns, nr_patterns))
+ array->items[kept++] = item;
+ else
+ free_ref_array_item(item);
+@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
+ upstream_pattern_list_clear(patterns, nr_patterns);
+ }
-- string_list_sort(&out);
-+ string_list_sort(out);
++struct forked_cb {
++ const struct upstream_pattern *patterns;
++ size_t nr_patterns;
++ struct string_list *out;
++};
+
-+ upstream_pattern_list_clear(patterns, nr_patterns);
++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_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
++ string_list_append(cb->out, ref->name);
++ return 0;
+}
+
-+static int list_forked_branches(int argc, const char **argv)
++static void collect_forked_set(const struct string_list *upstreams,
++ struct string_list *out)
+{
-+ struct string_list out = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
++ struct upstream_pattern *patterns = NULL;
++ size_t nr_patterns = 0;
++ struct forked_cb cb;
+
-+ if (!argc)
-+ die(_("--forked requires at least one <branch>"));
++ parse_forked_args(upstreams, &patterns, &nr_patterns);
++ cb.patterns = patterns;
++ cb.nr_patterns = nr_patterns;
++ cb.out = out;
+
-+ collect_forked_set(argc, argv, &out);
- for_each_string_list_item(item, &out)
- puts(item->string);
-
-- upstream_pattern_list_clear(patterns, nr_patterns);
- string_list_clear(&out, 0);
- return 0;
- }
-
-+static int prune_merged_branches(int argc, const char **argv, int quiet)
++ refs_for_each_branch_ref(get_main_ref_store(the_repository),
++ collect_forked_branch, &cb);
++
++ string_list_sort(out);
++
++ upstream_pattern_list_clear(patterns, nr_patterns);
++}
++
++static int prune_merged_branches(const struct string_list *upstreams,
++ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list candidates = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ struct string_list_item *item;
+ int ret = 0;
+
-+ if (!argc)
++ if (!upstreams->nr)
+ die(_("--prune-merged requires at least one <branch>"));
+
-+ collect_forked_set(argc, argv, &candidates);
++ collect_forked_set(upstreams, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
@@ builtin/branch.c: 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;
+ struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
++ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
N_("edit the description for the branch")),
- OPT_BOOL(0, "forked", &forked,
- N_("list local branches whose upstream matches the given <branch>...")),
-+ OPT_BOOL(0, "prune-merged", &prune_merged,
-+ N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
+ N_("list local branches whose upstream matches <branch> (repeatable)")),
++ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
++ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
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")),
@@ builtin/branch.c: 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 &&
+- !show_current && !unset_upstream && argc == 0)
++ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: 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;
+- !!unset_upstream;
++ !!unset_upstream + !!prune_merged_upstreams.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ quiet, 0, 0, 0);
goto out;
-+ } else if (prune_merged) {
-+ ret = prune_merged_branches(argc, argv, quiet);
++ } else if (prune_merged_upstreams.nr) {
++ if (argc)
++ die(_("--prune-merged does not take positional arguments; "
++ "repeat --prune-merged for each <branch>"));
++ ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
+@@ builtin/branch.c: int cmd_branch(int argc,
+ out:
+ string_list_clear(&sorting_options, 0);
+ string_list_clear(&forked_upstreams, 0);
++ string_list_clear(&prune_merged_upstreams, 0);
+ return ret;
+ }
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '
- test_grep "at least one <branch>" err
+@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
-+ git -C pm-union branch --prune-merged origin/next origin/main &&
++ git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
-+test_expect_success '--prune-merged requires at least one <branch>' '
++test_expect_success '--prune-merged requires a value' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
-+ test_grep "at least one <branch>" err
++ test_grep "requires a value" err
++'
++
++test_expect_success '--prune-merged rejects positional arguments' '
++ test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
++ test_grep "does not take positional arguments" err
+'
+
test_done
5: 75b6d2366a ! 5: 5f793f8d0d branch: add branch.<name>.pruneMerged opt-out
@@ Documentation/git-branch.adoc: the upstream refs refreshed.
warnings and skipped; pass them to `git branch -D` explicitly if
## builtin/branch.c ##
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
struct branch *branch = branch_get(short_name);
const char *upstream, *push;
struct strbuf full = STRBUF_INIT;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
strbuf_addf(&full, "refs/heads/%s", short_name);
skip = !!branch_checked_out(full.buf);
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
if (!push || !strcmp(push, upstream))
continue;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
- test_grep "at least one <branch>" err
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
+ test_grep "does not take positional arguments" err
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
6: a1a42a6b19 ! 6: 1a0d5eab15 branch: add --dry-run for --prune-merged
@@ Commit message
branch: add --dry-run for --prune-merged
With --dry-run, --prune-merged prints the local branches it would
- delete -- one "Would delete branch <name>" line per candidate --
- and exits without touching any ref.
+ delete, one "Would delete branch <name>" line per candidate, and
+ exits without touching any ref.
- This is the natural sanity check before letting a broad pattern
- like 'origin/*' run for real: the @{push}-vs-@{upstream} and
- unmerged filtering still applies, so the dry-run output is
- exactly the set that the live run would delete.
+ The @{push}-vs-@{upstream} and unmerged filtering still applies,
+ so the dry-run output is exactly the set that the live run would
+ delete.
--dry-run is only meaningful in combination with --prune-merged
and is rejected otherwise.
@@ Commit message
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
+@@ Documentation/git-branch.adoc: 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 <branch>...
--git branch --prune-merged <branch>...
-+git branch --prune-merged [--dry-run] <branch>...
+-git branch (--prune-merged <branch>)...
++git branch [--dry-run] (--prune-merged <branch>)...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety che
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
- return 0;
+@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,
}
--static int prune_merged_branches(int argc, const char **argv, int quiet)
-+static int prune_merged_branches(int argc, const char **argv, int quiet,
-+ int dry_run)
+ static int prune_merged_branches(const struct string_list *upstreams,
+- int quiet)
++ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct string_list candidates = STRING_LIST_INIT_DUP;
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
quiet,
1, /* warn_only */
1, /* no_head_fallback */
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
string_list_clear(&candidates, 0);
@@ builtin/branch.c: int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int forked = 0;
- int prune_merged = 0;
+ struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
+ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
- N_("list local branches whose upstream matches the given <branch>...")),
- OPT_BOOL(0, "prune-merged", &prune_merged,
- N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ N_("list local branches whose upstream matches <branch> (repeatable)")),
+ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
+ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --prune-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !prune_merged)
++ if (dry_run && !prune_merged_upstreams.nr)
+ die(_("--dry-run requires --prune-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: int cmd_branch(int argc,
- ret = list_forked_branches(argc, argv);
- goto out;
- } else if (prune_merged) {
-- ret = prune_merged_branches(argc, argv, quiet);
-+ ret = prune_merged_branches(argc, argv, quiet, dry_run);
+ if (argc)
+ die(_("--prune-merged does not take positional arguments; "
+ "repeat --prune-merged for each <branch>"));
+- ret = prune_merged_branches(&prune_merged_upstreams, quiet);
++ ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ git -C pm-dry branch two two-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next two &&
+
-+ git -C pm-dry branch --prune-merged --dry-run "origin/*" >actual &&
++ git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
+ test_grep "Would delete branch one " actual &&
+ test_grep "Would delete branch two " actual &&
+
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ git -C pm-dry-mixed branch merged one-commit &&
+ git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
-+ git -C pm-dry-mixed branch --prune-merged --dry-run "origin/*" >out &&
++ git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
+ test_grep "Would delete branch merged" out &&
+ test_grep ! "Would delete branch wip" out &&
+ git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
--
gitgitgadget
^ permalink raw reply
* Re: [PATCH 2/2] Documentation/MyFirstContribution: recommend the use of b4
From: Weijie Yuan @ 2026-06-03 8:00 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Junio C Hamano
In-Reply-To: <ah_dh3uozNdYcL0_@wyuan.org>
That said, my ideas may be too nitpicking. Overall, I do
think that introducing b4 support would be a good thing.
Thanks for the replies, and for bearing with my questions/comments.
Weijie Yuan
^ permalink raw reply
* Re: [PATCH 2/2] Documentation/MyFirstContribution: recommend the use of b4
From: Weijie Yuan @ 2026-06-03 7:53 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Junio C Hamano
In-Reply-To: <ah_PwOsbYfDCx0H2@pks.im>
On Wed, Jun 03, 2026 at 08:54:56AM +0200, Patrick Steinhardt wrote:
> Ah, that's what you're hinting at. So you mean to say that folks should
> first understand the basics before basically automating all of the parts
> for them?
>
> I guess I can see where you're coming from, but I'm not sure I agree
> with this a 100%. My main goal is to make it easier for new community
> members to contribute to Git, and that means that we should automate all
> the hard parts as far as possible. This saves those new contributors
> from frustration, and it means that reviewers on the mailing list won't
> have to teach every single new contributor about how they should thread
> the mails, generate range-diffs and the like.
>
> So in the end, it saves both their and our time, but the learning
> opportunity is of course a bit diminished. I'd gladly accept that
> tradeoff though.
Yeah, after I expressed my opinion, I also felt a bit conflicted though.
So I also agree with your intension.
Make an inappropriate metaphor: some usage of b4 and magit are "Porcelain"
to Git. Whether how you are good at using those porcelains like magit or
lazygit, in the end, you will eventually have to face git cli one day.
So the same for b4. If we list these three methods equally and
simultaneously, the logic might be not that correct.
Your proposal:
contribution workflow
|
-------------------------------------
| | |
v v v
GitGitGadget traditional email b4
```
But I would frame it more like this:
contribution workflow
|
-------------------------
| |
v v
GitGitGadget traditional email workflow
\
\
b4
For exmaple, If I am at this page the fisrt time:
https://git-scm.com/docs/MyFirstContribution
And, I see these 3 ways, okay, I choose b4.
After installing b4 and reading some manuals, I would wonder: what's
cover letter? what's Message-ID? So after a while, I would still have to
learn those stuff and how b4 indeed optimize those complicated process.
So, to put it another way.. b4 is developed for high-level maintainers,
who are apparently familiar with traditional ways. Therefore b4 saves
their time. But for some beginers like me, I still have to know those
concepts first.
But yeah, there are definitely some people would happily accept b4 and
contribute easily. Thus, I agree this tradeoff.
--
Sent before reading v2, hope there's no conflict :-)
^ permalink raw reply
* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Weijie Yuan @ 2026-06-03 7:50 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <ah_PyDwO1Sffr5yq@pks.im>
On Wed, Jun 03, 2026 at 08:55:04AM +0200, Patrick Steinhardt wrote:
> So this quote is definitely at odds with the configuration I have
> proposed. It's actually quite surprising to me that we recommend deep
> threading -- I personally find it extremely hard to navigate as the
> nesting eventually gets way too deep.
Sorry I'm a little confused. The example thread at git-scm.com:
https://git-scm.com/docs/MyFirstContribution#ready-to-share
Isn't this actually supporting shallow nesting?
> It's actually quite surprising to me that we recommend deep
> threading -- I personally find it extremely hard to navigate as the
> nesting eventually gets way too deep.
In my understanding, deep threading == --chain-reply-to, so can you
point out where do Git recommend deep threading? I always thought Git
supports shallow threading.
Thanks! And please forgive me if I am wrong :-)
^ permalink raw reply
* [PATCH v2 3/3] b4: introduce configuration for the Git project
From: Patrick Steinhardt @ 2026-06-03 6:59 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano, Tuomas Ahola, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-0-a8aea0aa2c23@pks.im>
We're about to extend our documentation to recommend b4 for sending
patch series to the mailing list. Prepare for this by introducing a b4
configuration so that the tool knows to honor our preferences. For now,
this configuration does two things:
- It configures "send-same-thread = shallow", which tells b4 to always
send subsequent versions of the same patch series as a reply to the
cover letter of the first version.
- It configures "prep-cover-template", which tells b4 to use a custom
template for the cover letter. The most important change compared to
the default template is that our custom template also includes a
range-diff.
There's potentially more things that we may want to configure going
forward, like for example auto-configuration of folks to Cc on certain
patches. But these two tweaks feel like a good place to start.
Note that these values only serve as defaults, and users may want to
tweak those defaults based on their own preference. Luckily, users can
do that without having to touch `.b4-config` at all, as b4 allows them
to override values via Git configuration:
```
$ git config set b4.prep-cover-template /does/not/exist
$ b4 send --dry-run
ERROR: prep-cover-template says to use x, but it does not exist
```
So this gives users an easy way to override our defaults without having
to touch ".b4-config", which would dirty the tree.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
.b4-config | 6 ++++++
.b4-cover-template | 11 +++++++++++
2 files changed, 17 insertions(+)
diff --git a/.b4-config b/.b4-config
new file mode 100644
index 0000000000..fd4fb56b6d
--- /dev/null
+++ b/.b4-config
@@ -0,0 +1,6 @@
+# Note that these are default values that you can tweak via the typical
+# git-config(1) machinery. You thus shouldn't ever have to change this file.
+# See also https://b4.docs.kernel.org/en/latest/config.html.
+[b4]
+send-same-thread = shallow
+prep-cover-template = ./.b4-cover-template
diff --git a/.b4-cover-template b/.b4-cover-template
new file mode 100644
index 0000000000..ab864933b5
--- /dev/null
+++ b/.b4-cover-template
@@ -0,0 +1,11 @@
+${cover}
+
+---
+${shortlog}
+
+${diffstat}
+
+${range_diff}
+---
+base-commit: ${base_commit}
+${prerequisites}
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 2/3] Documentation/MyFirstContribution: recommend the use of b4
From: Patrick Steinhardt @ 2026-06-03 6:59 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano, Tuomas Ahola, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-0-a8aea0aa2c23@pks.im>
The b4 tool originates from the Linux kernel community and is intended
to help mailing-list based workflows. It automates a lot of the annoying
bookkeeping tasks that contributors typically need to do: tracking the
list of recipients, Message-IDs, range-diffs and the like. In addition
to that, b4 also has many other subcommands that help the maintainer and
reviewers.
The Git project uses the same infrastructure as the kernel, so this tool
is also a very good fit for us. Adapt "MyFirstContribution" to
explicitly recommend its use.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Documentation/MyFirstContribution.adoc | 92 ++++++++++++++++++++++++++++++++--
Documentation/SubmittingPatches | 6 ++-
2 files changed, 93 insertions(+), 5 deletions(-)
diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
index 069020196c..fc0b06ae67 100644
--- a/Documentation/MyFirstContribution.adoc
+++ b/Documentation/MyFirstContribution.adoc
@@ -833,7 +833,7 @@ This patchset is part of the MyFirstContribution tutorial and should not
be merged.
----
-At this point the tutorial diverges, in order to demonstrate two
+At this point the tutorial diverges, in order to demonstrate three
different methods of formatting your patchset and getting it reviewed.
The first method to be covered is GitGitGadget, which is useful for those
@@ -845,9 +845,14 @@ more fine-grained control over the emails to be sent. This method requires some
setup which can change depending on your system and will not be covered in this
tutorial.
+The third method to be covered is `b4`, which builds on top of `git
+format-patch` and `git send-email`. This method is the recommended way to
+submit patches via mail as it automates a lot of the bookkeeping required by
+`git send-email`.
+
Regardless of which method you choose, your engagement with reviewers will be
-the same; the review process will be covered after the sections on GitGitGadget
-and `git send-email`.
+the same; the review process will be covered after the sections on GitGitGadget,
+`git send-email` and `b4`.
[[howto-ggg]]
== Sending Patches via GitGitGadget
@@ -1296,6 +1301,87 @@ index 88f126184c..38da593a60 100644
2.21.0.392.gf8f6787159e-goog
----
+[[howto-b4]]
+== Sending Patches with `b4`
+
+`b4` is a tool that builds on top of `git format-patch` and `git send-email`.
+It automates much of the bookkeeping involved in sending a patch series to a
+mailing-list-based project.
+
+Refer to the https://b4.docs.kernel.org/[b4 documentation] for a full reference.
+
+[[prep-b4]]
+=== Preparing a Patch Series
+
+`b4` tracks your patch series as a branch. To start tracking the `psuh` branch
+you have been working on, run:
+
+----
+$ b4 prep --enroll master
+----
+
+This enrolls the current branch, using `master` as the base of the topic. `b4`
+manages the cover letter as part of the branch, so you can edit it at any time
+with:
+
+----
+$ b4 prep --edit-cover
+----
+
+The cover letter not only tracks the content of the top-level mail, but also
+the set of recipients. You can add recipients by adding `To:` and `Cc:`
+trailer lines.
+
+[[send-b4]]
+=== Sending the Patches
+
+Before sending the series out for real, you can inspect what `b4` would send by
+passing `--dry-run`:
+
+----
+$ b4 send --dry-run
+----
+
+Once you are happy with the result, send the series with:
+
+----
+$ b4 send
+----
+
+[[v2-b4]]
+=== Sending v2
+
+When you are ready to send a new iteration of your series, refine your
+patches as usual using linkgit:git-rebase[1]. Note that you typically want to
+rebase on top of the cover letter. You can configure an alias to enable easy
+rebases going forward:
+
+---
+$ git config set alias.b4-rebase 'rebase "HEAD^{/--- b4-submit-tracking ---}"'
+$ git b4-rebase -i
+---
+
+Before sending out the new version you should also update the cover letter with
+`b4 prep --edit-cover` to note the relevant changes compared to the previous
+version. You can inspect the changes between the two versions with `b4 prep
+--compare-to=v1`.
+
+Same as with the first version, you can use `b4 send` to send out the second
+version. `b4` automatically bumps the version to `v2`, generates the range-diff
+against the previous iteration, and threads the new series as a reply to the
+cover letter of the first version.
+
+[[configure-b4]]
+=== Configure b4
+
+`b4` can be configured via linkgit:git-config[1]. In addition to that, projects
+can have their own set of defaults in `.b4-config` in the root tree, which also
+uses Git's config format. The user's configuration always takes precedence over
+the per-project defaults.
+
+Refer to the https://b4.docs.kernel.org/en/latest/config.html[b4 config documentation]
+for more information on the available options.
+
[[now-what]]
== My Patch Got Emailed - Now What?
diff --git a/Documentation/SubmittingPatches b/Documentation/SubmittingPatches
index d570184ec8..99427e1ee1 100644
--- a/Documentation/SubmittingPatches
+++ b/Documentation/SubmittingPatches
@@ -573,8 +573,10 @@ your existing e-mail client (often optimized for "multipart/*" MIME
type e-mails) might render your patches unusable.
NOTE: Here we outline the procedure using `format-patch` and
-`send-email`, but you can instead use GitGitGadget to send in your
-patches (see link:MyFirstContribution.html[MyFirstContribution]).
+`send-email`, but you can instead use GitGitGadget or `b4` to send in
+your patches (see link:MyFirstContribution.html[MyFirstContribution]).
+Contributors are encouraged to use `b4`, which automates much of the
+bookkeeping that is otherwise done by hand.
People on the Git mailing list need to be able to read and
comment on the changes you are submitting. It is important for
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 1/3] Documentation/MyFirstContribution: recommend shallow threading
From: Patrick Steinhardt @ 2026-06-03 6:58 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano, Tuomas Ahola, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260603-pks-b4-v2-0-a8aea0aa2c23@pks.im>
The "MyFirstContribution" document recommends the use of deep threading:
every cover letter of subsequent iterations shall be linked to the cover
letter of the preceding version. The result of this is that eventually,
threads with many versions are getting nested so deep that it becomes
hard to follow.
Adapt the recommendation to instead propose shallow threading: instead
of linking the cover letter to the previous cover letter, the user is
supposed to always link it to the first cover letter. This still makes
it easy to follow the iterations, but has the benefit of nesting to a
much shallower level.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Documentation/MyFirstContribution.adoc | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc
index b9fdefce02..069020196c 100644
--- a/Documentation/MyFirstContribution.adoc
+++ b/Documentation/MyFirstContribution.adoc
@@ -1227,8 +1227,8 @@ Message-ID: <foo.12345.author@example.com>
Your Message-ID is `<foo.12345.author@example.com>`. This example will be used
below as well; make sure to replace it with the correct Message-ID for your
-**previous cover letter** - that is, if you're sending v2, use the Message-ID
-from v1; if you're sending v3, use the Message-ID from v2.
+**first cover letter** - that is, for any subsequent version that you send,
+always use the Message-ID from v1.
While you're looking at the email, you should also note who is CC'd, as it's
common practice in the mailing list to keep all CCs on a thread. You can add
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 0/3] Documentation: recommend the use of b4
From: Patrick Steinhardt @ 2026-06-03 6:58 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano, Tuomas Ahola, Weijie Yuan, Ramsay Jones
In-Reply-To: <20260602-pks-b4-v1-0-a7ae5a49e9cf@pks.im>
Hi,
this small patch series wires up b4 in Git and recommends the use
thereof via "MyFirstContribution", as discussed in [1].
Changes in v2:
- Reorder commits so that the b4 docs are added first.
- Add a section that highlights how to configure b4, and that points
out that the per-project defaults can be overridden via Git
configuration.
- Add a patch to MyFirstContribution that recommends shallow
threading. I mostly intend this to be a discussion starter so that
the `.b4-config` file matches our preferred threading style.
- Fix a typo.
- Link to v1: https://patch.msgid.link/20260602-pks-b4-v1-0-a7ae5a49e9cf@pks.im
Thanks!
Patrick
[1]: <xmqqik81xpqx.fsf@gitster.g>
---
Patrick Steinhardt (3):
Documentation/MyFirstContribution: recommend shallow threading
Documentation/MyFirstContribution: recommend the use of b4
b4: introduce configuration for the Git project
.b4-config | 6 +++
.b4-cover-template | 11 ++++
Documentation/MyFirstContribution.adoc | 96 ++++++++++++++++++++++++++++++++--
Documentation/SubmittingPatches | 6 ++-
4 files changed, 112 insertions(+), 7 deletions(-)
Range-diff versus v1:
-: ---------- > 1: 359ce9ec24 Documentation/MyFirstContribution: recommend shallow threading
2: 55fffeb8f8 ! 2: ce9aa56846 Documentation/MyFirstContribution: recommend the use of b4
@@ Documentation/MyFirstContribution.adoc: index 88f126184c..38da593a60 100644
+version. `b4` automatically bumps the version to `v2`, generates the range-diff
+against the previous iteration, and threads the new series as a reply to the
+cover letter of the first version.
++
++[[configure-b4]]
++=== Configure b4
++
++`b4` can be configured via linkgit:git-config[1]. In addition to that, projects
++can have their own set of defaults in `.b4-config` in the root tree, which also
++uses Git's config format. The user's configuration always takes precedence over
++the per-project defaults.
++
++Refer to the https://b4.docs.kernel.org/en/latest/config.html[b4 config documentation]
++for more information on the available options.
+
[[now-what]]
== My Patch Got Emailed - Now What?
1: 0fe6cf8511 ! 3: e2bf7b6e46 b4: introduce configuration for the Git project
@@ Commit message
b4: introduce configuration for the Git project
We're about to extend our documentation to recommend b4 for sending
- patch series ot the mailing list. Prepare for this by introducing a b4
+ patch series to the mailing list. Prepare for this by introducing a b4
configuration so that the tool knows to honor our preferences. For now,
this configuration does two things:
@@ Commit message
forward, like for example auto-configuration of folks to Cc on certain
patches. But these two tweaks feel like a good place to start.
+ Note that these values only serve as defaults, and users may want to
+ tweak those defaults based on their own preference. Luckily, users can
+ do that without having to touch `.b4-config` at all, as b4 allows them
+ to override values via Git configuration:
+
+ ```
+ $ git config set b4.prep-cover-template /does/not/exist
+ $ b4 send --dry-run
+ ERROR: prep-cover-template says to use x, but it does not exist
+ ```
+
+ So this gives users an easy way to override our defaults without having
+ to touch ".b4-config", which would dirty the tree.
+
Signed-off-by: Patrick Steinhardt <ps@pks.im>
## .b4-config (new) ##
@@
++# Note that these are default values that you can tweak via the typical
++# git-config(1) machinery. You thus shouldn't ever have to change this file.
++# See also https://b4.docs.kernel.org/en/latest/config.html.
+[b4]
+send-same-thread = shallow
+prep-cover-template = ./.b4-cover-template
---
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
change-id: 20260602-pks-b4-31cc20d7f84b
^ permalink raw reply
* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Patrick Steinhardt @ 2026-06-03 6:55 UTC (permalink / raw)
To: Weijie Yuan; +Cc: Tuomas Ahola, git, Junio C Hamano
In-Reply-To: <ah-Nhr2PboWUq6eU@wyuan.org>
On Wed, Jun 03, 2026 at 10:12:22AM +0800, Weijie Yuan wrote:
> On Tue, Jun 02, 2026 at 08:09:55PM +0300, Tuomas Ahola wrote:
> > Huh? Doesn't MyFirstContribution speak *against* shallow threading?
> >
> > [...] make sure to replace it with the correct Message-ID for your
> > **previous cover letter** - that is, if you're sending v2, use the Message-ID
> > from v1; if you're sending v3, use the Message-ID from v2.
>
> I don't get it. Doesn't shallow threading means every following patches
> are replying to the cover letter? Replying to the previous one is
> --chain-reply-to, if I'm not mistaken.
Shallow threading basically means that all patches are sent as a
response to the current cover letter, and the current cover letter is
always attached to the cover letter of the _first_ version.
So this quote is definitely at odds with the configuration I have
proposed. It's actually quite surprising to me that we recommend deep
threading -- I personally find it extremely hard to navigate as the
nesting eventually gets way too deep.
You know -- I'll include a patch that changes the wording there to also
use shallow nesting, mostly to kick off a discussion and arrive at a
decision there.
Thanks!
Patrick
^ permalink raw reply
* Re: [PATCH 2/2] Documentation/MyFirstContribution: recommend the use of b4
From: Patrick Steinhardt @ 2026-06-03 6:54 UTC (permalink / raw)
To: Weijie Yuan; +Cc: git, Junio C Hamano
In-Reply-To: <ah8ALHMDVA2Gzz10@wyuan.org>
On Wed, Jun 03, 2026 at 12:09:16AM +0800, Weijie Yuan wrote:
> > +Contributors are encouraged to use `b4`, which automates much of the
> > +bookkeeping that is otherwise done by hand.
>
> So for statement like this and with my personal experience, I would say
> b4 is a more suitable option for senior contributors, as they already
> know, for example, what Message-ID and range-diffs are. But apparently,
> whose who use forges may not know.
I think it's perfectly suitable for newcomers, too. It automates so many
of the concepts that a contributor has to learn way less about mailing
list specific concepts, which reduces the learning curve.
> Back to the patch, I think regarding b4 as a more advanced contribution
> way for those who had contributed via mailing lists for more than one
> time is a better expression or formulation. Here I mean "b4 prep", other
> usage like "b4 mbox" and "b4 am" are of course more basic, and be
> mentioned as tips when interacting with Git mailing list.
>
> A bit too wordy, in conclusion: Suggest that new contributors master
> classic git operations first. When they are familiar with those process,
> b4 might be a good option.
Ah, that's what you're hinting at. So you mean to say that folks should
first understand the basics before basically automating all of the parts
for them?
I guess I can see where you're coming from, but I'm not sure I agree
with this a 100%. My main goal is to make it easier for new community
members to contribute to Git, and that means that we should automate all
the hard parts as far as possible. This saves those new contributors
from frustration, and it means that reviewers on the mailing list won't
have to teach every single new contributor about how they should thread
the mails, generate range-diffs and the like.
So in the end, it saves both their and our time, but the learning
opportunity is of course a bit diminished. I'd gladly accept that
tradeoff though.
Thanks for your input!
Patrick
^ permalink raw reply
* Re: [PATCH 1/2] b4: introduce configuration for the Git project
From: Patrick Steinhardt @ 2026-06-03 6:52 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Ramsay Jones, git
In-Reply-To: <871peopbvf.fsf@gitster.g>
On Wed, Jun 03, 2026 at 11:59:48AM +0900, Junio C Hamano wrote:
> Ramsay Jones <ramsay@ramsayjones.plus.com> writes:
>
> > On 02/06/2026 2:32 pm, Junio C Hamano wrote:
> >> Patrick Steinhardt <ps@pks.im> writes:
> >>
> >>> We're about to extend our documentation to recommend b4 for sending
> >>> patch series ot the mailing list. Prepare for this by introducing a b4
> >>> configuration so that the tool knows to honor our preferences. For now,
> >>> this configuration does two things:
> >>> ...
> >> (hence making the tree dirty).
> >
> > Hmm, for those of us not in the know, perhaps mention the b4 documentation
> > at 'b4.docs.kernel.org' (which includes how to install b4 ... ;) ).
>
> Thanks for raising an excellent point.
I already refer to the docs in the second commit. Let me maybe reorder
them so that we first show how it's used before tweaking it.
Patrick
^ permalink raw reply
* [PATCH v2 4/4] t: let prove fail when parsing invalid TAP output
From: Patrick Steinhardt @ 2026-06-03 5:39 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano
In-Reply-To: <20260603-pks-t7527-fix-tap-output-v2-0-cf3af5694e20@pks.im>
To make the result of our tests accessible we use the TAP protocol. This
protocol is parsed by either prove or by Meson. Unfortunately, these two
tools differ when it comes to their strictness when parsing the
protocol:
- Prove by default happily accepts lines not specified by the
protocol.
- Meson will also accept such lines, but prints a big and ugly warning
message.
We have fixed our test suite in the past to not print invalid TAP lines
anymore via b1dc2e796e (Merge branch 'ps/meson-tap-parse', 2025-06-17).
But as none of our tools perform a strict check it's still possible for
broken tests to sneak back in, like for example in 362f69547f (Merge
branch 'ps/t1006-tap-fix', 2025-07-16). This doesn't hurt at all when
using prove, but it's quite annoying when using Meson due to the
generated warnings.
Unfortunately, there doesn't seem to be a portable way to make all tools
complain about violations of the TAP format. The TAP 14 specification
has added pragmas to the protocol that would allow us to say `pragma
+strict`, and the effect of that would be to treat invalid TAP lines as
a test failure. But the release of TAP 14 is still rather recent, and
Test-Harness for example only gained support for it in version 3.48,
which was released in 2023.
In fact though, this pragma was already introduced as an inofficial
extension of the TAP protocol with Test-Harness 3.10, released in 2008.
So while not all tools understand the pragma, at least prove does for a
long time.
Unconditionally enable the pragma when using prove so that we'll detect
tests that emit broken TAP output right away. This would have detected
the issues fixed in preceding commits:
$ prove t7527-builtin-fsmonitor.sh
t7527-builtin-fsmonitor.sh .. All 69 subtests passed
(less 6 skipped subtests: 63 okay)
Test Summary Report
-------------------
t7527-builtin-fsmonitor.sh (Wstat: 0 Tests: 69 Failed: 0)
Parse errors: Unknown TAP token: "Initialized empty Git repository in /tmp/git/test_fsmonitor_smoke/.git/"
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
t/test-lib.sh | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/t/test-lib.sh b/t/test-lib.sh
index d1d24c4124..ceefb99bff 100644
--- a/t/test-lib.sh
+++ b/t/test-lib.sh
@@ -1532,6 +1532,12 @@ then
BAIL_OUT 'You need to build test-tool; Run "make t/helper/test-tool" in the source (toplevel) directory'
fi
+if test -n "$HARNESS_ACTIVE"
+then
+ say "TAP version 13"
+ say "pragma +strict"
+fi
+
# Are we running this test at all?
remove_trash=
this_test=${0##*/}
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 3/4] t/lib-git-p4: silence output when killing p4d and its watchdog
From: Patrick Steinhardt @ 2026-06-03 5:39 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano
In-Reply-To: <20260603-pks-t7527-fix-tap-output-v2-0-cf3af5694e20@pks.im>
When stopping the p4d watchdog process via "kill -9", the shell may
print a job-control notification like:
./test-lib.sh: line 1269: 57960 Killed: 9 while true; do
if test $nr_tries_left -eq 0; then
kill -9 $p4d_pid; exit 1;
fi; sleep 1; nr_tries_left=$(($nr_tries_left - 1));
done 2> /dev/null 4>&2 (wd: ~)
This message is printed asynchronously by the shell when it reaps the
process. While harmless right now, this will cause breakage once we
enable strict parsing of the TAP protocol in a subsequent commit.
Fix this by using `wait` so that we can synchronously reap the watchdog
process and swallow the diagnostic.
While at it, deduplicate the logic we have in `stop_p4d_and_watchdog ()`
and `stop_and_cleanup_p4d ()`.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
t/lib-git-p4.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/t/lib-git-p4.sh b/t/lib-git-p4.sh
index d22e9c684a..9108868187 100644
--- a/t/lib-git-p4.sh
+++ b/t/lib-git-p4.sh
@@ -65,6 +65,7 @@ pidfile="$TRASH_DIRECTORY/p4d.pid"
stop_p4d_and_watchdog () {
kill -9 $p4d_pid $watchdog_pid
+ wait $p4d_pid $watchdog_pid 2>/dev/null
}
# git p4 submit generates a temp file, which will
@@ -174,8 +175,7 @@ retry_until_success () {
}
stop_and_cleanup_p4d () {
- kill -9 $p4d_pid $watchdog_pid
- wait $p4d_pid
+ stop_p4d_and_watchdog
rm -rf "$db" "$cli" "$pidfile"
}
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 2/4] t/test-lib: silence EBUSY errors on Windows during test cleanup
From: Patrick Steinhardt @ 2026-06-03 5:39 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano
In-Reply-To: <20260603-pks-t7527-fix-tap-output-v2-0-cf3af5694e20@pks.im>
When tests have finished we clean up the trash directory via `rm -rf`.
On Windows this can fail with EBUSY in cases where a process still holds
some of the files open, for example when we have spawned a daemonized
process that wasn't properly terminated. We thus retry several times,
but every failure will result in error messages being printed, and that
in turn breaks the TAP output format.
One such case where this is causing issues is in t921x, which contains
tests related to Scalar. Some tests spawn the fsmonitor daemon, and we
never properly terminate it.
The obvious fix would be to ensure that we never leak any processes, but
that gets ugly fast. Instead, let's work around the issue by silencing
error messages printed by the `rm -rf` calls. We already know to print
an error when the retry loop fails, so we don't loose much.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
t/test-lib.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/t/test-lib.sh b/t/test-lib.sh
index 4a7357b547..d1d24c4124 100644
--- a/t/test-lib.sh
+++ b/t/test-lib.sh
@@ -1299,10 +1299,10 @@ test_done () {
error "Tests passed but trash directory already removed before test cleanup; aborting"
cd "$TRASH_DIRECTORY/.." &&
- rm -fr "$TRASH_DIRECTORY" || {
+ rm -fr "$TRASH_DIRECTORY" 2>/dev/null || {
# try again in a bit
sleep 5;
- rm -fr "$TRASH_DIRECTORY"
+ rm -fr "$TRASH_DIRECTORY" 2>/dev/null
} ||
error "Tests passed but test cleanup failed; aborting"
fi
--
2.54.0.1064.gd145956f57.dirty
^ permalink raw reply related
* [PATCH v2 1/4] t7527: fix broken TAP output
From: Patrick Steinhardt @ 2026-06-03 5:39 UTC (permalink / raw)
To: git; +Cc: Junio C Hamano
In-Reply-To: <20260603-pks-t7527-fix-tap-output-v2-0-cf3af5694e20@pks.im>
Before running the tests in t7527 we first verify whether the fsmonitor
even works, which seems to depend on the actual filesystem that is in
use. The verification executes outside of any prerequisite or test body,
so its stdout/stderr is not being redirected.
The consequence of this is that any command that prints to stdout/stderr
may break the TAP specification by printing invalid lines. And in fact
we already do that, as git-init(1) prints the path to the created Git
repository by default.
Fix this issue by moving the logic into a lazy prerequisite.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
t/t7527-builtin-fsmonitor.sh | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh
index b63c162f9b..d881e27466 100755
--- a/t/t7527-builtin-fsmonitor.sh
+++ b/t/t7527-builtin-fsmonitor.sh
@@ -25,7 +25,8 @@ maybe_timeout () {
"$@"
fi
}
-verify_fsmonitor_works () {
+
+test_lazy_prereq FSMONITOR_WORKS '
git init test_fsmonitor_smoke || return 1
GIT_TRACE_FSMONITOR="$PWD/smoke.trace" &&
@@ -50,9 +51,9 @@ verify_fsmonitor_works () {
ret=$?
rm -rf test_fsmonitor_smoke smoke.trace
return $ret
-}
+'
-if ! verify_fsmonitor_works
+if ! test_have_prereq FSMONITOR_WORKS
then
skip_all="filesystem does not deliver fsmonitor events (container/overlayfs?)"
test_done
--
2.54.0.1064.gd145956f57.dirty
^ 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