* Re: [PATCH v3] git-gui: silence install recipes under "make -s"
From: Johannes Sixt @ 2026-06-06 9:38 UTC (permalink / raw)
To: Harald Nordgren; +Cc: git, Harald Nordgren via GitGitGadget
In-Reply-To: <pull.2318.v3.git.git.1780555730228.gitgitgadget@gmail.com>
Am 04.06.26 um 08:48 schrieb Harald Nordgren via GitGitGadget:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Several install and uninstall recipes embed "echo" calls that fire as
> part of the recipe itself, so the install banners (DEST, INSTALL,
> LINK, REMOVE) were visible whenever the variables expand non-empty.
>
> Guard the whole "ifndef V" block on "-s" so the loud variants are
> selected only when "-s" is absent and V=1 is unset. The existing
> "-s" check also had its findstring arguments in the wrong order
> (needle "-s" never fit in haystack "s"), so swap them while moving
> the check to wrap the block.
>
> Signed-off-by: Harald Nordgren <harald.nordgren@kostdoktorn.se>
The new text looks good. However, the email addresses of author and
signer-off are different. They should be the same. I notice that you use
the gmail address in both places in other patch submissions, so I can
use that if you agree (and you don't need to send another round).
-- Hannes
^ permalink raw reply
* Re: [PATCH v3] index-pack: retain child bases in delta cache
From: Arijit Banerjee @ 2026-06-05 21:18 UTC (permalink / raw)
To: Jeff King
Cc: Arijit Banerjee via GitGitGadget, git,
Ævar Arnfjörð Bjarmason, Junio C Hamano,
Derrick Stolee, Arijit Banerjee
In-Reply-To: <20260604071204.GA3196596@coredump.intra.peff.net>
Apologies, my earlier replies were sent through GitHub's notification
emails and appeared only as PR comments, so they did not reach the mailing
list.
On Thu, Jun 4, 2026, Jeff King wrote:
> So I am happy with either v2 or v3.
I also did not see a meaningful performance difference between v2 and v3.
I am happy with either direction and defer to the maintainers on whether
v3's more precise release is worth the added complexity.
On Wed, Jun 3, 2026, Derrick Stolee wrote:
> Did you see any evidence that this change has the intended effect of
> reducing process memory proactively instead of relying on cache evictions?
I do not have strong RSS evidence. The spot checks showed no meaningful RSS
change, and max RSS is not a good signal here because free_base_data()
lowers Git's internal base_cache_used accounting but may not return pages
to the OS or reduce the recorded peak.
The evidence for v3 is therefore structural: it releases the cached data
once all direct children have been dispatched and retain_data reaches zero,
rather than waiting for cache-pressure eviction.
Thanks,
Arijit
^ permalink raw reply
* Re: [PATCH v2] prio-queue: use cascade-down for faster extract-min
From: Kristofer Karlsson @ 2026-06-05 20:39 UTC (permalink / raw)
To: René Scharfe; +Cc: Kristofer Karlsson via GitGitGadget, git
In-Reply-To: <CAL71e4Ob-B5MJ5DPY+_tzpj6nyrbQ5WutxED2T93SWJV6kJGPA@mail.gmail.com>
I did some more benchmarking to understand how these approaches
interact, with four variants based on origin/next on my large monorepo:
1. base: next as-is
2. cascade: base + sift_up_rebalance from this patch (v2)
3. lazy-fold: base + lazy get fusion folded into prio_queue
4. cascade+lazy: both combined
Note that alt 3 is not yet shared with the mailing list so it's hard for you
to reason about it, though it's quite straightforward. I will submit a new
patch for that one soon, not necessarily with the primary goal to merge it,
but rather show how it is implemented.
merge-base --all master master~1000:
base 4.27s
cascade 4.07s (1.05x)
lazy-fold 4.12s (1.03x)
cascade+lazy 4.01s (1.06x)
rev-list --count master~1000..master:
base 3.60s
cascade 3.35s (1.08x)
lazy-fold 3.37s (1.07x)
cascade+lazy 3.30s (1.09x)
So both optimizations are valuable both on their own, and when combined,
which I think helps to reason about it. This cascading sift seems to have a
larger effect, but folding lazy_queue into prio_queue also speeds up other
use cases and simplifies the code a bit.
Based on this, my (very subjective) approach would be:
1. Land this cascade patch first since it's a pure algorithmic improvement,
2. Follow up with a separate patch that folds lazy_queue into
prio_queue. Will post it separately soon, as I mentioned.
- Kristofer
^ permalink raw reply
* Re: [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees
From: brian m. carlson @ 2026-06-05 19:59 UTC (permalink / raw)
To: Jason Newton via GitGitGadget; +Cc: git, Jason Newton
In-Reply-To: <pull.2317.git.git.1780685368.gitgitgadget@gmail.com>
[-- Attachment #1: Type: text/plain, Size: 2933 bytes --]
On 2026-06-05 at 18:49:26, Jason Newton via GitGitGadget wrote:
> When many worktrees share one repository -- e .g. a fleet of agents each
> needing an isolated checkout -- "git worktree add" is costly at scale.
> Objects are shared via the common dir, but the working tree is not: each add
> rewrites every tracked file, so N worktrees cost N full checkouts of disk
> and I/O. And a branch can only be checked out in one worktree.
>
> Patch 1 adds "git worktree add --reflink": on a copy-on-write filesystem it
> populates the new worktree by reflinking the current worktree's files and
> index, then "git reset --hard" rewrites only the paths that differ from . A
> reflink_file() helper in copy.c uses FICLONE (Linux) and clonefile()
> (macOS); elsewhere (other filesystems, Windows) it is probed up front and
> falls back to a normal checkout. Defaulting is via the worktree.reflink
> config (true/false/auto); --no-reflink overrides.
Windows apparently has CoW functionality if you use ReFS. I believe Git
LFS has code to do this and you may be interested in checking it out.
Also, how does this work if worktree A is dirty (but `git update-index`
and `git status` have not been run) when the reflink occurs? Does B
have stale files from the working tree? If not, how do we plan on
detecting that? (While I'm curious, this should also be explained in
your commit message because we want to know that you have thought about
this problem and have a good answer for it.)
I was curious as to how this would work with containers, which typically
use overlayfs, but some searching reveals that overlayfs does indeed
support reflinks. Thanks for the opportunity to learn something new
today.
> Patch 2 lets a branch be checked out in several worktrees, for parallel work
> on one checkout. A branch mid-rebase or mid-bisect elsewhere is still
> refused.
So how does this work if you have two worktrees for the same branch, A
and B, and A commits, and then B does? What we don't want to happen is
that because B's worktree is not up to date, it effectively reverts the
changes that A made when adding objects to the index to commit. (Again,
this is a good thing to explain in your commit message, since reviewers
will be curious.)
My personal approach, if I needed many worktrees of the same commit,
would be to create many refs pointing to the same object ID and check
those out. `git update-ref` can perform a single ref transaction with
many refs, which is especially efficient with reftable. That would
avoid the need for multiple checkout support, although I could still see
the utility of reflinking if it can be done safely. If that's a
solution that you think would be valuable, you could propose it as a FAQ
entry or an edit to the manual page, since I'm sure there are other
people with your use case.
--
brian m. carlson (they/them)
Toronto, Ontario, CA
[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 325 bytes --]
^ permalink raw reply
* [PATCH 2/2] worktree: allow sharing a checked-out branch across worktrees
From: Jason Newton via GitGitGadget @ 2026-06-05 18:49 UTC (permalink / raw)
To: git; +Cc: Jason Newton, Jason Newton
In-Reply-To: <pull.2317.git.git.1780685368.gitgitgadget@gmail.com>
From: Jason Newton <nevion@gmail.com>
When spinning up several worktrees on the same checkout for parallel
work (for example a fleet of agents working from one branch), git's
refusal to check out a branch that is already checked out elsewhere is
just in the way. The restriction exists to stop two worktrees from
moving the same branch underneath each other, but plain parallel
checkouts do not need that protection.
Drop the restriction: "git worktree add <branch>" now checks out a
branch even if it is in use by another worktree. The genuinely
dangerous case is kept -- a branch that another worktree is in the
middle of rebasing or bisecting is still refused, because a second
checkout could corrupt that operation. die_if_branch_busy() performs
that narrower check in place of the old die_if_checked_out(). The
separate guard against force-updating (e.g. with -B) a branch in use
elsewhere is left untouched.
Signed-off-by: Jason Newton <nevion@gmail.com>
---
Documentation/git-worktree.adoc | 17 +++++++++--------
builtin/worktree.c | 30 +++++++++++++++++++++++++++++-
t/t2400-worktree-add.sh | 31 ++++++++++++++++++++++++-------
3 files changed, 62 insertions(+), 16 deletions(-)
diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
index 1ca81718b7..cc4c91b787 100644
--- a/Documentation/git-worktree.adoc
+++ b/Documentation/git-worktree.adoc
@@ -93,8 +93,9 @@ then, as a convenience, the new worktree is associated with a branch (call
it _<branch>_) named after `$(basename <path>)`. If _<branch>_ doesn't
exist, a new branch based on `HEAD` is automatically created as if
`-b <branch>` was given. If _<branch>_ does exist, it will be checked out
-in the new worktree, if it's not checked out anywhere else, otherwise the
-command will refuse to create the worktree (unless `--force` is used).
+in the new worktree, even if it is already checked out in another worktree.
+(A branch that another worktree is in the middle of rebasing or bisecting is
+refused unless `--force` is used.)
+
If _<commit-ish>_ is omitted, neither `--detach`, or `--orphan` is
used, and there are no valid local branches (or remote branches if
@@ -177,12 +178,12 @@ OPTIONS
`-f`::
`--force`::
- By default, `add` refuses to create a new worktree when
- _<commit-ish>_ is a branch name and is already checked out by
- another worktree, or if _<path>_ is already assigned to some
- worktree but is missing (for instance, if _<path>_ was deleted
- manually). This option overrides these safeguards. To add a missing but
- locked worktree path, specify `--force` twice.
+ `add` refuses to create a new worktree when _<commit-ish>_ is a
+ branch that another worktree is in the middle of rebasing or
+ bisecting, or if _<path>_ is already assigned to some worktree but
+ is missing (for instance, if _<path>_ was deleted manually). This
+ option overrides these safeguards. To add a missing but locked
+ worktree path, specify `--force` twice.
+
`move` refuses to move a locked worktree unless `--force` is specified
twice. If the destination is already assigned to some other worktree but is
diff --git a/builtin/worktree.c b/builtin/worktree.c
index 973da33051..b457b015d1 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -648,6 +648,34 @@ static void setup_alternate_ref_dir(struct worktree *wt, const char *wt_git_path
strbuf_release(&sb);
}
+/*
+ * Checking out a branch that is already checked out in another worktree is
+ * fine -- it is exactly what you want when spinning up several worktrees on
+ * the same checkout for parallel work. The one case that is still unsafe is a
+ * branch that another worktree is in the middle of rebasing or bisecting,
+ * since a second checkout could corrupt that operation, so refuse only that.
+ */
+static void die_if_branch_busy(const char *branch)
+{
+ struct worktree **worktrees = get_worktrees();
+ int i;
+
+ for (i = 0; worktrees[i]; i++) {
+ const struct worktree *wt = worktrees[i];
+
+ if (is_worktree_being_rebased(wt, branch) ||
+ is_worktree_being_bisected(wt, branch)) {
+ const char *shortname = branch;
+
+ skip_prefix(branch, "refs/heads/", &shortname);
+ die(_("'%s' is already used by worktree at '%s'"),
+ shortname, wt->path);
+ }
+ }
+
+ free_worktrees(worktrees);
+}
+
static int add_worktree(const char *path, const char *refname,
const struct add_opts *opts)
{
@@ -675,7 +703,7 @@ static int add_worktree(const char *path, const char *refname,
refs_ref_exists(get_main_ref_store(the_repository), symref.buf)) {
is_branch = 1;
if (!opts->force)
- die_if_checked_out(symref.buf, 0);
+ die_if_branch_busy(symref.buf);
}
commit = lookup_commit_reference_by_name(refname);
if (!commit && !opts->orphan)
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 56fb79179a..6a1eb72ac7 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -40,10 +40,24 @@ test_expect_success '"add" using - shorthand' '
test_cmp expect actual
'
-test_expect_success '"add" refuses to checkout locked branch' '
- test_must_fail git worktree add zere main &&
- test_path_is_missing zere &&
- test_path_is_missing .git/worktrees/zere
+test_expect_success '"add" can check out a branch in use by another worktree' '
+ test_when_finished "git worktree remove -f zere || :" &&
+ git worktree add zere main &&
+ echo refs/heads/main >expect &&
+ git -C zere symbolic-ref HEAD >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success 'the same branch can be checked out in several worktrees' '
+ test_when_finished "git worktree remove -f shared1 || :; git worktree remove -f shared2 || :" &&
+ git branch -f sharedbr main &&
+ git worktree add shared1 sharedbr &&
+ git worktree add shared2 sharedbr &&
+ echo refs/heads/sharedbr >expect &&
+ git -C shared1 symbolic-ref HEAD >actual &&
+ test_cmp expect actual &&
+ git -C shared2 symbolic-ref HEAD >actual &&
+ test_cmp expect actual
'
test_expect_success 'checking out paths not complaining about linked checkouts' '
@@ -304,10 +318,13 @@ test_expect_success '"add" checks out existing branch of dwimd name' '
)
'
-test_expect_success '"add <path>" dwim fails with checked out branch' '
+test_expect_success '"add <path>" dwim shares a checked out branch' '
git checkout -b test-branch &&
- test_must_fail git worktree add test-branch &&
- test_path_is_missing test-branch
+ git worktree add test-branch &&
+ echo refs/heads/test-branch >expect &&
+ git -C test-branch symbolic-ref HEAD >actual &&
+ test_cmp expect actual &&
+ git worktree remove test-branch
'
test_expect_success '"add --force" with existing dwimd name doesnt die' '
--
gitgitgadget
^ permalink raw reply related
* [PATCH 1/2] worktree: add --reflink for copy-on-write worktree creation
From: Jason Newton via GitGitGadget @ 2026-06-05 18:49 UTC (permalink / raw)
To: git; +Cc: Jason Newton, Jason Newton
In-Reply-To: <pull.2317.git.git.1780685368.gitgitgadget@gmail.com>
From: Jason Newton <nevion@gmail.com>
Creating many worktrees from the same base -- for example to run a
fleet of automated agents in parallel -- is expensive today: every
"git worktree add" materializes the entire working tree by writing
each tracked file out from the object store. The objects are shared
via the common directory, but the working tree is not: N worktrees
mean N full checkouts on disk and N times the file I/O.
Add a "--reflink" option that, on copy-on-write filesystems, populates
the new worktree by reflinking the current worktree's files and index
instead. The subsequent "git reset --hard" then only rewrites the
paths that actually differ between the current worktree and
<commit-ish>; everything else (including untracked files such as build
outputs) keeps sharing storage with the source until modified. Because
the cloned index still carries the source files' stat data, it is
refreshed against the reflinked files first so that reset recognizes
the unchanged paths as up to date and leaves them sharing extents
rather than rewriting them.
The clones are made by a new reflink_file() helper in copy.c, which
uses the FICLONE ioctl on Linux and clonefile() on macOS and reports
an error otherwise so callers fall back to a normal copy. Support is
probed up front; when unavailable -- including on filesystems without
copy-on-write and on platforms such as Windows that lack a reflink
primitive -- "--reflink" transparently falls back to an ordinary
checkout, so the worst case is no slower than today rather than a
byte-for-byte copy of the source tree. The directory walk skips the
new worktree itself when it lives inside the source one, and preserves
symlinks and modes.
The behavior can be made the default with the worktree.reflink
configuration ("true", "false" or "auto", the last suppressing the
unsupported-filesystem warning), and turned off per-invocation with
--no-reflink. A configured default degrades quietly in modes that
cannot reflink (--orphan, --no-checkout) instead of erroring, so
enabling it never breaks those commands. The checkout step continues
to honor checkout.workers, so parallel checkout composes with
--reflink for the paths that do need rewriting.
Signed-off-by: Jason Newton <nevion@gmail.com>
---
Documentation/config/worktree.adoc | 10 ++
Documentation/git-worktree.adoc | 30 +++-
builtin/worktree.c | 227 ++++++++++++++++++++++++++++-
copy.c | 65 +++++++++
copy.h | 13 ++
t/t2400-worktree-add.sh | 88 +++++++++++
6 files changed, 431 insertions(+), 2 deletions(-)
diff --git a/Documentation/config/worktree.adoc b/Documentation/config/worktree.adoc
index a248076ea5..d3a03c86d4 100644
--- a/Documentation/config/worktree.adoc
+++ b/Documentation/config/worktree.adoc
@@ -17,3 +17,13 @@
Note that setting `worktree.useRelativePaths` to "`true`" implies enabling the
`extensions.relativeWorktrees` config (see linkgit:git-config[1]),
thus making it incompatible with older versions of Git.
+
+`worktree.reflink`::
+ Controls whether `git worktree add` populates new worktrees with
+ copy-on-write (reflink) clones, as if `--reflink` had been given
+ (see linkgit:git-worktree[1]). May be set to "`true`", "`false`"
+ (the default), or "`auto`". With "`true`", a filesystem that does
+ not support reflinks produces a warning before falling back to an
+ ordinary checkout; with "`auto`", the fallback is silent. An
+ explicit `--reflink` or `--no-reflink` on the command line
+ overrides this setting.
diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
index fbf8426cd9..1ca81718b7 100644
--- a/Documentation/git-worktree.adoc
+++ b/Documentation/git-worktree.adoc
@@ -10,7 +10,7 @@ SYNOPSIS
--------
[synopsis]
git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
- [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
+ [--orphan] [--reflink] [(-b | -B) <new-branch>] <path> [<commit-ish>]
git worktree list [-v | --porcelain [-z]]
git worktree lock [--reason <string>] <worktree>
git worktree move <worktree> <new-path>
@@ -213,6 +213,34 @@ To remove a locked worktree, specify `--force` twice.
such as configuring sparse-checkout. See "Sparse checkout"
in linkgit:git-read-tree[1].
+`--reflink`::
+`--no-reflink`::
+ Populate the new worktree by creating copy-on-write (reflink)
+ clones of the current worktree's files and index instead of
+ writing every file out from the object store. The checkout that
+ follows then only has to rewrite the paths that actually differ
+ between the current worktree and _<commit-ish>_; everything else
+ (including untracked files such as build outputs) keeps sharing
+ storage with the source worktree until modified. This makes
+ `add` much faster and far cheaper on disk when creating many
+ worktrees from the same base. `--no-reflink` forces an ordinary
+ checkout, overriding the `worktree.reflink` configuration.
++
+Reflinks require a copy-on-write filesystem (for example btrfs, XFS,
+bcachefs or ZFS on Linux, or APFS on macOS). On filesystems or platforms
+that do not support reflinks, `--reflink` transparently falls back to an
+ordinary checkout. Because the source worktree's untracked and ignored
+files are cloned as well, only use `--reflink` when that is acceptable.
++
+This option cannot be combined with `--no-checkout` or `--orphan`. It can
+be enabled by default with the `worktree.reflink` configuration; see
+linkgit:git-config[1].
++
+The checkout that populates a new worktree also honors the
+`checkout.workers` configuration (see linkgit:git-config[1]), so setting it
+parallelizes the file writes and can further speed up `add`, with or
+without `--reflink`.
+
`--guess-remote`::
`--no-guess-remote`::
With `worktree add <path>`, without _<commit-ish>_, instead
diff --git a/builtin/worktree.c b/builtin/worktree.c
index d21c43fde3..973da33051 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -30,7 +30,7 @@
#define BUILTIN_WORKTREE_ADD_USAGE \
N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]\n" \
- " [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]")
+ " [--orphan] [--reflink] [(-b | -B) <new-branch>] <path> [<commit-ish>]")
#define BUILTIN_WORKTREE_LIST_USAGE \
N_("git worktree list [-v | --porcelain [-z]]")
@@ -47,6 +47,11 @@
#define BUILTIN_WORKTREE_UNLOCK_USAGE \
N_("git worktree unlock <worktree>")
+/* values for add_opts.reflink and the worktree.reflink config */
+#define REFLINK_OFF 0
+#define REFLINK_ON 1 /* warn and fall back if unsupported */
+#define REFLINK_AUTO 2 /* silently fall back if unsupported */
+
#define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
_("No possible source branch, inferring '--orphan'")
@@ -123,6 +128,7 @@ struct add_opts {
int checkout;
int orphan;
int relative_paths;
+ int reflink;
const char *keep_locked;
};
@@ -130,6 +136,7 @@ static int show_only;
static int verbose;
static int guess_remote;
static int use_relative_paths;
+static int reflink_config = REFLINK_OFF;
static timestamp_t expire;
static int git_worktree_config(const char *var, const char *value,
@@ -141,6 +148,13 @@ static int git_worktree_config(const char *var, const char *value,
} else if (!strcmp(var, "worktree.userelativepaths")) {
use_relative_paths = git_config_bool(var, value);
return 0;
+ } else if (!strcmp(var, "worktree.reflink")) {
+ if (value && !strcmp(value, "auto"))
+ reflink_config = REFLINK_AUTO;
+ else
+ reflink_config = git_config_bool(var, value) ?
+ REFLINK_ON : REFLINK_OFF;
+ return 0;
}
return git_default_config(var, value, ctx, cb);
@@ -397,6 +411,182 @@ worktree_copy_cleanup:
free(to_file);
}
+/*
+ * Probe whether the filesystem backing "dir" supports reflinks. We do this
+ * once up front so that, on filesystems without copy-on-write support (or on
+ * platforms such as Windows that lack a reflink primitive entirely), we can
+ * fall back to a normal checkout instead of byte-copying the whole source
+ * working tree -- which would include untracked files and be slower than the
+ * checkout we are trying to avoid.
+ */
+static int reflink_supported(const char *dir)
+{
+ struct strbuf src = STRBUF_INIT, dst = STRBUF_INIT;
+ int fd, ok = 0;
+
+ strbuf_addf(&src, "%s/.git-reflink-probe-src", dir);
+ strbuf_addf(&dst, "%s/.git-reflink-probe-dst", dir);
+
+ fd = open(src.buf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
+ if (fd >= 0) {
+ write_in_full(fd, "x", 1);
+ close(fd);
+ if (!reflink_file(dst.buf, src.buf, 0600))
+ ok = 1;
+ unlink(dst.buf);
+ }
+ unlink(src.buf);
+
+ strbuf_release(&src);
+ strbuf_release(&dst);
+ return ok;
+}
+
+/*
+ * Reflink a single regular file, falling back to a regular copy when the
+ * clone fails for this particular file (for example across mount points).
+ */
+static int reflink_or_copy(const char *dst, const char *src, int mode)
+{
+ if (!reflink_file(dst, src, mode))
+ return 0;
+ return copy_file(dst, src, mode);
+}
+
+/*
+ * Recursively copy the working-tree directory "src" into "dst" using reflinks
+ * for regular files. Directory entries that resolve to the destination
+ * worktree itself (identified by skip_dev/skip_ino, which matters when the new
+ * worktree lives inside the source one) and the top-level ".git" gitfile are
+ * skipped.
+ */
+static int reflink_tree(const char *src, const char *dst,
+ dev_t skip_dev, ino_t skip_ino, int top)
+{
+ struct strbuf s = STRBUF_INIT, d = STRBUF_INIT;
+ DIR *dir;
+ struct dirent *de;
+ int ret = 0;
+
+ dir = opendir(src);
+ if (!dir)
+ return error_errno(_("could not open directory '%s'"), src);
+
+ while (!ret && (de = readdir(dir))) {
+ struct stat st;
+
+ if (is_dot_or_dotdot(de->d_name))
+ continue;
+ if (top && !strcmp(de->d_name, ".git"))
+ continue;
+
+ strbuf_reset(&s);
+ strbuf_addf(&s, "%s/%s", src, de->d_name);
+ strbuf_reset(&d);
+ strbuf_addf(&d, "%s/%s", dst, de->d_name);
+
+ if (lstat(s.buf, &st)) {
+ ret = error_errno(_("could not stat '%s'"), s.buf);
+ break;
+ }
+
+ if (S_ISDIR(st.st_mode)) {
+ /* never recurse into the new worktree itself */
+ if (st.st_dev == skip_dev && st.st_ino == skip_ino)
+ continue;
+ if (mkdir(d.buf, st.st_mode & 07777) && errno != EEXIST) {
+ ret = error_errno(_("could not create directory '%s'"), d.buf);
+ break;
+ }
+ ret = reflink_tree(s.buf, d.buf, skip_dev, skip_ino, 0);
+ } else if (S_ISLNK(st.st_mode)) {
+ struct strbuf link = STRBUF_INIT;
+
+ if (strbuf_readlink(&link, s.buf, st.st_size))
+ ret = error_errno(_("could not read symlink '%s'"), s.buf);
+ else if (symlink(link.buf, d.buf))
+ ret = error_errno(_("could not create symlink '%s'"), d.buf);
+ strbuf_release(&link);
+ } else if (S_ISREG(st.st_mode)) {
+ if (reflink_or_copy(d.buf, s.buf, st.st_mode))
+ ret = error_errno(_("could not copy '%s' to '%s'"),
+ s.buf, d.buf);
+ }
+ /* silently skip fifos, sockets and device nodes */
+ }
+
+ closedir(dir);
+ strbuf_release(&s);
+ strbuf_release(&d);
+ return ret;
+}
+
+/*
+ * Populate the new worktree at "path" by reflinking the current worktree's
+ * files and index. The subsequent "git reset --hard" then only has to rewrite
+ * the paths that actually differ between the source and <commit-ish>, leaving
+ * everything else sharing storage with the source. Returns 1 when reflinks are
+ * unavailable so the caller can fall back to a plain checkout.
+ */
+static int reflink_worktree(const char *path, const char *wt_git_dir,
+ const struct add_opts *opts, struct strvec *child_env)
+{
+ const char *src_wt = the_repository->worktree;
+ char *src_index = NULL, *dst_index = NULL;
+ struct stat dst_st;
+ int ret = 0;
+
+ if (!src_wt)
+ return error(_("--reflink needs a source working tree, but this "
+ "repository does not have one"));
+
+ if (!reflink_supported(path)) {
+ /* In auto mode the fallback is expected, so stay quiet. */
+ if (!opts->quiet && opts->reflink != REFLINK_AUTO)
+ warning(_("the filesystem at '%s' does not support reflinks; "
+ "falling back to a regular checkout"), path);
+ return 1;
+ }
+
+ if (stat(path, &dst_st))
+ return error_errno(_("could not stat '%s'"), path);
+
+ if ((ret = reflink_tree(src_wt, path, dst_st.st_dev, dst_st.st_ino, 1)))
+ return ret;
+
+ /*
+ * Clone the source index so the following reset sees the source's
+ * state and only materializes the differences to <commit-ish>.
+ */
+ src_index = repo_git_path(the_repository, "index");
+ dst_index = xstrfmt("%s/index", wt_git_dir);
+ if (!access(src_index, F_OK) &&
+ reflink_or_copy(dst_index, src_index, 0666)) {
+ ret = error_errno(_("could not copy index to '%s'"), dst_index);
+ goto out;
+ }
+
+ /*
+ * The cloned index still carries the source files' stat information.
+ * Refresh it against the freshly reflinked files so that "git reset"
+ * recognizes unchanged paths as up to date and leaves them sharing
+ * storage instead of rewriting (and thus un-sharing) them.
+ */
+ if (!access(dst_index, F_OK)) {
+ struct child_process cp = CHILD_PROCESS_INIT;
+ cp.git_cmd = 1;
+ strvec_pushl(&cp.args, "update-index", "-q", "--refresh", NULL);
+ strvec_pushv(&cp.env, child_env->v);
+ /* a dirty working tree is not an error here */
+ run_command(&cp);
+ }
+
+out:
+ free(src_index);
+ free(dst_index);
+ return ret;
+}
+
static int checkout_worktree(const struct add_opts *opts,
struct strvec *child_env)
{
@@ -589,6 +779,20 @@ static int add_worktree(const char *path, const char *refname,
(ret = make_worktree_orphan(refname, opts, &child_env)))
goto done;
+ /*
+ * When --reflink is requested and the filesystem supports it, copy the
+ * current worktree (and its index) into the new one using copy-on-write
+ * clones. checkout_worktree() then only rewrites the paths that differ
+ * from <commit-ish>. reflink_worktree() returns 1 when reflinks are not
+ * available, in which case we just do an ordinary checkout below.
+ */
+ if (opts->checkout && opts->reflink) {
+ ret = reflink_worktree(path, sb_repo.buf, opts, &child_env);
+ if (ret < 0)
+ goto done;
+ ret = 0;
+ }
+
if (opts->checkout &&
(ret = checkout_worktree(opts, &child_env)))
goto done;
@@ -801,6 +1005,7 @@ static int add(int ac, const char **av, const char *prefix,
const char *lock_reason = NULL;
int keep_locked = 0;
int used_new_branch_options;
+ int reflink_cli = -1;
struct option options[] = {
OPT__FORCE(&opts.force,
N_("checkout <branch> even if already checked out in other worktree"),
@@ -823,6 +1028,8 @@ static int add(int ac, const char **av, const char *prefix,
N_("try to match the new branch name with a remote-tracking branch")),
OPT_BOOL(0, "relative-paths", &opts.relative_paths,
N_("use relative paths for worktrees")),
+ OPT_BOOL(0, "reflink", &reflink_cli,
+ N_("populate the worktree using copy-on-write clones when supported")),
OPT_END()
};
int ret;
@@ -842,6 +1049,24 @@ static int add(int ac, const char **av, const char *prefix,
if (opts.orphan && !opts.checkout)
die(_("options '%s' and '%s' cannot be used together"),
"--orphan", "--no-checkout");
+
+ /*
+ * Resolve whether to reflink: an explicit --reflink/--no-reflink on
+ * the command line wins, otherwise fall back to the worktree.reflink
+ * configuration (which may select the "auto" mode).
+ */
+ opts.reflink = (reflink_cli != -1) ? reflink_cli : reflink_config;
+ if (opts.reflink && (opts.orphan || !opts.checkout)) {
+ /*
+ * Reflinking is incompatible with these; only complain when it
+ * was explicitly requested, otherwise quietly do a plain
+ * checkout so a configured default does not break these modes.
+ */
+ if (reflink_cli == REFLINK_ON)
+ die(_("options '%s' and '%s' cannot be used together"),
+ "--reflink", opts.orphan ? "--orphan" : "--no-checkout");
+ opts.reflink = REFLINK_OFF;
+ }
if (opts.orphan && ac == 2)
die(_("option '%s' and commit-ish cannot be used together"),
"--orphan");
diff --git a/copy.c b/copy.c
index b668209b6c..d2c8ce5209 100644
--- a/copy.c
+++ b/copy.c
@@ -7,6 +7,21 @@
#include "strbuf.h"
#include "abspath.h"
+#if defined(__linux__)
+#include <sys/ioctl.h>
+/*
+ * FICLONE lives in <linux/fs.h>, but including that header tends to clash
+ * with the libc headers git already pulls in, so define it ourselves if it
+ * is missing. The value is part of the stable kernel uapi.
+ */
+#ifndef FICLONE
+#define FICLONE _IOW(0x94, 9, int)
+#endif
+#elif defined(__APPLE__)
+#include <sys/attr.h>
+#include <sys/clonefile.h>
+#endif
+
int copy_fd(int ifd, int ofd)
{
while (1) {
@@ -72,3 +87,53 @@ int copy_file_with_time(const char *dst, const char *src, int mode)
return copy_times(dst, src);
return status;
}
+
+int reflink_file(const char *dst, const char *src, int mode)
+{
+#if defined(__APPLE__)
+ /*
+ * clonefile() refuses to operate when the destination exists and
+ * copies the source's permissions for us, so "mode" is unused here.
+ */
+ (void)mode;
+ if (clonefile(src, dst, 0) < 0)
+ return -1;
+ if (adjust_shared_perm(the_repository, dst))
+ return -1;
+ return 0;
+#elif defined(__linux__)
+ int fdi, fdo, status;
+
+ mode = (mode & 0111) ? 0777 : 0666;
+ if ((fdi = open(src, O_RDONLY)) < 0)
+ return -1;
+ if ((fdo = open(dst, O_WRONLY | O_CREAT | O_EXCL, mode)) < 0) {
+ int saved = errno;
+ close(fdi);
+ errno = saved;
+ return -1;
+ }
+ status = ioctl(fdo, FICLONE, fdi);
+ close(fdi);
+ if (status < 0) {
+ int saved = errno;
+ close(fdo);
+ /* we created an empty file above; do not leave it behind */
+ unlink(dst);
+ errno = saved;
+ return -1;
+ }
+ if (close(fdo) != 0)
+ return -1;
+ if (adjust_shared_perm(the_repository, dst))
+ return -1;
+ return 0;
+#else
+ /* No reflink support on this platform (e.g. Windows). */
+ (void)dst;
+ (void)src;
+ (void)mode;
+ errno = ENOSYS;
+ return -1;
+#endif
+}
diff --git a/copy.h b/copy.h
index 2af77cba86..a8646f7ff5 100644
--- a/copy.h
+++ b/copy.h
@@ -7,4 +7,17 @@ int copy_fd(int ifd, int ofd);
int copy_file(const char *dst, const char *src, int mode);
int copy_file_with_time(const char *dst, const char *src, int mode);
+/*
+ * Create "dst" as a copy-on-write (reflink) clone of the regular file
+ * "src", so that the two files share their data blocks until one of
+ * them is modified. "dst" must not already exist.
+ *
+ * This only succeeds on filesystems that support block cloning (e.g.
+ * btrfs, XFS or bcachefs on Linux, APFS on macOS). When the platform or
+ * filesystem does not support reflinks, -1 is returned with errno set to
+ * ENOSYS (or the underlying error). Callers are expected to fall back to
+ * copy_file() in that case. Returns 0 on success.
+ */
+int reflink_file(const char *dst, const char *src, int mode);
+
#endif /* COPY_H */
diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh
index 58b4445cc4..56fb79179a 100755
--- a/t/t2400-worktree-add.sh
+++ b/t/t2400-worktree-add.sh
@@ -1248,4 +1248,92 @@ test_expect_success 'relative worktree sets extension config' '
test_cmp_config -C repo true extensions.relativeworktrees
'
+test_expect_success '--reflink produces a correct checkout on any filesystem' '
+ test_when_finished "rm -rf reflinkrepo" &&
+ git init reflinkrepo &&
+ (
+ cd reflinkrepo &&
+ test_commit base file.txt base &&
+ test_commit next file.txt next &&
+ git worktree add --reflink wt -b reflinkbr base &&
+ echo base >expect &&
+ test_cmp expect wt/file.txt &&
+ git -C wt status --porcelain >status &&
+ test_must_be_empty status
+ )
+'
+
+test_expect_success '--reflink rejects incompatible options' '
+ test_must_fail git worktree add --reflink --no-checkout wt-bad HEAD 2>err &&
+ test_grep "cannot be used together" err &&
+ test_must_fail git worktree add --reflink --orphan wt-bad2 2>err &&
+ test_grep "cannot be used together" err
+'
+
+test_expect_success 'worktree.reflink config drives a correct checkout' '
+ test_when_finished "rm -rf reflinkcfg" &&
+ git init reflinkcfg &&
+ (
+ cd reflinkcfg &&
+ test_commit cfg file.txt value &&
+ git -c worktree.reflink=true worktree add wt HEAD &&
+ echo value >expect &&
+ test_cmp expect wt/file.txt
+ )
+'
+
+test_expect_success 'worktree.reflink=auto does not break --orphan' '
+ test_when_finished "rm -rf reflinkorphan" &&
+ git init reflinkorphan &&
+ (
+ cd reflinkorphan &&
+ test_commit base file.txt value &&
+ git -c worktree.reflink=auto worktree add --orphan wt &&
+ git -C wt symbolic-ref --short HEAD >actual &&
+ echo wt >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--no-reflink overrides worktree.reflink' '
+ test_when_finished "rm -rf reflinkoff" &&
+ git init reflinkoff &&
+ (
+ cd reflinkoff &&
+ test_commit base file.txt value &&
+ git -c worktree.reflink=true worktree add --no-reflink wt HEAD &&
+ echo value >expect &&
+ test_cmp expect wt/file.txt
+ )
+'
+
+# Reflinks are only available on copy-on-write filesystems (btrfs, XFS,
+# bcachefs, APFS, ...). Where they are unavailable, --reflink transparently
+# falls back to a regular checkout, which the test above already covers.
+test_lazy_prereq REFLINK '
+ echo probe >reflink-src &&
+ cp --reflink=always reflink-src reflink-dst 2>/dev/null
+'
+
+test_expect_success REFLINK '--reflink carries untracked files and reconciles changes' '
+ test_when_finished "rm -rf cowrepo" &&
+ git init cowrepo &&
+ (
+ cd cowrepo &&
+ test_commit same same.txt content &&
+ test_commit old change.txt old-value &&
+ test_commit new change.txt new-value &&
+ echo artifact >untracked.bin &&
+ git worktree add --reflink wt -b cowbr old &&
+ echo content >expect &&
+ test_cmp expect wt/same.txt &&
+ echo old-value >expect &&
+ test_cmp expect wt/change.txt &&
+ echo artifact >expect &&
+ test_cmp expect wt/untracked.bin &&
+ git -C wt status --porcelain >status &&
+ grep "?? untracked.bin" status
+ )
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees
From: Jason Newton via GitGitGadget @ 2026-06-05 18:49 UTC (permalink / raw)
To: git; +Cc: Jason Newton
When many worktrees share one repository -- e .g. a fleet of agents each
needing an isolated checkout -- "git worktree add" is costly at scale.
Objects are shared via the common dir, but the working tree is not: each add
rewrites every tracked file, so N worktrees cost N full checkouts of disk
and I/O. And a branch can only be checked out in one worktree.
Patch 1 adds "git worktree add --reflink": on a copy-on-write filesystem it
populates the new worktree by reflinking the current worktree's files and
index, then "git reset --hard" rewrites only the paths that differ from . A
reflink_file() helper in copy.c uses FICLONE (Linux) and clonefile()
(macOS); elsewhere (other filesystems, Windows) it is probed up front and
falls back to a normal checkout. Defaulting is via the worktree.reflink
config (true/false/auto); --no-reflink overrides.
Patch 2 lets a branch be checked out in several worktrees, for parallel work
on one checkout. A branch mid-rebase or mid-bisect elsewhere is still
refused.
Benchmark (Linux-kernel fork, 93k files, ~33 GB tree incl. build output,
btrfs): a normal add allocates ~0.9 GB of real disk per worktree (~5.3 GB
for four, linear); --reflink allocates ~0 at any count and also carries the
untracked build tree. ("Real disk" = btrfs exclusive bytes.)
worktree-reflink-bench
[https://github.com/user-attachments/assets/e3e721c8-2206-4b78-ad08-21677ef30753]
Note: patch 2 changes a default (same-branch checkout now allowed); two
t2400 assertions were updated accordingly.
Jason Newton (2):
worktree: add --reflink for copy-on-write worktree creation
worktree: allow sharing a checked-out branch across worktrees
Documentation/config/worktree.adoc | 10 ++
Documentation/git-worktree.adoc | 47 +++++-
builtin/worktree.c | 257 ++++++++++++++++++++++++++++-
copy.c | 65 ++++++++
copy.h | 13 ++
t/t2400-worktree-add.sh | 119 ++++++++++++-
6 files changed, 493 insertions(+), 18 deletions(-)
base-commit: c69baaf57ba26cf117c2b6793802877f19738b0d
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2317%2Fnevion%2Fworktree-reflink-cow-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2317/nevion/worktree-reflink-cow-v1
Pull-Request: https://github.com/git/git/pull/2317
--
gitgitgadget
^ permalink raw reply
* [PATCH v13 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --prune-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 13 ++++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5c43dc55a8..1f49a831fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --prune-merged <branch>...
+git branch [--dry-run] --prune-merged <branch>...
DESCRIPTION
-----------
@@ -226,6 +226,12 @@ Branches refused by the "fully merged" safety check are listed as
warnings and skipped; pass them to `git branch -D` explicitly if
you want them gone.
+`--dry-run`::
+ With `--prune-merged`, print which branches would be
+ deleted and exit without touching any ref. Useful for
+ sanity-checking a wide pattern like `'origin/*'` before
+ committing to the deletion.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index be4218ded3..98e56d4ff8 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -715,7 +715,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
static int prune_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct ref_filter filter = REF_FILTER_INIT;
@@ -775,7 +775,8 @@ static int prune_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES,
DELETE_BRANCH_WARN_ONLY |
DELETE_BRANCH_NO_HEAD_FALLBACK |
- (quiet ? DELETE_BRANCH_QUIET : 0));
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
ref_array_clear(&candidates);
@@ -825,6 +826,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
int prune_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -880,6 +882,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "prune-merged", &prune_merged,
N_("delete local branches whose upstream matches <branch> and is merged")),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --prune-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -942,6 +946,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged)
+ die(_("--dry-run requires --prune-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ -981,7 +988,7 @@ int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
+ ret = prune_merged_branches(argc, argv, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 3f7b1fc3d6..305c0141fc 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+ test_when_finished "rm -rf pm-dry" &&
+ git clone pm-upstream pm-dry &&
+ git -C pm-dry remote add fork ../pm-fork &&
+ test_config -C pm-dry remote.pushDefault fork &&
+ test_config -C pm-dry push.default current &&
+ git -C pm-dry branch one one-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next one &&
+ git -C pm-dry branch two two-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next two &&
+
+ git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
+ test_grep "Would delete branch one " actual &&
+ test_grep "Would delete branch two " actual &&
+
+ git -C pm-dry rev-parse --verify refs/heads/one &&
+ git -C pm-dry rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
+ test_when_finished "rm -rf pm-dry-mixed" &&
+ git clone pm-upstream pm-dry-mixed &&
+ git -C pm-dry-mixed remote add fork ../pm-fork &&
+ test_config -C pm-dry-mixed remote.pushDefault fork &&
+ test_config -C pm-dry-mixed push.default current &&
+ git -C pm-dry-mixed checkout -b wip origin/next &&
+ git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-dry-mixed local-only &&
+ git -C pm-dry-mixed checkout - &&
+ git -C pm-dry-mixed branch merged one-commit &&
+ git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
+ git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
+ test_grep "Would delete branch merged" out &&
+ test_grep ! "Would delete branch wip" out &&
+ git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
+ git -C pm-dry-mixed rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run without --prune-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
+ test_grep "requires --prune-merged" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
"git branch --prune-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/branch.adoc | 7 +++++++
Documentation/git-branch.adoc | 5 +++--
builtin/branch.c | 14 ++++++++++++++
t/t3200-branch.sh | 30 ++++++++++++++++++++++++++++++
4 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
`git branch --edit-description`. Branch description is
automatically added to the `format-patch` cover letter or
`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ is exempt from
+ `git branch --prune-merged`. Useful for a topic branch you
+ intend to develop further after an initial round has been
+ merged upstream. Defaults to true. Explicit deletion via
+ `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index fdaccc9662..5c43dc55a8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -217,9 +217,10 @@ the upstream refs refreshed.
+
A branch is left alone if any of the following holds:
its upstream no longer resolves locally; it is checked out in any
-worktree; or its push destination (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.pruneMerged` is set to `false`.
+
Branches refused by the "fully merged" safety check are listed as
warnings and skipped; pass them to `git branch -D` explicitly if
diff --git a/builtin/branch.c b/builtin/branch.c
index 7a26447b2a..be4218ded3 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -739,6 +739,8 @@ static int prune_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
+ struct strbuf key = STRBUF_INIT;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
continue;
@@ -753,6 +755,18 @@ static int prune_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!quiet)
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
+ continue;
+ }
+ strbuf_release(&key);
+
strvec_push(&deletable, short_name);
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 27ea1319bb..3f7b1fc3d6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '
test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+ test_when_finished "rm -rf pm-optout-d" &&
+ git clone pm-upstream pm-optout-d &&
+ git -C pm-optout-d branch one one-commit &&
+ git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+ test_config -C pm-optout-d branch.one.pruneMerged false &&
+
+ git -C pm-optout-d branch -d one &&
+ test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 4/6] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from local refs; nothing is fetched. Run
"git fetch" first if you want fresh upstream refs.
Three kinds of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally, since a
missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is left
alone. Only branches that push somewhere other than their
upstream, typically topics in a fork workflow, are candidates.
Branches that are not yet merged into their upstream are reported
as a short warning and skipped, so one unmerged topic does not
abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 24 ++++
builtin/branch.c | 67 +++++++++++-
t/t3200-branch.sh | 201 ++++++++++++++++++++++++++++++++++
3 files changed, 290 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 62ebab6051..fdaccc9662 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --prune-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--prune-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 9568bb8445..7a26447b2a 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --prune-merged <branch>..."),
NULL
};
@@ -713,6 +714,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+static int prune_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates;
+ struct strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ memset(&candidates, 0, sizeof(candidates));
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream, *push;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ continue;
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
+ push = branch ? branch_get_push(branch, NULL) : NULL;
+ if (!push || !strcmp(push, upstream))
+ continue;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_clear(&deletable);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -754,6 +810,7 @@ int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ int prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -807,6 +864,8 @@ int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches <branch> and is merged")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -854,7 +913,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !prune_merged &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -864,7 +924,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -906,6 +966,9 @@ int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..27ea1319bb 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--prune-merged takes positional <branch> arguments' '
+ test_when_finished "rm -rf pm-positional" &&
+ git clone pm-upstream pm-positional &&
+ git -C pm-positional remote add fork ../pm-fork &&
+ test_config -C pm-positional remote.pushDefault fork &&
+ test_config -C pm-positional push.default current &&
+ git -C pm-positional branch one one-commit &&
+ git -C pm-positional branch --set-upstream-to=origin/next one &&
+ git -C pm-positional branch two base &&
+ git -C pm-positional branch --set-upstream-to=origin/main two &&
+ git -C pm-positional checkout --detach &&
+
+ git -C pm-positional branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 3/6] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--prune-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 19d6147e71..9568bb8445 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,
* upstream, if any, otherwise with HEAD", we should just
* return the result of the repo_in_merge_bases() above without
* any of the following code, but during the transition period,
- * a gentle reminder is in order.
+ * a gentle reminder is in order. Callers that opt out of the
+ * HEAD fallback by passing head_rev=NULL are not interested in
+ * the reminder either: they have already established that the
+ * branch has an upstream, so HEAD is irrelevant to the decision.
*/
- if (head_rev != reference_rev) {
- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+ if (head_rev && head_rev != reference_rev) {
+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
if (expect < 0)
exit(128);
if (expect == merged)
@@ -193,6 +196,8 @@ enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
DELETE_BRANCH_WARN_ONLY = (1 << 2),
+ DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
+ DELETE_BRANCH_DRY_RUN = (1 << 4),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -242,6 +247,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
int remote_branch = 0;
int force = flags & DELETE_BRANCH_FORCE;
int quiet = flags & DELETE_BRANCH_QUIET;
+ int dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ int no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -267,7 +274,7 @@ static int delete_branches(int argc, const char **argv, 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)) {
@@ -338,13 +345,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
free(target);
}
- if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+ if (!dry_run &&
+ refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
ret = 1;
for_each_string_list_item(item, &refs_to_delete) {
char *describe_ref = item->util;
char *name = item->string;
- if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+ if (dry_run) {
+ if (!quiet)
+ printf(remote_branch
+ ? _("Would delete remote-tracking branch %s (was %s).\n")
+ : _("Would delete branch %s (was %s).\n"),
+ name + branch_name_pos, describe_ref);
+ } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
if (!quiet)
printf(remote_branch
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a warn-only mode to delete_branches() and check_branch_commit()
so a bulk caller can report branches that are not fully merged as a
short warning and carry on, rather than erroring with the longer
"use 'git branch -D'" advice that the plain "git branch -d" path
emits. Existing callers are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 50 ++++++++++++++++++++++++++++++++----------------
1 file changed, 34 insertions(+), 16 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..19d6147e71 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,20 +189,33 @@ static int branch_merged(int kind, const char *name,
return merged;
}
+enum delete_branch_flags {
+ DELETE_BRANCH_FORCE = (1 << 0),
+ DELETE_BRANCH_QUIET = (1 << 1),
+ DELETE_BRANCH_WARN_ONLY = (1 << 2),
+};
+
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
+ int force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
- error(_("the branch '%s' is not fully merged"), branchname);
- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
- _("If you are sure you want to delete it, "
- "run 'git branch -D %s'"), branchname);
+ if (flags & DELETE_BRANCH_WARN_ONLY) {
+ warning(_("the branch '%s' is not fully merged"),
+ branchname);
+ } else {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+ _("If you are sure you want to delete it, "
+ "run 'git branch -D %s'"), branchname);
+ }
return -1;
}
return 0;
@@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
-static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
+static int delete_branches(int argc, const char **argv, int kinds,
+ unsigned int flags)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -227,6 +240,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
int i;
int ret = 0;
int remote_branch = 0;
+ int force = flags & DELETE_BRANCH_FORCE;
+ int quiet = flags & DELETE_BRANCH_QUIET;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -257,7 +272,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
char *target = NULL;
- int flags = 0;
+ int ref_flags = 0;
copy_branchname(&bname, argv[i], allowed_interpret);
free(name);
@@ -279,7 +294,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
RESOLVE_REF_READING
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
- &oid, &flags);
+ &oid, &ref_flags);
if (!target) {
if (remote_branch) {
error(_("remote-tracking branch '%s' not found"), bname.buf);
@@ -291,7 +306,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
&oid,
- &flags);
+ &ref_flags);
FREE_AND_NULL(virtual_name);
if (virtual_target)
@@ -306,16 +321,17 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
continue;
}
- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+ if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
- ret = 1;
+ flags)) {
+ if (!(flags & DELETE_BRANCH_WARN_ONLY))
+ ret = 1;
goto next;
}
item = string_list_append(&refs_to_delete, name);
- item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
- : (flags & REF_ISSYMREF) ? target
+ item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
+ : (ref_flags & REF_ISSYMREF) ? target
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
next:
@@ -872,7 +888,9 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+ ret = delete_branches(argc, argv, filter.kind,
+ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 10 +++-
builtin/branch.c | 18 ++++++-
ref-filter.c | 70 ++++++++++++++++++++++++++
ref-filter.h | 10 ++++
t/t3200-branch.sh | 92 +++++++++++++++++++++++++++++++++++
5 files changed, 197 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..62ebab6051 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are
reachable from the named commit) will be listed. With `--no-merged` only
branches not merged into the named commit will be listed. If the _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,12 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..c159f45b4c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -30,7 +30,7 @@
#include "commit-reach.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
+{
+ struct ref_filter *filter = opt->value;
+
+ BUG_ON_OPT_NEG(unset);
+ if (ref_filter_forked_add(filter, arg) < 0)
+ die(_("'%s' is not a valid branch or pattern"), arg);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ int cmd_branch(int argc,
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
+ OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
+ N_("print only branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_forked),
OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
OPT_REF_SORT(&sorting_options),
OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -815,7 +828,8 @@ int cmd_branch(int argc,
list = 1;
if (filter.with_commit || filter.no_commit ||
- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+ filter.reachable_from || filter.unreachable_from ||
+ filter.points_at.nr || filter.forked.nr)
list = 1;
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..1ddd5a3f6d 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
return match_pattern(filter->exclude.v, refname, filter->ignore_case);
}
+static const char *short_upstream_name(const char *full_ref)
+{
+ const char *short_name = full_ref;
+ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+ skip_prefix(short_name, "refs/remotes/", &short_name));
+ return short_name;
+}
+
+/*
+ * Match the configured upstream of a branch against the registered
+ * --forked patterns. Exact patterns are compared against the full
+ * upstream refname so they are unambiguous; glob patterns are matched
+ * against the abbreviated upstream so that a glob such as origin/...
+ * works as typed.
+ */
+static int filter_forked_match(struct ref_filter *filter, const char *refname)
+{
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+ int i;
+
+ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
+
+ for (i = 0; i < filter->forked.nr; i++) {
+ const char *pattern = filter->forked.v[i];
+ if (has_glob_specials(pattern)) {
+ if (!wildmatch(pattern, short_upstream_name(upstream),
+ WM_PATHNAME))
+ return 1;
+ } else if (!strcmp(pattern, upstream)) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ if (has_glob_specials(arg)) {
+ strvec_push(&filter->forked, arg);
+ return 0;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
+ starts_with(full_ref, "refs/remotes/"))) {
+ strvec_push(&filter->forked, full_ref);
+ free(full_ref);
+ return 0;
+ }
+ free(full_ref);
+ return -1;
+}
+
/*
* We need to seek to the reference right after a given marker but excluding any
* matching references. So we seek to the lexicographically next reference.
@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
return NULL;
+ if (filter->forked.nr && !filter_forked_match(filter, ref->name))
+ return NULL;
+
/*
* A merge filter is applied on refs pointing to commits. Hence
* obtain the commit using the 'oid' available and discard all
@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)
void ref_filter_clear(struct ref_filter *filter)
{
strvec_clear(&filter->exclude);
+ strvec_clear(&filter->forked);
oid_array_clear(&filter->points_at);
commit_list_free(filter->with_commit);
commit_list_free(filter->no_commit);
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..9361296e2a 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -67,6 +67,7 @@ struct ref_filter {
const char **name_patterns;
const char *start_after;
struct strvec exclude;
+ struct strvec forked;
struct oid_array points_at;
struct commit_list *with_commit;
struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {
#define REF_FILTER_INIT { \
.points_at = OID_ARRAY_INIT, \
.exclude = STRVEC_INIT, \
+ .forked = STRVEC_INIT, \
}
#define REF_FORMAT_INIT { \
.use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
struct ref_sorting *ref_sorting_options(struct string_list *);
/* Function to parse --merged and --no-merged options */
int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
+/*
+ * Register a --forked <branch> pattern on the filter. The argument is
+ * either a ref, which is resolved to its full refname, or a shell-style
+ * glob. Branches are kept only when their configured upstream matches
+ * one of the registered patterns. Returns -1 if the argument is not a
+ * valid ref or pattern.
+ */
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
/* Get the current HEAD's description */
char *get_head_description(void);
/* Set up translated strings in the output. */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ test_when_finished "git -C forked checkout detached" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v13 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its <branch> patterns as positional arguments
(e.g. git branch --prune-merged origin/main 'feature*') instead of
repeating the option.
Harald Nordgren (6):
branch: add --forked filter for --list mode
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 41 +++-
builtin/branch.c | 182 ++++++++++++---
ref-filter.c | 70 ++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 367 +++++++++++++++++++++++++++++++
6 files changed, 650 insertions(+), 27 deletions(-)
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v13
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v13
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v12:
1: 8834c424fb ! 1: ccd07cff25 branch: add --forked filter for --list mode
@@ Metadata
## Commit message ##
branch: add --forked filter for --list mode
- Add a --forked option to "git branch" list mode that keeps only
+ Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
- can be a ref (e.g. "origin/main", "master") or a shell-style
- glob (e.g. "origin/*"). The option can be repeated to widen the
- filter.
+ can be a ref (e.g. "origin/main", "master") or a shell glob
+ (e.g. "origin/*"), and may be repeated to widen the filter.
- Because it is a filter on list mode, --forked composes with the
- existing list-mode filters, so
+ It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
- lists branches forked from origin that have already been
- integrated into origin/main, and --no-merged inverts the question.
+ lists branches forked from origin that are already merged into
+ origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
@@ Commit message
## Documentation/git-branch.adoc ##
@@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]
+ [--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
- [--points-at <object>] [--format=<format>]
+ [(--forked <branch>)...]
+ [--points-at <object>] [--format=<format>]
[(-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.
+@@ Documentation/git-branch.adoc: merged into the named commit (i.e. the branches whose tip commits are
+ reachable from the named commit) will be listed. With `--no-merged` only
+ branches not merged into the named commit will be listed. If the _<commit>_
+ argument is missing it defaults to `HEAD` (i.e. the tip of the current
+-branch).
++branch). With `--forked`, only branches whose configured upstream matches
++the given branch or pattern will be listed.
+
+ The command's second form creates a new branch head named _<branch-name>_
+ which points to the current `HEAD`, or _<start-point>_ if given. As a
+@@ Documentation/git-branch.adoc: superproject's "origin/main", but tracks the submodule's "origin/main".
+ Only list branches whose tips are not reachable from
+ _<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
-+ List only branches whose configured upstream matches
++ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
-+ option can be repeated to widen the filter.
++ option can be repeated to widen the filter. Implies `--list`.
+
- `-v`::
- `-vv`::
- `--verbose`::
+ `--points-at <object>`::
+ Only list branches of _<object>_.
+
## builtin/branch.c ##
@@
- #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]"),
@@ builtin/branch.c
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);
+@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
+ free_worktrees(worktrees);
}
-+static void filter_array_by_forked(struct ref_array *array,
-+ const struct string_list *upstreams);
++static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
++{
++ struct ref_filter *filter = opt->value;
+
- 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
++ BUG_ON_OPT_NEG(unset);
++ if (ref_filter_forked_add(filter, arg) < 0)
++ die(_("'%s' is not a valid branch or pattern"), arg);
++ return 0;
++}
++
+ static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
- filter_refs(&array, filter, filter->kind);
+ static int edit_branch_description(const char *branch_name)
+@@ builtin/branch.c: int cmd_branch(int argc,
+ OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
+ OPT_MERGED(&filter, N_("print only branches that are merged")),
+ OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
++ OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
++ N_("print only branches whose upstream matches <branch> (repeatable)"),
++ PARSE_OPT_NONEG, parse_opt_forked),
+ OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
+ OPT_REF_SORT(&sorting_options),
+ OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
+@@ builtin/branch.c: int cmd_branch(int argc,
+ list = 1;
-+ if (forked_upstreams->nr)
-+ filter_array_by_forked(&array, forked_upstreams);
-+
- if (filter->verbose)
- maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
+ if (filter.with_commit || filter.no_commit ||
+- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
++ filter.reachable_from || filter.unreachable_from ||
++ filter.points_at.nr || filter.forked.nr)
+ list = 1;
-@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
- free_worktrees(worktrees);
+ noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
+
+ ## ref-filter.c ##
+@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const char *refname)
+ return match_pattern(filter->exclude.v, refname, filter->ignore_case);
}
-+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;
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ 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)
++/*
++ * Match the configured upstream of a branch against the registered
++ * --forked patterns. Exact patterns are compared against the full
++ * upstream refname so they are unambiguous; glob patterns are matched
++ * against the abbreviated upstream so that a glob such as origin/...
++ * works as typed.
++ */
++static int filter_forked_match(struct ref_filter *filter, const char *refname)
+{
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
++ int i;
+
-+ if (!skip_prefix(full_refname, "refs/heads/", &short_name))
++ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
-+ return upstream_matches(short_upstream_name(upstream),
-+ patterns, nr_patterns);
++
++ for (i = 0; i < filter->forked.nr; i++) {
++ const char *pattern = filter->forked.v[i];
++ if (has_glob_specials(pattern)) {
++ if (!wildmatch(pattern, short_upstream_name(upstream),
++ WM_PATHNAME))
++ return 1;
++ } else if (!strcmp(pattern, upstream)) {
++ return 1;
++ }
++ }
++ return 0;
+}
+
-+static void filter_array_by_forked(struct ref_array *array,
-+ const struct string_list *upstreams)
++int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
-+ struct upstream_pattern *patterns = NULL;
-+ size_t nr_patterns = 0;
-+ int i, kept = 0;
-+
-+ parse_forked_args(upstreams, &patterns, &nr_patterns);
++ struct object_id oid;
++ char *full_ref = NULL;
+
-+ 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);
++ if (has_glob_specials(arg)) {
++ strvec_push(&filter->forked, arg);
++ return 0;
+ }
-+ array->nr = kept;
+
-+ upstream_pattern_list_clear(patterns, nr_patterns);
++ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
++ &full_ref, 0) == 1 &&
++ (starts_with(full_ref, "refs/heads/") ||
++ starts_with(full_ref, "refs/remotes/"))) {
++ strvec_push(&filter->forked, full_ref);
++ free(full_ref);
++ return 0;
++ }
++ free(full_ref);
++ return -1;
+}
+
- static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
-
- static int edit_branch_description(const char *branch_name)
-@@ 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;
-+ 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_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,
- 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 +
-@@ builtin/branch.c: 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);
-@@ 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);
+ /*
+ * We need to seek to the reference right after a given marker but excluding any
+ * matching references. So we seek to the lexicographically next reference.
+@@ ref-filter.c: static struct ref_array_item *apply_ref_filter(const struct reference *ref,
+ if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
+ return NULL;
++ if (filter->forked.nr && !filter_forked_match(filter, ref->name))
++ return NULL;
++
/*
- * 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)
+ * A merge filter is applied on refs pointing to commits. Hence
+ * obtain the commit using the 'oid' available and discard all
+@@ ref-filter.c: void ref_filter_init(struct ref_filter *filter)
+ void ref_filter_clear(struct ref_filter *filter)
+ {
+ strvec_clear(&filter->exclude);
++ strvec_clear(&filter->forked);
+ oid_array_clear(&filter->points_at);
+ commit_list_free(filter->with_commit);
+ commit_list_free(filter->no_commit);
## 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 */
+@@ ref-filter.h: struct ref_filter {
+ const char **name_patterns;
+ const char *start_after;
+ struct strvec exclude;
++ struct strvec forked;
+ struct oid_array points_at;
+ struct commit_list *with_commit;
+ struct commit_list *no_commit;
+@@ ref-filter.h: struct ref_format {
+ #define REF_FILTER_INIT { \
+ .points_at = OID_ARRAY_INIT, \
+ .exclude = STRVEC_INIT, \
++ .forked = STRVEC_INIT, \
+ }
+ #define REF_FORMAT_INIT { \
+ .use_color = GIT_COLOR_UNKNOWN, \
+@@ ref-filter.h: void ref_sorting_release(struct ref_sorting *);
+ struct ref_sorting *ref_sorting_options(struct string_list *);
+ /* Function to parse --merged and --no-merged options */
+ int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
++/*
++ * Register a --forked <branch> pattern on the filter. The argument is
++ * either a ref, which is resolved to its full refname, or a shell-style
++ * glob. Branches are kept only when their configured upstream matches
++ * one of the registered patterns. Returns -1 if the argument is not a
++ * valid ref or pattern.
++ */
++int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
+ /* Get the current HEAD's description */
+ char *get_head_description(void);
+ /* Set up translated strings in the output. */
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
2: 6c95e4e77c ! 2: a7672713f6 branch: let delete_branches warn instead of error on bulk refusal
@@ Metadata
## Commit message ##
branch: let delete_branches warn instead of error on bulk refusal
- 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.
+ Add a warn-only mode to delete_branches() and check_branch_commit()
+ so a bulk caller can report branches that are not fully merged as a
+ short warning and carry on, rather than erroring with the longer
+ "use 'git branch -D'" advice that the plain "git branch -d" path
+ emits. Existing callers are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## builtin/branch.c ##
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
+ return merged;
+ }
++enum delete_branch_flags {
++ DELETE_BRANCH_FORCE = (1 << 0),
++ DELETE_BRANCH_QUIET = (1 << 1),
++ DELETE_BRANCH_WARN_ONLY = (1 << 2),
++};
++
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
-+ int kinds, int force, int warn_only)
++ int kinds, unsigned int flags)
{
++ int force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
-@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
+ error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
- 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) {
++ if (flags & DELETE_BRANCH_WARN_ONLY) {
+ warning(_("the branch '%s' is not fully merged"),
+ branchname);
+ } else {
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
}
return 0;
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
+ strbuf_release(&buf);
}
- static int delete_branches(int argc, const char **argv, int force, int kinds,
+-static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
-+ int quiet, int warn_only)
++static int delete_branches(int argc, const char **argv, int kinds,
++ unsigned int flags)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ int i;
+ int ret = 0;
+ int remote_branch = 0;
++ int force = flags & DELETE_BRANCH_FORCE;
++ int quiet = flags & DELETE_BRANCH_QUIET;
+ struct strbuf bname = STRBUF_INIT;
+ enum interpret_branch_kind allowed_interpret;
+ struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+
+ for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
+ char *target = NULL;
+- int flags = 0;
++ int ref_flags = 0;
+
+ copy_branchname(&bname, argv[i], allowed_interpret);
+ free(name);
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ RESOLVE_REF_READING
+ | RESOLVE_REF_NO_RECURSE
+ | RESOLVE_REF_ALLOW_BAD_NAME,
+- &oid, &flags);
++ &oid, &ref_flags);
+ if (!target) {
+ if (remote_branch) {
+ error(_("remote-tracking branch '%s' not found"), bname.buf);
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ | RESOLVE_REF_NO_RECURSE
+ | RESOLVE_REF_ALLOW_BAD_NAME,
+ &oid,
+- &flags);
++ &ref_flags);
+ FREE_AND_NULL(virtual_name);
- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+ if (virtual_target)
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ continue;
+ }
+
+- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
++ if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
- ret = 1;
-+ force, warn_only)) {
-+ if (!warn_only)
++ flags)) {
++ if (!(flags & DELETE_BRANCH_WARN_ONLY))
+ ret = 1;
goto next;
}
+ item = string_list_append(&refs_to_delete, name);
+- item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
+- : (flags & REF_ISSYMREF) ? target
++ item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
++ : (ref_flags & REF_ISSYMREF) ? target
+ : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
+
+ next:
@@ builtin/branch.c: 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);
++ ret = delete_branches(argc, argv, filter.kind,
++ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
++ (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
3: 004a96f7a4 ! 3: 5ee7643d3a 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.
-
- 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.
+ Teach delete_branches() two new modes for the upcoming
+ --prune-merged: one that asks only whether a branch is merged into
+ its upstream, without falling back to HEAD when there is no
+ upstream, and one that rehearses the deletions without removing any
+ ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
if (expect < 0)
exit(128);
if (expect == merged)
-@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
- }
+@@ builtin/branch.c: enum delete_branch_flags {
+ DELETE_BRANCH_FORCE = (1 << 0),
+ DELETE_BRANCH_QUIET = (1 << 1),
+ DELETE_BRANCH_WARN_ONLY = (1 << 2),
++ DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
++ DELETE_BRANCH_DRY_RUN = (1 << 4),
+ };
- static int 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;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ static int check_branch_commit(const char *branchname, const char *refname,
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
+ int remote_branch = 0;
+ int force = flags & DELETE_BRANCH_FORCE;
+ int quiet = flags & DELETE_BRANCH_QUIET;
++ int dry_run = flags & DELETE_BRANCH_DRY_RUN;
++ int no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
+ struct strbuf bname = STRBUF_INIT;
+ enum interpret_branch_kind allowed_interpret;
+ struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
free(target);
}
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
char *refname = name + branch_name_pos;
if (!quiet)
printf(remote_branch
-@@ builtin/branch.c: 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();
4: cccfdb831c ! 4: 5f913c445c branch: add --prune-merged <branch>
@@ Commit message
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
- restricted to those whose tip is reachable from their configured
+ keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
- Reachability is read from local refs; nothing is fetched. Users
- who want fresh upstream refs run "git fetch" first.
+ Reachability is read from local refs; nothing is fetched. Run
+ "git fetch" first if you want fresh upstream refs.
- Three classes of branches are spared:
+ Three kinds 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 upstream no longer resolves locally, since a
+ missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
- (<branch>@{push} == <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.
+ (<branch>@{push} is the same as <branch>@{upstream}), such as
+ a local "main" that tracks and pushes to "origin/main". Right
+ after a pull it just looks "fully merged", so it is left
+ alone. Only branches that push somewhere other than their
+ upstream, typically topics in a fork workflow, are candidates.
- 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.
+ Branches that are not yet merged into their upstream are reported
+ as a short warning and skipped, so one unmerged topic does not
+ abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: 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 --prune-merged <branch>...
DESCRIPTION
-----------
@@ 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.
+ Print the name of the current branch. In detached `HEAD` state,
+ nothing is printed.
-+`--prune-merged <branch>`::
++`--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.
++ given _<branch>_ arguments, but only those whose tip is
++ reachable from their configured upstream. In other words, the
++ work on the branch has already landed on the upstream it
++ tracks, so the local copy is no longer needed. Several
++ _<branch>_ patterns may be given, e.g. `git branch
++ --prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
@@ 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>)..."),
++ N_("git branch [<options>] --prune-merged <branch>..."),
NULL
};
-@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
+@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
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);
-@@ builtin/branch.c: 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);
-@@ builtin/branch.c: 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,
++static int prune_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct string_list candidates = STRING_LIST_INIT_DUP;
++ struct ref_filter filter = REF_FILTER_INIT;
++ struct ref_array candidates;
+ struct strvec deletable = STRVEC_INIT;
-+ struct string_list_item *item;
-+ int ret = 0;
++ int i, ret = 0;
+
-+ if (!upstreams->nr)
++ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
-+ collect_forked_set(upstreams, &candidates);
++ for (i = 0; i < argc; i++)
++ if (ref_filter_forked_add(&filter, argv[i]) < 0)
++ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
-+ for_each_string_list_item(item, &candidates) {
-+ const char *short_name = item->string;
-+ struct branch *branch = branch_get(short_name);
++ filter.kind = FILTER_REFS_BRANCHES;
++ memset(&candidates, 0, sizeof(candidates));
++ filter_refs(&candidates, &filter, filter.kind);
++
++ for (i = 0; i < candidates.nr; i++) {
++ const char *full_name = candidates.items[i]->refname;
++ const char *short_name;
++ struct branch *branch;
+ const char *upstream, *push;
-+ 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)
++ if (!skip_prefix(full_name, "refs/heads/", &short_name))
++ continue;
++ if (branch_checked_out(full_name))
+ continue;
+
++ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
+
+ 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 */);
++ DELETE_BRANCH_WARN_ONLY |
++ DELETE_BRANCH_NO_HEAD_FALLBACK |
++ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_clear(&deletable);
-+ string_list_clear(&candidates, 0);
++ ref_array_clear(&candidates);
++ ref_filter_clear(&filter);
+ return ret;
+}
+
@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
static int edit_branch_description(const char *branch_name)
@@ 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;
- struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
-+ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
++ int prune_merged = 0;
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_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_BOOL(0, "prune-merged", &prune_merged,
++ N_("delete local branches whose upstream matches <branch> and is merged")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
++ !show_current && !unset_upstream && !prune_merged &&
+ 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;
-+ !!unset_upstream + !!prune_merged_upstreams.nr;
++ !!unset_upstream + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
- quiet, 0, 0, 0);
+ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+ (quiet ? DELETE_BRANCH_QUIET : 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);
++ } else if (prune_merged) {
++ ret = prune_merged_branches(argc, argv, 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 a value' '
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 &&
++ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
-+test_expect_success '--prune-merged requires a value' '
++test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
-+ test_grep "requires a value" err
++ test_grep "requires at least one <branch>" 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_expect_success '--prune-merged takes positional <branch> arguments' '
++ test_when_finished "rm -rf pm-positional" &&
++ git clone pm-upstream pm-positional &&
++ git -C pm-positional remote add fork ../pm-fork &&
++ test_config -C pm-positional remote.pushDefault fork &&
++ test_config -C pm-positional push.default current &&
++ git -C pm-positional branch one one-commit &&
++ git -C pm-positional branch --set-upstream-to=origin/next one &&
++ git -C pm-positional branch two base &&
++ git -C pm-positional branch --set-upstream-to=origin/main two &&
++ git -C pm-positional checkout --detach &&
++
++ git -C pm-positional branch --prune-merged origin/next origin/main &&
++
++ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
++ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
5: 5f793f8d0d ! 5: 8e9a735ffe branch: add branch.<name>.pruneMerged opt-out
@@ Commit message
branch: add branch.<name>.pruneMerged opt-out
Setting branch.<name>.pruneMerged=false exempts that branch from
- "git branch --prune-merged". Useful for a topic branch you want
- to develop further after an initial round has been merged
- upstream.
+ "git branch --prune-merged", which is useful for a topic you want
+ to keep developing after an early round of it has been merged
+ upstream. Unless --quiet is given, each skip is reported so the
+ user knows why their topic was kept.
- 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.
+ Explicit deletion with "git branch -d" still uses the normal merge
+ check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/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(const struct string_list *upstreams,
- struct branch *branch = branch_get(short_name);
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ const char *short_name;
+ struct branch *branch;
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);
-@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ continue;
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *ups
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
- test_grep "does not take positional arguments" err
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
6: 1a0d5eab15 ! 6: 511de4788e 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 each, and exits
+ without touching any ref. The same filtering applies, so the output
+ is exactly the set that the real 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.
+ --dry-run is only meaningful together with --prune-merged and is
+ rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: 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>)...
+-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 void collect_forked_set(const struct string_list *upstreams,
+@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
- static int prune_merged_branches(const struct string_list *upstreams,
+ static int prune_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
- struct string_list candidates = STRING_LIST_INIT_DUP;
-@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
- quiet,
- 1, /* warn_only */
- 1, /* no_head_fallback */
-- 0 /* dry_run */);
-+ dry_run);
+ struct ref_filter filter = REF_FILTER_INIT;
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+- (quiet ? DELETE_BRANCH_QUIET : 0));
++ (quiet ? DELETE_BRANCH_QUIET : 0) |
++ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
- string_list_clear(&candidates, 0);
+ ref_array_clear(&candidates);
@@ 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;
- struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
- struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
+ int prune_merged = 0;
+ 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 <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)")),
+ N_("edit the description for the branch")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches <branch> and is merged")),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --prune-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !prune_merged_upstreams.nr)
++ if (dry_run && !prune_merged)
+ die(_("--dry-run requires --prune-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: 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);
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+ goto out;
+ } else if (prune_merged) {
+- ret = prune_merged_branches(argc, argv, quiet);
++ ret = prune_merged_branches(argc, argv, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply
* Re: [PATCH] docs: fix typos
From: Weijie Yuan @ 2026-06-05 18:26 UTC (permalink / raw)
To: Tuomas Ahola, Kristoffer Haugsbakk; +Cc: git
In-Reply-To: <20260604165557.MWQuu%taahol@utu.fi>
On Thu, Jun 04, 2026 at 07:55:57PM +0300, Tuomas Ahola wrote:
> "Kristoffer Haugsbakk" <kristofferhaugsbakk@fastmail.com> wrote:
>
> > On Thu, Jun 4, 2026, at 15:14, Tuomas Ahola wrote:
> > > [PATCH] docs: fix typos
> >
> > The area `docs` isn´t correct since you are also changing comments in
> > source files.
> >
> > `*` could be used (as in a wildcard). Other people have used other
> > things for "treewide" changes.
> >
>
> Hmm, I took that from the other typofix patches we have currently in `seen`.
Hi all,
Yup, my patch 8570d9ba31 (Merge branch 'wy/docs-typofixes' into seen, 2026-06-04)
used "docs" as the scope of the commit message.
I thought for a while what would be the preferred scope for a typofixes
commit that touches a quite wide range of the tree. And I didn't know we
could use "*".
On the one hand, I would rather let the typofix thing be simple and easy
in one commit, since the actual changes were sort of logically unified.
On the other hand, splitting them apart makes it easier to name the
scope of each commit, while it would make much "meaningless" noise. So I
was torn at that time.
"Luckily", Junio didn't say something about that, and my topic is
waiting for more comments. So it just happens when I am struggling with
re-roll it or not, I saw this thread. Therefore, could you please also
raise it again in my topic in order to give it a final decision? (Or
start a new thread as my patch is actually in Andrew's thread, really
sorry Andrew)
Message-ID of my previous patch:
<7b502e20e9495cd4720496bd6738a1fbeb453410.1780041658.git.wy@wyuan.org>
Thanks!
^ permalink raw reply
* Re: [PATCH v12 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren @ 2026-06-05 17:50 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
In-Reply-To: <9f5c36a9-a3b8-403d-9c59-40367eb895bd@gmail.com>
Hi Phillip!
Great points all around, I will take a look at implementing them. I'll
respond here instead of for each specific message, and then include
comments as part of the next version.
> > 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.
>
> Do we want to support a remote name as an alias for $remote/HEAD to
> match "git checkout -b $remote"?
I have been going back and forth on this, and while I like the bare
remote, it made the implementation a lot easier after it was removed,
as the arguments from some of the others made sense to me.
Harald
^ permalink raw reply
* Re: [GSoC PATCH v2 0/4] teach git repo info to handle path keys
From: Lucas Seiki Oshiro @ 2026-06-05 17:35 UTC (permalink / raw)
To: K Jayatheerth
Cc: git, a3205153416, gitster, jltobler, kumarayushjha123,
phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>
> * About lexicographical order: "Breaking" wasn't the right term
> before, but I do believe keeping .absolute and .relative as
> suffixes is a better choice. I prefer having the two choices
> side-by-side grouped by entity, rather than a cluster of absolute
> keys followed by relative ones. Open to hearing if the latter is
> preferred!
I prefer `.(absolute|relative)` at the end. `path.gitdir.relative`
means that we have a collection of paths, in those collections we
have gitdir that can be relative or absolute, and we want the
relative. `path.relative.gitdir` means that we have a collection
of relative paths and from those we're picking gitdir. The first
feels more natural.
> Thanks for this round of feedback guys, this has been fruitful!
Thanks again for your interest in improving `git repo info`!
I'll review your patchset with more attention later.
PS: this is a nitpick, but it would be really helpful if you provide
a range-diff in the cover letter. Check the usage of `--range-diff`
in git-format-patch documentation (this flag also works for
git-send-email). Or, if you prefer, you can generate it by running
`git range-diff` and copying the output.
^ permalink raw reply
* [PATCH] doc: fix typo in GIT_ALTERNATE_OBJECT_DIRECTORIES
From: Alexander Monakov @ 2026-06-05 17:26 UTC (permalink / raw)
To: git
One file accidentally spelled GIT_ALTERNATE_OBJECT_DIRECTORIES with
REPOSITORIES instead of DIRECTORIES. Fix the typo.
Signed-off-by: Alexander Monakov <amonakov@ispras.ru>
---
Documentation/technical/hash-function-transition.adoc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Documentation/technical/hash-function-transition.adoc b/Documentation/technical/hash-function-transition.adoc
index 2359d7d106..241d2f763d 100644
--- a/Documentation/technical/hash-function-transition.adoc
+++ b/Documentation/technical/hash-function-transition.adoc
@@ -545,7 +545,7 @@ Alternates
~~~~~~~~~~
For the same reason, a SHA-256 repository cannot borrow objects from a
SHA-1 repository using objects/info/alternates or
-$GIT_ALTERNATE_OBJECT_REPOSITORIES.
+$GIT_ALTERNATE_OBJECT_DIRECTORIES.
git notes
~~~~~~~~~
--
2.49.1
^ permalink raw reply related
* Re: [PATCH] worktree: record creation time and free-form note
From: Chris Torek @ 2026-06-05 16:57 UTC (permalink / raw)
To: phillip.wood; +Cc: Kiesel, Norbert, git
In-Reply-To: <b1b15a47-0842-4a26-9a95-bfdae12799e0@gmail.com>
On Fri, Jun 5, 2026 at 8:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
> Isn't "what is the worktree for" a property of the branch that's checked
> out, not the worktree itself?
I don't think it is.
A lot of things within Git have, shall way say, "less than optimal"
names, with "branch" (with at least three different meanings),
"HEAD", and "index" being examples of this. (This is just an
observation, not a complaint: we know from studies that
oddities in names don't matter that much after a bit of usage
of some system. They're just minor stumbling blocks when
getting started.)
Work-tree or working tree is not one of them, though. It's
concise and pointed: a working tree is where you do work.
As such, the *purpose* of a working tree is exactly as general
as the purpose of doing work! That's a wide-open set.
Git's internal constraint, of requiring each working tree that
is using a branch name to have a unique-to-that-tree branch
name, is a property specific to branch names, not to branching
in general (an example of the ambiguity of "branch" here).
And of course, as you note, any working tree can be on
a detached HEAD.
Exactly what properties any given working tree should
have, and the weird entanglement Git has between the
"primary" working tree (the one created by any non-bare
clone) and all "secondary" working trees, is a mere (ahem)
matter of implementation. Descriptions, creation times,
modification times, etc., are all potentially useful.
I think, had Git initially made all repositories effectively
bare, with separate working trees added later, this might
all be a little clearer, but of course that ship sailed,
crossed *all* the oceans, sank, was refloated and refitted,
and sailed for another decade already. :-)
Chris
^ permalink raw reply
* Re: [GSoC PATCH v2 1/4] path: introduce format_path() for centralized path formatting
From: Kristoffer Haugsbakk @ 2026-06-05 16:55 UTC (permalink / raw)
To: JAYATHEERTH K, git
Cc: a3205153416, Junio C Hamano, Justin Tobler, kumarayushjha123,
Lucas Seiki Oshiro, Phillip Wood, brian m. carlson
In-Reply-To: <20260605163012.181089-2-jayatheerthkulkarni2005@gmail.com>
On Fri, Jun 5, 2026, at 18:30, K Jayatheerth wrote:
> The path-formatting logic inside `builtin/rev-parse.c` handles absolute,
> canonical, and relative formatting rules based on user-supplied options.
> However, this logic is tightly coupled to `rev-parse` and writes directly
> to stdout.
>
> To allow other builtins (such as the upcoming `git repo` path keys) to
> re-use this logic, extract the core path-formatting algorithm into a centralized
> helper function, `format_path()`, in `path.c`.
>
> Expose a single, streamlined `path_format` enum in `path.h` to let callers
> explicitly declare their formatting strategy (UNMODIFIED, RELATIVE,
> RELATIVE_IF_SHARED, or CANONICAL). This decouples the core algorithm from
> the localized fallback mechanics specific to `rev-parse`.
This looks very well explained to my naive eyes.
>
> Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
> Mentored-by: Justin Tobler <jltobler@gmail.com>
> Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Nitpick. You are supposed to add your `Signed-off-by` at the end. You
are saying with that line that you are signing off on the changes and
the commit message, including the trailers (mentors) you’ve decided to
add. Imagine if the maintainer applies this patch and fixes a typo and
the commit becomes:
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
[jc: typo fix]
Signed-off-by: Junio ...
The chain of custody is then very clear.
> ---
>[snip]
^ permalink raw reply
* Re: [PATCH] worktree: record creation time and free-form note
From: Kristoffer Haugsbakk @ 2026-06-05 16:50 UTC (permalink / raw)
To: Phillip Wood, Kiesel, Norbert, git
In-Reply-To: <b1b15a47-0842-4a26-9a95-bfdae12799e0@gmail.com>
On Fri, Jun 5, 2026, at 17:17, Phillip Wood wrote:
>>[snip]
>> Add per-worktree metadata so users can answer "what is this worktree
>> for, and when did I make it?" without resorting to external notes.
>
> A couple of thoughts related to this
>
> Isn't "what is the worktree for" a property of the branch that's checked
> out, not the worktree itself? We already have
> branch.<branch>.description to add a descritpion to a branch. If you
> have a detached HEAD it is trickier though.
Some worktrees that I have that are not about one branch:
• For building and deploying the code
• For spelunking old versions
• For testing building the code/app with all untracked files deleted
(“cleanroom”) to compare with my own setup or my coworkers when
the regular working trees get apparently gummed up
• (for Git) with leak checker
• (for Git) with breaking changes (when testing git-whatchanged(1) I
think?)
I would also like to (some time) set up a worktree or multiple of them
to run tests on each commit.
***
If I would feel like needing a note about these worktrees and it wasn’t
supported by git(1)? I guess I would use a per-worktree ref. That would
even feel more “Git” than an administrative file, but I guess there are
technical reasons for why they are not used for these things.
>[snip]
^ permalink raw reply
* [PATCH] ref-filter: restore prefix-scoped iteration
From: Tamir Duberstein @ 2026-06-05 16:43 UTC (permalink / raw)
To: git
Cc: Karthik Nayak, Patrick Steinhardt, Junio C Hamano, Victoria Dye,
ZheNing Hu, Tamir Duberstein
Commit dabecb9db2 (for-each-ref: introduce a '--start-after' option,
2025-07-15) changed single-kind branch, remote-tracking branch, and tag
enumeration in do_filter_refs() from constructing an iterator with the
namespace prefix to constructing an unscoped iterator and applying the
prefix with ref_iterator_seek().
Before that change, refs_for_each_fullref_in() passed the namespace
prefix during iterator construction. That helper has since been
replaced by refs_for_each_ref_ext().
The files backend primes its loose-ref cache for the construction
prefix before it opens packed refs. An empty construction prefix
therefore reads every loose ref, and a later seek cannot undo that I/O.
Consequently, git branch, git branch --remotes, and git tag scale with
unrelated loose refs.
Patrick Steinhardt observed during review that iterator construction
and seeking accepted similar strings but assigned them different state
semantics. Junio C Hamano then pointed out that no current command can
combine start_after with this single-kind path, but future branch or
tag support would need to keep the namespace while moving the cursor.
Keep the existing start_after path unchanged. The iterator API cannot
currently seek to one string while retaining another as its prefix:
an unflagged seek clears the prefix, while REF_ITERATOR_SEEK_SET_PREFIX
replaces it with the seek string.
For the commands affected by this regression, which do not set
start_after, pass the namespace prefix during iterator construction so
that loose refs are scoped before the packed-refs snapshot is opened.
This fixes the current regression without deleting the ref-filter state
discussed during review or changing its dormant behavior.
Add REFFILES-gated performance cases with one branch, one
remote-tracking branch, one tag, and 10,000 unrelated loose refs. The
benchmarks were run with:
GIT_PERF_REPEAT_COUNT=5 GIT_PERF_MAKE_OPTS=-j8 \
t/perf/run a89346e34a . -- p6300-for-each-ref.sh
The following are the best of five runs, with each run invoking the
command ten times. Times are elapsed seconds with user and system CPU
seconds in parentheses:
a89346e34a this commit
branch 2.74(0.13+2.56) 0.11(0.04+0.04)
branch --remotes 2.81(0.13+2.62) 0.12(0.04+0.04)
tag 3.01(0.14+2.82) 0.11(0.04+0.04)
Both revisions used the default -O2 build flags and a config.mak
containing only "NO_REGEX = NeedsStartEnd". They were built with Apple
clang 21.0.0 on macOS 26.5. The machine was a MacBook Pro (Mac16,6)
with a 16-core Apple M4 Max (12 performance and four efficiency cores)
and 128 GB RAM.
Link: https://lore.kernel.org/git/aGZidwwlToWThkn8@pks.im/
Link: https://lore.kernel.org/git/xmqqikjq7s16.fsf@gitster.g/
Fixes: dabecb9db2b2 ("for-each-ref: introduce a '--start-after' option")
Assisted-by: Codex gpt-5.5
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
The series is based on a89346e34a (maint) because the regression has
been present in released versions since Git 2.51.0.
---
ref-filter.c | 30 +++++++++++++++++++++---------
t/perf/p6300-for-each-ref.sh | 39 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 59 insertions(+), 10 deletions(-)
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..2388a57b39 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -3315,19 +3315,31 @@ static int do_filter_refs(struct ref_filter *filter, unsigned int type, refs_for
prefix = "refs/tags/";
if (prefix) {
- struct ref_iterator *iter;
+ if (filter->start_after) {
+ struct ref_iterator *iter;
- iter = refs_ref_iterator_begin(get_main_ref_store(the_repository),
- "", NULL, 0, 0);
+ iter = refs_ref_iterator_begin(
+ get_main_ref_store(the_repository), "", NULL, 0,
+ 0);
- if (filter->start_after)
ret = start_ref_iterator_after(iter, filter->start_after);
- else
- ret = ref_iterator_seek(iter, prefix,
- REF_ITERATOR_SEEK_SET_PREFIX);
+ if (!ret)
+ ret = do_for_each_ref_iterator(iter, fn,
+ cb_data);
+ } else {
+ /*
+ * Pass the prefix during construction because the files
+ * backend primes loose refs before a later seek can
+ * narrow the iterator.
+ */
+ struct refs_for_each_ref_options opts = {
+ .prefix = prefix,
+ };
- if (!ret)
- ret = do_for_each_ref_iterator(iter, fn, cb_data);
+ ret = refs_for_each_ref_ext(
+ get_main_ref_store(the_repository), fn, cb_data,
+ &opts);
+ }
} else if (filter->kind & FILTER_REFS_REGULAR) {
ret = for_each_fullref_in_pattern(filter, fn, cb_data);
}
diff --git a/t/perf/p6300-for-each-ref.sh b/t/perf/p6300-for-each-ref.sh
index fa7289c752..ed9c1c6a19 100755
--- a/t/perf/p6300-for-each-ref.sh
+++ b/t/perf/p6300-for-each-ref.sh
@@ -1,6 +1,6 @@
#!/bin/sh
-test_description='performance of for-each-ref'
+test_description='performance of ref-filter users'
. ./perf-lib.sh
test_perf_fresh_repo
@@ -84,4 +84,41 @@ test_expect_success 'pack refs' '
'
run_tests "packed"
+test_expect_success REFFILES 'setup many unrelated loose refs' '
+ git init scoped &&
+ test_commit -C scoped --no-tag base &&
+ test_seq $ref_count_per_type |
+ sed "s,.*,update refs/custom/unrelated_& HEAD," |
+ git -C scoped update-ref --stdin &&
+ git -C scoped update-ref refs/remotes/origin/main HEAD &&
+ git -C scoped update-ref refs/tags/only HEAD
+'
+
+test_perf "branch (many unrelated loose refs)" --prereq REFFILES "
+ (
+ cd scoped &&
+ for i in \$(test_seq $test_iteration_count); do
+ git branch --format='%(refname)' >/dev/null
+ done
+ )
+"
+
+test_perf "branch --remotes (many unrelated loose refs)" --prereq REFFILES "
+ (
+ cd scoped &&
+ for i in \$(test_seq $test_iteration_count); do
+ git branch --remotes --format='%(refname)' >/dev/null
+ done
+ )
+"
+
+test_perf "tag (many unrelated loose refs)" --prereq REFFILES "
+ (
+ cd scoped &&
+ for i in \$(test_seq $test_iteration_count); do
+ git tag --format='%(refname)' >/dev/null
+ done
+ )
+"
+
test_done
---
base-commit: a89346e34a937f001e5d397ee62224e3e9852040
change-id: 20260605-fix-git-branch-regression-9e4236f18091
Best regards,
--
Tamir Duberstein <tamird@gmail.com>
^ permalink raw reply related
* [GSoC PATCH v2 4/4] repo: add path.commondir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
To: git
Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>
In standard Git repositories, the Git directory and the common directory
are identical. However, in environments utilizing multiple worktrees, the
local working state ($GIT_DIR) is separated from the shared central data
($GIT_COMMON_DIR). Scripts require a reliable way to discover this shared
path.
Introduce `path.commondir.absolute` and `path.commondir.relative` keys
to `git repo info`. Similar to the `path.gitdir` keys, exposing explicit
format variants removes the ambiguity of default fallbacks. Both keys are
evaluated via the `format_path()` engine.
Insert the new keys into the `repo_info_field` array in lexicographical
order to maintain the integrity of binary search lookups.
Utilize the parameterized `test_repo_info_path` helper to validate the
worktree edge cases. This ensures that path resolution correctly respects
$GIT_COMMON_DIR when defined and safely falls back to $GIT_DIR otherwise.
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
Documentation/git-repo.adoc | 9 +++++++++
builtin/repo.c | 24 ++++++++++++++++++++++++
t/t1900-repo-info.sh | 7 +++++++
3 files changed, 40 insertions(+)
diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index a0dca7ce88..ed7d80c690 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -104,6 +104,15 @@ values that they return:
`object.format`::
The object format (hash algorithm) used in the repository.
+`path.commondir.absolute`::
+ The canonical absolute path to the Git repository's common
+ directory (the shared `.git` directory containing objects,
+ refs, and global configuration).
+
+`path.commondir.relative`::
+ The path to the Git repository's common directory relative to
+ the current working directory.
+
`path.gitdir.absolute`::
The canonical absolute path to the Git repository directory (the `.git` directory).
diff --git a/builtin/repo.c b/builtin/repo.c
index 6e97f6a0e4..27c8caff38 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -77,6 +77,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf)
return 0;
}
+static int get_path_commondir_absolute(struct repository *repo, struct strbuf *buf)
+{
+ const char *common_dir = repo_get_common_dir(repo);
+
+ if (!common_dir)
+ return error(_("unable to get common directory"));
+
+ format_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+ return 0;
+}
+
+static int get_path_commondir_relative(struct repository *repo, struct strbuf *buf)
+{
+ const char *common_dir = repo_get_common_dir(repo);
+
+ if (!common_dir)
+ return error(_("unable to get common directory"));
+
+ format_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+ return 0;
+}
+
static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf)
{
const char *git_dir = repo_get_git_dir(repo);
@@ -111,6 +133,8 @@ static const struct repo_info_field repo_info_field[] = {
{ "layout.bare", get_layout_bare },
{ "layout.shallow", get_layout_shallow },
{ "object.format", get_object_format },
+ { "path.commondir.absolute", get_path_commondir_absolute },
+ { "path.commondir.relative", get_path_commondir_relative },
{ "path.gitdir.absolute", get_path_gitdir_absolute },
{ "path.gitdir.relative", get_path_gitdir_relative },
{ "references.format", get_references_format },
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 0660b00bbc..21755d9d14 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -186,6 +186,13 @@ test_expect_success 'setup test repository layout for path fields' '
mkdir -p test-repo/sub
'
+test_expect_success 'setup custom-common for commondir tests' '
+ git init --bare test-repo/custom-common
+'
+
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/.git"' '../.git'
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/custom-common"' '../custom-common' 'GIT_COMMON_DIR="$(cd .. && pwd)/custom-common" GIT_DIR=../.git'
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/.git"' '../.git' 'GIT_DIR=../.git'
test_repo_info_path 'gitdir' 'echo "$(cd .. && pwd)/.git"' '../.git'
test_done
--
2.54.0
^ permalink raw reply related
* [GSoC PATCH v2 3/4] repo: add path.gitdir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
To: git
Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>
Scripts often need to locate the `.git` directory. While `git rev-parse`
provides this, it relies on command-line flags to dictate path formatting.
Introduce `path.gitdir.absolute` and `path.gitdir.relative` keys to
`git repo info`. Exposing separate format-specific keys instead of a base
`path.gitdir` key avoids default fallbacks and requires callers to state
their format requirements explicitly. Both keys use `format_path()` to
resolve paths.
To test these keys, introduce the `test_repo_info_path` helper in
`t/t1900-repo-info.sh`. The helper evaluates paths dynamically and accepts
environment variable prefixes. This prepares the test suite for future path
keys that depend on environment overrides, such as `commondir`.
Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
Documentation/git-repo.adoc | 6 ++++++
builtin/repo.c | 26 ++++++++++++++++++++++++++
t/t1900-repo-info.sh | 33 +++++++++++++++++++++++++++++++++
3 files changed, 65 insertions(+)
diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index 42262c1983..a0dca7ce88 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -104,6 +104,12 @@ values that they return:
`object.format`::
The object format (hash algorithm) used in the repository.
+`path.gitdir.absolute`::
+ The canonical absolute path to the Git repository directory (the `.git` directory).
+
+`path.gitdir.relative`::
+ The path to the Git repository directory relative to the current working directory.
+
`references.format`::
The reference storage format. The valid values are:
+
diff --git a/builtin/repo.c b/builtin/repo.c
index 71a5c1c29c..6e97f6a0e4 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -7,12 +7,14 @@
#include "hex.h"
#include "odb.h"
#include "parse-options.h"
+#include "path.h"
#include "path-walk.h"
#include "progress.h"
#include "quote.h"
#include "ref-filter.h"
#include "refs.h"
#include "revision.h"
+#include "setup.h"
#include "strbuf.h"
#include "string-list.h"
#include "shallow.h"
@@ -75,6 +77,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf)
return 0;
}
+static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf)
+{
+ const char *git_dir = repo_get_git_dir(repo);
+
+ if (!git_dir)
+ return error(_("unable to get git directory"));
+
+ format_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+ return 0;
+}
+
+static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf)
+{
+ const char *git_dir = repo_get_git_dir(repo);
+
+ if (!git_dir)
+ return error(_("unable to get git directory"));
+
+ format_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+ return 0;
+}
+
static int get_references_format(struct repository *repo, struct strbuf *buf)
{
strbuf_addstr(buf,
@@ -87,6 +111,8 @@ static const struct repo_info_field repo_info_field[] = {
{ "layout.bare", get_layout_bare },
{ "layout.shallow", get_layout_shallow },
{ "object.format", get_object_format },
+ { "path.gitdir.absolute", get_path_gitdir_absolute },
+ { "path.gitdir.relative", get_path_gitdir_relative },
{ "references.format", get_references_format },
};
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 39bb77dda0..0660b00bbc 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -155,4 +155,37 @@ test_expect_success 'git repo info -h shows only repo info usage' '
test_grep ! "git repo structure" actual
'
+test_repo_info_path () {
+ field_name=$1
+ expect_absolute_eval=$2
+ expect_relative=$3
+ env_prefix=$4
+
+ test_expect_success "query individual key: path.$field_name.absolute${env_prefix:+ ($env_prefix)}" '
+ (
+ cd test-repo/sub &&
+ expect_absolute=$(eval "$expect_absolute_eval") &&
+ echo "path.$field_name.absolute=$expect_absolute" >expect &&
+ eval "${env_prefix:+$env_prefix }git repo info \"path.$field_name.absolute\"" >actual &&
+ test_cmp expect actual
+ )
+ '
+
+ test_expect_success "query individual key: path.$field_name.relative${env_prefix:+ ($env_prefix)}" '
+ (
+ cd test-repo/sub &&
+ echo "path.$field_name.relative=$expect_relative" >expect &&
+ eval "${env_prefix:+$env_prefix }git repo info \"path.$field_name.relative\"" >actual &&
+ test_cmp expect actual
+ )
+ '
+}
+
+test_expect_success 'setup test repository layout for path fields' '
+ git init test-repo &&
+ mkdir -p test-repo/sub
+'
+
+test_repo_info_path 'gitdir' 'echo "$(cd .. && pwd)/.git"' '../.git'
+
test_done
--
2.54.0
^ permalink raw reply related
* followRemoteHEAD management question
From: Matt Hunter @ 2026-06-05 16:31 UTC (permalink / raw)
To: git; +Cc: Bence Ferdinandy
Hello git list,
In the past, I've preferred to run 'git remote set-head <name> -d' when
setting up a new repository, since I generally have an awareness of what
the remote default branch is, and I don't like seeing them in branch
listings or git-log annotations. They are especially noisy to me if I
have multiple remotes. It's possible this config is ill-advised - I
would love to be educated if so...
However, since b7f7d16562c3 (fetch: add configuration for set_head
behaviour), these changes are undone by every 'git fetch'.
The topic mentioned above (merged in a1f34d595503) adds a new
configuration key 'remote.<name>.followRemoteHEAD'. I'm assuming that
the intended use for followRemoteHEAD is really only in local /
per-repository config, since trying to apply it to my personal
.gitconfig has some odd behavior.
The <name> in the key template does not accept a wildcard, so I must
list out each of the common remote names I use across different
repositories. Since many of my repos don't actually have remotes
established for all of these names, they pick up a kind of half-baked
definition for each of them as git performs its config parsing. For
instance, a name will appear under 'git remote -v', but it won't
have any actual properties configured.
I'd like to add a line to my config somewhere that can globally restore
the old behavior in this context, eg:
git config --global remote.*.followRemoteHEAD never
instead of adding individual entries to each project's .git/config.
Is there another solution in place I've missed? If not, would there be
any opposition to a new key like 'remote.followRemoteHEAD' which serves
to provide a default value for any remote that doesn't have its own
'remote.<name>.followRemoteHEAD' key?
I've started scouting out changes to make for such a patch. It's not
ready yet, but I figured I would throw this question out in case an easy
answer can save the effort.
Thanks
^ permalink raw reply
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