* [PATCH v4 02/13] pack-objects: pass --objects with --path-walk
From: Derrick Stolee via GitGitGadget @ 2026-05-13 21:18 UTC (permalink / raw)
To: git
Cc: christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, me, newren, peff, ps,
Taylor Blau, Derrick Stolee, Derrick Stolee
In-Reply-To: <pull.2101.v4.git.1778707135.gitgitgadget@gmail.com>
From: Derrick Stolee <stolee@gmail.com>
When 'git pack-objects' has the --path-walk option enabled, it uses a
different set of revision walk parameters than normal. For one,
--objects was previously assumed by the path-walk API and could be
omitted. We also needed --boundary to allow discovering UNINTERESTING
objects to use as delta bases.
We will be updating the path-walk API soon to work with some filter
options. However, the revision machinery will trigger a fatal error:
fatal: object filtering requires --objects
The fix is easy: add the --objects option as an argument. This has no
effect on the path-walk API but does simplify the revision option
parsing for the objects filter.
We can remove the comment about "removing" the options because they were
never removed and instead not added. We still need to disable using
bitmaps.
Signed-off-by: Derrick Stolee <stolee@gmail.com>
---
builtin/pack-objects.c | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c
index dd2480a73d..4338962904 100644
--- a/builtin/pack-objects.c
+++ b/builtin/pack-objects.c
@@ -5190,10 +5190,7 @@ int cmd_pack_objects(int argc,
}
if (path_walk) {
strvec_push(&rp, "--boundary");
- /*
- * We must disable the bitmaps because we are removing
- * the --objects / --objects-edge[-aggressive] options.
- */
+ strvec_push(&rp, "--objects");
use_bitmap_index = 0;
} else if (thin) {
use_internal_rev_list = 1;
--
gitgitgadget
^ permalink raw reply related
* [PATCH v4 01/13] t5620: make test work with path-walk var
From: Derrick Stolee via GitGitGadget @ 2026-05-13 21:18 UTC (permalink / raw)
To: git
Cc: christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, me, newren, peff, ps,
Taylor Blau, Derrick Stolee, Derrick Stolee
In-Reply-To: <pull.2101.v4.git.1778707135.gitgitgadget@gmail.com>
From: Derrick Stolee <stolee@gmail.com>
The GIT_TEST_PACK_PATH_WALK test variable allows enabling the
--path-walk option to 'git pack-objects' by default. This sometimes
engages the warning that --path-walk is incompatible with the --filter
option. These tests in t5620 fail due to this warning over stderr in
this case. Disable this variable for this moment until these options
work together.
Signed-off-by: Derrick Stolee <stolee@gmail.com>
---
t/t5620-backfill.sh | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/t/t5620-backfill.sh b/t/t5620-backfill.sh
index 94f35ce190..e174290787 100755
--- a/t/t5620-backfill.sh
+++ b/t/t5620-backfill.sh
@@ -298,6 +298,9 @@ test_expect_success 'backfill with prefix pathspec' '
git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing &&
test_line_count = 48 missing &&
+ # If we enable --path-walk here, we will get a warning overs stderr
+ # due to incompatibilities with --filter.
+ GIT_TEST_PACK_PATH_WALK=0 \
git -C backfill-path backfill HEAD -- d/f 2>err &&
test_must_be_empty err &&
@@ -315,6 +318,9 @@ test_expect_success 'backfill with multiple pathspecs' '
git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing &&
test_line_count = 48 missing &&
+ # If we enable --path-walk here, we will get a warning overs stderr
+ # due to incompatibilities with --filter.
+ GIT_TEST_PACK_PATH_WALK=0 \
git -C backfill-path backfill HEAD -- d/f a 2>err &&
test_must_be_empty err &&
@@ -332,6 +338,9 @@ test_expect_success 'backfill with wildcard pathspec' '
git -C backfill-path rev-list --quiet --objects --missing=print HEAD >missing &&
test_line_count = 48 missing &&
+ # If we enable --path-walk here, we will get a warning overs stderr
+ # due to incompatibilities with --filter.
+ GIT_TEST_PACK_PATH_WALK=0 \
git -C backfill-path backfill HEAD -- "d/file.*.txt" 2>err &&
test_must_be_empty err &&
--
gitgitgadget
^ permalink raw reply related
* [PATCH v4 00/13] pack-objects: integrate --path-walk and some --filter options
From: Derrick Stolee via GitGitGadget @ 2026-05-13 21:18 UTC (permalink / raw)
To: git
Cc: christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, me, newren, peff, ps,
Taylor Blau, Derrick Stolee
In-Reply-To: <pull.2101.v3.git.1778523189.gitgitgadget@gmail.com>
NOTE: This series is based on en/backfill-fixes-and-edges.
The 'git pack-objects' command has a '--path-walk' option that uses the
path-walk API instead of a typical revision walk to group objects into
chunks by path name instead of relying solely on name-hashes to group
similar files together. (It also does a second compression pass looking for
better deltas after the first pass that is focused within chunks per path.)
The '--path-walk' feature was not previously integrated with the '--filter'
feature, so a warning would appear and disable the path-walk API when a
filter is given. This patch series integrates these together in the
following ways:
* --filter=blob:none updates the path-walk API options to skip blobs.
* --filter=blob:limit=<size> adds a scan to a list of blob objects to
remove objects that are too large.
* --filter=sparse:<oid> adds a scan to the chunks to validate that the
paths match the sparse-checkout patterns.
In particular, this last check is significantly faster than the previous
algorithm because it can check all objects at a given path simultaneously
instead of checking all sparse-checkout patterns for each discovered blob
object.
A subtlety must be added here, in that we must change how we mark an object
as "seen" during the path-walk. We may need to add an object to multiple
paths and only mark it as "seen" if it indeed matched the sparse-checkout
patterns as the path is accepted for emitting to the callback. This adds a
new filter that the "seen" objects must also be removed from later chunks to
avoid sending the same object as grouped to multiple chunks.
There's also a subtle detail here in that the path-walk API also prunes tree
paths based on cone-mode sparse-checkouts, to enable 'git backfill --sparse'
operating quickly for small sparse-checkout scopes. But the
--filter=sparse:<oid> feature doesn't prune trees!
As a future step, I do plan to recommend that we add a treesparse:<oid>
setting that does allow us to trim the tree set by cone-mode sparse
patterns. At the time that partial clone filters were being created, cone
mode sparse-checkout didn't exist and neither did the sparse index. Those
features together make a smaller tree set possible, assuming the user never
needs to change their scope. This would be a significant change so it is not
implemented here, though the git pack-objects integration would be quick
after this series completes.
Neither the sparse:<oid> or hypothetical treesparse:<oid> options are or
should necessarily be supported by Git servers. It's too expensive to
compute dynamically and it doesn't work well with reachability bitmaps. What
becomes possible with this change is that it becomes reasonably fast to
construct bundles with these filters that can bootstrap a working
environment with the full history of all files within a given
sparse-checkout scope.
Performance Results
===================
Since the '--path-walk' option is ignored in today's Git version when a
'--filter' is added, the performance matches the behavior without
'--path-walk'. For the tables below, you can compare the rows against each
other (time and then packfile size) for the mode without and then with
'--path-walk' as a representation of "before" and "after". (These tables are
repeated in the commit messages as new implementations improve specific
rows.)
I chose a number of open source repositories of various sizes and shapes:
git/git
=======
Test HEAD
-------------------------------------------------------------------
5315.2: repack (no filter) 27.73
5315.3: repack size (no filter) 250.6M
5315.4: repack (no filter, --path-walk) 35.19
5315.5: repack size (no filter, --path-walk) 220.1M
5315.6: repack (blob:none) 13.42
5315.7: repack size (blob:none) 137.6M
5315.8: repack (blob:none, --path-walk) 20.98
5315.9: repack size (blob:none, --path-walk) 115.2M
5315.10: repack (sparse:oid) 72.53
5315.11: repack size (sparse:oid) 187.5M
5315.12: repack (sparse:oid, --path-walk) 29.00
5315.13: repack size (sparse:oid, --path-walk) 161.0M
nodejs/node
===========
Test HEAD
--------------------------------------------------------------------
5315.2: repack (no filter) 75.53
5315.3: repack size (no filter) 0.9G
5315.4: repack (no filter, --path-walk) 80.54
5315.5: repack size (no filter, --path-walk) 885.7M
5315.6: repack (blob:none) 12.65
5315.7: repack size (blob:none) 148.6M
5315.8: repack (blob:none, --path-walk) 17.60
5315.9: repack size (blob:none, --path-walk) 134.6M
5315.10: repack (sparse:oid) 518.84
5315.11: repack size (sparse:oid) 153.4M
5315.12: repack (sparse:oid, --path-walk) 27.99
5315.13: repack size (sparse:oid, --path-walk) 139.4M
microsoft/fluentui
==================
Test HEAD
--------------------------------------------------------------------
5315.2: repack (no filter) 146.77
5315.3: repack size (no filter) 562.1M
5315.4: repack (no filter, --path-walk) 72.82
5315.5: repack size (no filter, --path-walk) 172.6M
5315.6: repack (blob:none) 4.84
5315.7: repack size (blob:none) 62.7M
5315.8: repack (blob:none, --path-walk) 5.19
5315.9: repack size (blob:none, --path-walk) 59.9M
5315.10: repack (sparse:oid) 59.95
5315.11: repack size (sparse:oid) 85.6M
5315.12: repack (sparse:oid, --path-walk) 15.16
5315.13: repack size (sparse:oid, --path-walk) 72.5M
microsoftdocs/azure-devops-docs
===============================
Test HEAD
---------------------------------------------------------------------
5315.2: repack (no filter) 4.41
5315.3: repack size (no filter) 1.6G
5315.4: repack (no filter, --path-walk) 6.00
5315.5: repack size (no filter, --path-walk) 1.6G
5315.6: repack (blob:none) 1.35
5315.7: repack size (blob:none) 60.0M
5315.8: repack (blob:none, --path-walk) 1.23
5315.9: repack size (blob:none, --path-walk) 60.0M
5315.10: repack (sparse:oid) 138.24
5315.11: repack size (sparse:oid) 84.4M
5315.12: repack (sparse:oid, --path-walk) 1.86
5315.13: repack size (sparse:oid, --path-walk) 84.4M
Performance conclusions
=======================
As seen in earlier series around the '--path-walk' feature, the space
savings can be valuable but is not always guaranteed. When the space savings
doesn't happen, then the time spent is generally slower because of the
two-pass mechanism. The microsoftdocs/azure-devops-docs repo demonstrates
this case quite clearly.
However, even in these cases the 'sparse:<oid>' filters are much faster
because of the ability to check an entire set of objects against the
sparse-checkout patterns only once.
Thanks, -Stolee
UPDATES IN V2
=============
* Rebased onto en/backfill-fixes-and-edges to properly integrate with the
incompatible rev-list options logic in that series.
* Updated documentation as behavior changes. Credit to Taylor Blau for
presenting these suggestions in his RFC [2].
* Added three patches of Taylor's to extend more filter options.
UPDATES IN V3
=============
Upon realizing that the tests were not passing with
GIT_TEST_PACK_PATH_WALK=1, I spent a lot of time reworking each patch to
pass all tests with that variable enabled. This led to a lot of meaningful
changes:
* A new patch updates t5620-backfill.sh because they are currently failing
due to a check for quiet stderr checks. These changes are reverted later
when the filters are integrated so the warning stops being written.
* I move the logic for the path-walk API emitting "directly requested"
objects (non-commits in the 'pending' list). This is substantial enough
to be its own patch.
* The filtering logic is pulled entirely within the path-walk API instead
of needing integration within builtin/pack-objects.c.
* The tree:0 filter had a lot to be desired when fetching missing objects,
so is substantially updated.
* The object-type filter requires a change to the typical direct-request
behavior, including a new 'strict_types' member that prevents ever
allowing objects against type.
* The combine filter needed better logic around multiple blob size limits,
to take the smaller of the two.
* The t6601-path-walk.sh script has many test updates to better reflect the
new behavior, as required by the other partial clone tests under
GIT_TEST_PACK_PATH_WALK=1.
* Doc updates for 'form' to 'forms' when multiple forms are supported.
I've also updated Taylor's bitmap-related patches into three commits on top
of this series (see [3]).
UPDATES IN V4
=============
Thanks, Taylor for the careful review.
* Several typos are fixed.
* The performance test is corrected for issues around piping Git commands
and made more robust to the existence of submodules.
* BIG: The tree:0 patch is significantly updated in this version. Taylor
correctly smelled a problem with the new logic to emit the /tagged-trees
object set, and that signaled that those trees were previously never
emitted. I update the test to demonstrate that changing the data shape
(including tagged trees that are otherwise-unreachable) doesn't change
the test behavior, signaling a bug. The behavior change details all the
complexities of visiting only directly-requested trees under a tree:0
filter and recursing on all trees in other cases.
P.S. I've CC'd the folks who were on the original path-walk feature thread
[1]
[1]
https://lore.kernel.org/git/pull.1819.git.1741571455.gitgitgadget@gmail.com/
[2] https://lore.kernel.org/git/cover.1777853408.git.me@ttaylorr.com/
[3]
https://github.com/derrickstolee/git/compare/path-walk-filters...derrickstolee:git:path-walk-bitmaps
Derrick Stolee (10):
t5620: make test work with path-walk var
pack-objects: pass --objects with --path-walk
t/perf: add pack-objects filter and path-walk benchmark
path-walk: always emit directly-requested objects
path-walk: support blobless filter
backfill: die on incompatible filter options
path-walk: support blob size limit filter
path-walk: add pl_sparse_trees to control tree pruning
pack-objects: support sparse:oid filter with path-walk
t6601: tag otherwise-unreachable trees
Taylor Blau (3):
path-walk: support `tree:0` filter
path-walk: support `object:type` filter
path-walk: support `combine` filter
Documentation/git-backfill.adoc | 4 +
Documentation/git-pack-objects.adoc | 8 +-
Documentation/technical/api-path-walk.adoc | 7 +
builtin/backfill.c | 8 +-
builtin/pack-objects.c | 23 +-
path-walk.c | 270 ++++++++--
path-walk.h | 31 ++
t/helper/test-path-walk.c | 17 +-
t/perf/p5315-pack-objects-filter.sh | 131 +++++
t/t5317-pack-objects-filter-objects.sh | 125 +++++
t/t5620-backfill.sh | 8 +
t/t6601-path-walk.sh | 572 +++++++++++++++++++--
12 files changed, 1126 insertions(+), 78 deletions(-)
create mode 100755 t/perf/p5315-pack-objects-filter.sh
base-commit: a1ad4a0fca14cdeb55ab9fb065551b15cafa8a4f
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2101%2Fderrickstolee%2Fpath-walk-filters-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2101/derrickstolee/path-walk-filters-v4
Pull-Request: https://github.com/gitgitgadget/git/pull/2101
Range-diff vs v3:
1: 0840110116 = 1: 0840110116 t5620: make test work with path-walk var
2: 5cc6383380 ! 2: d7c87545f3 pack-objects: pass --objects with --path-walk
@@ Commit message
pack-objects: pass --objects with --path-walk
When 'git pack-objects' has the --path-walk option enabled, it uses a
- different set of revision walk parameters than normal. For once,
- --objects was previously assumed by the path-walk API and was not needed
- to be added. We also needed --boundary to allow discovering
- UNINTERESTING objects to use as delta bases.
+ different set of revision walk parameters than normal. For one,
+ --objects was previously assumed by the path-walk API and could be
+ omitted. We also needed --boundary to allow discovering UNINTERESTING
+ objects to use as delta bases.
We will be updating the path-walk API soon to work with some filter
options. However, the revision machinery will trigger a fatal error:
3: 77329cf8f4 ! 3: fb8a0f9c43 t/perf: add pack-objects filter and path-walk benchmark
@@ t/perf/p5315-pack-objects-filter.sh (new)
+ # directories at evenly-spaced positions so the choice is stable
+ # and scales to repos of any shape.
+
-+ git ls-tree -d --name-only HEAD >top-dirs &&
++ git ls-tree -d HEAD >top-entries &&
++ grep "^040000" top-entries |
++ awk "{print \$4;}" >top-dirs &&
+ top_nr=$(wc -l <top-dirs) &&
+
+ >depth2-dirs &&
+ while read tdir
+ do
-+ git ls-tree -d --name-only "HEAD:$tdir" 2>/dev/null |
-+ sed "s|^|$tdir/|" >>depth2-dirs || return 1
-+ done <top-dirs &&
++ git ls-tree -d --name-only "HEAD:$tdir" 2>/dev/null || return 1
++ done <top-dirs >depth2-dirs.raw &&
++ sed "s|^|$tdir/|" <depth2-dirs.raw >depth2-dirs &&
+
+ d2_nr=$(wc -l <depth2-dirs) &&
+
4: 50933cccf5 ! 4: e77c8a6bbc path-walk: always emit directly-requested objects
@@ path-walk.c: static int add_tree_entries(struct path_walk_context *ctx,
+ */
+static int path_is_for_direct_objects(const char *path)
+{
++ ASSERT(path);
+ return path[0] == '/';
+}
+
5: b2deb7f819 ! 5: f4904f81e0 path-walk: support blobless filter
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1
-`--use-bitmap-index` option will be ignored in the presence of
-`--path-walk.`
+Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
-+ignored in the presence of `--path-walk`. Whe `--path-walk` option
++ignored in the presence of `--path-walk`. The `--path-walk` option
+supports the `--filter=<spec>` form `blob:none`.
6: da191e29e6 = 6: f37467e46f backfill: die on incompatible filter options
7: a1ab70405d ! 7: 133c1b156c path-walk: support blob size limit filter
@@ Documentation/git-pack-objects.adoc
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1`.
+
Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
- ignored in the presence of `--path-walk`. Whe `--path-walk` option
+ ignored in the presence of `--path-walk`. The `--path-walk` option
-supports the `--filter=<spec>` form `blob:none`.
+supports the `--filter=<spec>` forms `blob:none` and `blob:limit=<n>`.
8: 2360a5be81 = 8: 0f517be8e3 path-walk: add pl_sparse_trees to control tree pruning
9: d9f5a98830 ! 9: b4dc09ab69 pack-objects: support sparse:oid filter with path-walk
@@ Documentation/git-pack-objects.adoc
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1`.
+
Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
- ignored in the presence of `--path-walk`. Whe `--path-walk` option
+ ignored in the presence of `--path-walk`. The `--path-walk` option
-supports the `--filter=<spec>` forms `blob:none` and `blob:limit=<n>`.
+supports the `--filter=<spec>` forms `blob:none`, `blob:limit=<n>`, and
+`sparse:<oid>`.
-: ---------- > 10: 0b1eed0790 t6601: tag otherwise-unreachable trees
10: c9efff0ea8 ! 11: b23244c4c2 path-walk: support `tree:0` filter
@@ Commit message
at all under `tree:0`, which matches the intended behavior of the
filter.
+ However, this requires first fixing some issues with how the path-walk
+ API handles directly-requested trees _and_ trees requested through
+ lightweight tags. These changes create substantial updates to
+ t6601-path-walk.sh, which the previous change highlighted as a problem
+ by tagging otherwise-unreachable trees and having them not appear in the
+ output.
+
Non-zero tree-depth filters are not supported. Those depend on the depth
at which a tree is visited, which is a path-walk concept the filter
machinery doesn't currently share with the path-walk API. Reject them in
@@ Documentation/git-pack-objects.adoc
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1`.
+
Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
- ignored in the presence of `--path-walk`. Whe `--path-walk` option
+ ignored in the presence of `--path-walk`. The `--path-walk` option
-supports the `--filter=<spec>` forms `blob:none`, `blob:limit=<n>`, and
-`sparse:<oid>`.
+supports the `--filter=<spec>` forms `blob:none`, `blob:limit=<n>`,
@@ path-walk.c: static int walk_path(struct path_walk_context *ctx,
- /* Expand data for children. */
- if (list->type == OBJ_TREE) {
-+ /* Expand data for children, unless this is a direct-object path. */
-+ if (list->type == OBJ_TREE && !path_is_for_direct_objects(path)) {
++ /*
++ * Expand tree children, except when the set is directly requested
++ * _and_ we are otherwise filtering out trees.
++ */
++ if (list->type == OBJ_TREE &&
++ (!path_is_for_direct_objects(path) || ctx->info->trees)) {
++ /* Use root path if expanding from tagged/direct trees. */
++ const char *expand_path = !strcmp(path, "/tagged-trees")
++ ? root_path : path;
for (size_t i = 0; i < list->oids.nr; i++) {
ret |= add_tree_entries(ctx,
- path,
+- path,
++ expand_path,
+ &list->oids.oid[i]);
+ }
+ }
@@ path-walk.c: static int setup_pending_objects(struct path_walk_info *info,
{
struct type_and_oid_list *tags = NULL;
struct type_and_oid_list *tagged_blobs = NULL;
+- struct type_and_oid_list *root_tree_list = NULL;
+ struct type_and_oid_list *tagged_trees = NULL;
- struct type_and_oid_list *root_tree_list = NULL;
if (info->tags)
CALLOC_ARRAY(tags, 1);
CALLOC_ARRAY(tagged_blobs, 1);
-+ if (!info->trees)
-+ CALLOC_ARRAY(tagged_trees, 1);
- root_tree_list = strmap_get(&ctx->paths_to_lists, root_path);
+- root_tree_list = strmap_get(&ctx->paths_to_lists, root_path);
++ CALLOC_ARRAY(tagged_trees, 1);
/*
+ * Pending objects include:
@@ path-walk.c: static int setup_pending_objects(struct path_walk_info *info,
switch (obj->type) {
case OBJ_TREE:
- if (pending->path) {
-+ if (tagged_trees) {
-+ /*
-+ * Trees are disabled but pending trees
-+ * should still be emitted. Collect them
-+ * into a "/tagged-trees" list that
-+ * bypasses the object type filter.
-+ */
-+ oid_array_append(&tagged_trees->oids, &obj->oid);
-+ } else if (pending->path) {
- char *path = *pending->path ? xstrfmt("%s/", pending->path)
- : xstrdup("");
+- char *path = *pending->path ? xstrfmt("%s/", pending->path)
+- : xstrdup("");
++ if (pending->path && *pending->path) {
++ char *path = xstrfmt("%s/", pending->path);
add_path_to_list(ctx, path, OBJ_TREE, &obj->oid, 1);
+ free(path);
++ } else if (!pending->path || !info->trees) {
++ oid_array_append(&tagged_trees->oids, &obj->oid);
+ } else {
+- /* assume a root tree, such as a lightweight tag. */
+- oid_array_append(&root_tree_list->oids, &obj->oid);
++ add_path_to_list(ctx, root_path, OBJ_TREE,
++ &obj->oid, 1);
+ }
+ break;
+
@@ path-walk.c: static int setup_pending_objects(struct path_walk_info *info,
free(tagged_blobs);
}
@@ path-walk.c: static int prepare_filters(struct path_walk_info *info,
struct object_id sparse_oid;
## t/t6601-path-walk.sh ##
+@@ t/t6601-path-walk.sh: test_expect_success 'all' '
+ 3:tree::$(git rev-parse base^{tree})
+ 3:tree::$(git rev-parse base~1^{tree})
+ 3:tree::$(git rev-parse base~2^{tree})
+- 3:tree::$(git rev-parse refs/tags/tree-tag^{})
+- 3:tree::$(git rev-parse refs/tags/tree-tag2^{})
+ 4:blob:a:$(git rev-parse base~2:a)
+- 5:blob:file2:$(git rev-parse refs/tags/tree-tag2^{}:file2)
+- 6:tree:a/:$(git rev-parse base:a)
+- 7:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
+- 8:blob:child/file:$(git rev-parse refs/tags/tree-tag:child/file)
+- 9:tree:left/:$(git rev-parse base:left)
+- 9:tree:left/:$(git rev-parse base~2:left)
+- 10:blob:left/b:$(git rev-parse base~2:left/b)
+- 10:blob:left/b:$(git rev-parse base:left/b)
+- 11:tree:right/:$(git rev-parse topic:right)
+- 11:tree:right/:$(git rev-parse base~1:right)
+- 11:tree:right/:$(git rev-parse base~2:right)
+- 12:blob:right/c:$(git rev-parse base~2:right/c)
+- 12:blob:right/c:$(git rev-parse topic:right/c)
+- 13:blob:right/d:$(git rev-parse base~1:right/d)
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 6:blob:file2:$(git rev-parse refs/tags/tree-tag2^{}:file2)
++ 7:tree:a/:$(git rev-parse base:a)
++ 8:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 9:blob:child/file:$(git rev-parse refs/tags/tree-tag:child/file)
++ 10:tree:left/:$(git rev-parse base:left)
++ 10:tree:left/:$(git rev-parse base~2:left)
++ 11:blob:left/b:$(git rev-parse base~2:left/b)
++ 11:blob:left/b:$(git rev-parse base:left/b)
++ 12:tree:right/:$(git rev-parse topic:right)
++ 12:tree:right/:$(git rev-parse base~1:right)
++ 12:tree:right/:$(git rev-parse base~2:right)
++ 13:blob:right/c:$(git rev-parse base~2:right/c)
++ 13:blob:right/c:$(git rev-parse topic:right/c)
++ 14:blob:right/d:$(git rev-parse base~1:right/d)
+ blobs:10
+ commits:4
+ tags:7
+@@ t/t6601-path-walk.sh: test_expect_success 'all, blob:none filter' '
+ 3:tree::$(git rev-parse base^{tree})
+ 3:tree::$(git rev-parse base~1^{tree})
+ 3:tree::$(git rev-parse base~2^{tree})
+- 3:tree::$(git rev-parse refs/tags/tree-tag^{})
+- 3:tree::$(git rev-parse refs/tags/tree-tag2^{})
+- 4:tree:a/:$(git rev-parse base:a)
+- 5:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
+- 6:tree:left/:$(git rev-parse base:left)
+- 6:tree:left/:$(git rev-parse base~2:left)
+- 7:tree:right/:$(git rev-parse topic:right)
+- 7:tree:right/:$(git rev-parse base~1:right)
+- 7:tree:right/:$(git rev-parse base~2:right)
++ 4:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 4:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 5:tree:a/:$(git rev-parse base:a)
++ 6:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 7:tree:left/:$(git rev-parse base:left)
++ 7:tree:left/:$(git rev-parse base~2:left)
++ 8:tree:right/:$(git rev-parse topic:right)
++ 8:tree:right/:$(git rev-parse base~1:right)
++ 8:tree:right/:$(git rev-parse base~2:right)
+ blobs:2
+ commits:4
+ tags:7
+@@ t/t6601-path-walk.sh: test_expect_success 'all, blob:limit=0 filter' '
+ 3:tree::$(git rev-parse base^{tree})
+ 3:tree::$(git rev-parse base~1^{tree})
+ 3:tree::$(git rev-parse base~2^{tree})
+- 3:tree::$(git rev-parse refs/tags/tree-tag^{})
+- 3:tree::$(git rev-parse refs/tags/tree-tag2^{})
+- 4:tree:a/:$(git rev-parse base:a)
+- 5:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
+- 6:tree:left/:$(git rev-parse base:left)
+- 6:tree:left/:$(git rev-parse base~2:left)
+- 7:tree:right/:$(git rev-parse topic:right)
+- 7:tree:right/:$(git rev-parse base~1:right)
+- 7:tree:right/:$(git rev-parse base~2:right)
++ 4:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 4:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 5:tree:a/:$(git rev-parse base:a)
++ 6:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 7:tree:left/:$(git rev-parse base:left)
++ 7:tree:left/:$(git rev-parse base~2:left)
++ 8:tree:right/:$(git rev-parse topic:right)
++ 8:tree:right/:$(git rev-parse base~1:right)
++ 8:tree:right/:$(git rev-parse base~2:right)
+ blobs:2
+ commits:4
+ tags:7
+@@ t/t6601-path-walk.sh: test_expect_success 'all, blob:limit=3 filter' '
+ 3:tree::$(git rev-parse base^{tree})
+ 3:tree::$(git rev-parse base~1^{tree})
+ 3:tree::$(git rev-parse base~2^{tree})
+- 3:tree::$(git rev-parse refs/tags/tree-tag^{})
+- 3:tree::$(git rev-parse refs/tags/tree-tag2^{})
+ 4:blob:a:$(git rev-parse base~2:a)
+- 5:tree:a/:$(git rev-parse base:a)
+- 6:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
+- 7:tree:left/:$(git rev-parse base:left)
+- 7:tree:left/:$(git rev-parse base~2:left)
+- 8:blob:left/b:$(git rev-parse base~2:left/b)
+- 9:tree:right/:$(git rev-parse topic:right)
+- 9:tree:right/:$(git rev-parse base~1:right)
+- 9:tree:right/:$(git rev-parse base~2:right)
+- 10:blob:right/c:$(git rev-parse base~2:right/c)
+- 11:blob:right/d:$(git rev-parse base~1:right/d)
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 6:tree:a/:$(git rev-parse base:a)
++ 7:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 8:tree:left/:$(git rev-parse base:left)
++ 8:tree:left/:$(git rev-parse base~2:left)
++ 9:blob:left/b:$(git rev-parse base~2:left/b)
++ 10:tree:right/:$(git rev-parse topic:right)
++ 10:tree:right/:$(git rev-parse base~1:right)
++ 10:tree:right/:$(git rev-parse base~2:right)
++ 11:blob:right/c:$(git rev-parse base~2:right/c)
++ 12:blob:right/d:$(git rev-parse base~1:right/d)
+ blobs:6
+ commits:4
+ tags:7
@@ t/t6601-path-walk.sh: test_expect_success 'all, blob:limit=3 filter' '
test_cmp_sorted expect out
'
@@ t/t6601-path-walk.sh: test_expect_success 'all, blob:limit=3 filter' '
test_expect_success 'setup sparse filter blob' '
# Cone-mode patterns: include root, exclude all dirs, include left/
cat >patterns <<-\EOF &&
+@@ t/t6601-path-walk.sh: test_expect_success 'all, sparse:oid filter' '
+ 3:tree::$(git rev-parse base^{tree})
+ 3:tree::$(git rev-parse base~1^{tree})
+ 3:tree::$(git rev-parse base~2^{tree})
+- 3:tree::$(git rev-parse refs/tags/tree-tag^{})
+- 3:tree::$(git rev-parse refs/tags/tree-tag2^{})
+ 4:blob:a:$(git rev-parse base~2:a)
+- 5:blob:file2:$(git rev-parse refs/tags/tree-tag2^{}:file2)
+- 6:tree:a/:$(git rev-parse base:a)
+- 7:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
+- 8:tree:left/:$(git rev-parse base:left)
+- 8:tree:left/:$(git rev-parse base~2:left)
+- 9:blob:left/b:$(git rev-parse base~2:left/b)
+- 9:blob:left/b:$(git rev-parse base:left/b)
+- 10:tree:right/:$(git rev-parse topic:right)
+- 10:tree:right/:$(git rev-parse base~1:right)
+- 10:tree:right/:$(git rev-parse base~2:right)
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 5:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 6:blob:file2:$(git rev-parse refs/tags/tree-tag2^{}:file2)
++ 7:tree:a/:$(git rev-parse base:a)
++ 8:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 9:tree:left/:$(git rev-parse base:left)
++ 9:tree:left/:$(git rev-parse base~2:left)
++ 10:blob:left/b:$(git rev-parse base~2:left/b)
++ 10:blob:left/b:$(git rev-parse base:left/b)
++ 11:tree:right/:$(git rev-parse topic:right)
++ 11:tree:right/:$(git rev-parse base~1:right)
++ 11:tree:right/:$(git rev-parse base~2:right)
+ blobs:6
+ commits:4
+ tags:7
11: b221ea4712 ! 12: 7e1e503361 path-walk: support `object:type` filter
@@ Commit message
## Documentation/git-pack-objects.adoc ##
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1`.
Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
- ignored in the presence of `--path-walk`. Whe `--path-walk` option
+ ignored in the presence of `--path-walk`. The `--path-walk` option
supports the `--filter=<spec>` forms `blob:none`, `blob:limit=<n>`,
-`tree:0`, and `sparse:<oid>`.
+`tree:0`, `object:type=<type>`, and `sparse:<oid>`.
@@ t/t6601-path-walk.sh: test_expect_success 'tree:1 filter is rejected' '
+ 0:tree::$(git rev-parse base^{tree})
+ 0:tree::$(git rev-parse base~1^{tree})
+ 0:tree::$(git rev-parse base~2^{tree})
-+ 0:tree::$(git rev-parse refs/tags/tree-tag^{})
-+ 0:tree::$(git rev-parse refs/tags/tree-tag2^{})
-+ 1:tree:a/:$(git rev-parse base:a)
-+ 2:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
-+ 3:tree:left/:$(git rev-parse base:left)
-+ 3:tree:left/:$(git rev-parse base~2:left)
-+ 4:tree:right/:$(git rev-parse topic:right)
-+ 4:tree:right/:$(git rev-parse base~1:right)
-+ 4:tree:right/:$(git rev-parse base~2:right)
++ 1:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag^{})
++ 1:tree:/tagged-trees:$(git rev-parse refs/tags/tree-tag2^{})
++ 2:tree:a/:$(git rev-parse base:a)
++ 3:tree:child/:$(git rev-parse refs/tags/tree-tag:child)
++ 4:tree:left/:$(git rev-parse base:left)
++ 4:tree:left/:$(git rev-parse base~2:left)
++ 5:tree:right/:$(git rev-parse topic:right)
++ 5:tree:right/:$(git rev-parse base~1:right)
++ 5:tree:right/:$(git rev-parse base~2:right)
+ blobs:0
+ commits:0
+ tags:0
12: 16bd3c3161 ! 13: a615b1a707 path-walk: support `combine` filter
@@ Commit message
## Documentation/git-pack-objects.adoc ##
@@ Documentation/git-pack-objects.adoc: will be automatically changed to version `1`.
Incompatible with `--delta-islands`. The `--use-bitmap-index` option is
- ignored in the presence of `--path-walk`. Whe `--path-walk` option
+ ignored in the presence of `--path-walk`. The `--path-walk` option
supports the `--filter=<spec>` forms `blob:none`, `blob:limit=<n>`,
-`tree:0`, `object:type=<type>`, and `sparse:<oid>`.
+`tree:0`, `object:type=<type>`, and `sparse:<oid>`. These supported filter
--
gitgitgadget
^ permalink raw reply
* Re: [PATCH v3 10/12] path-walk: support `tree:0` filter
From: Derrick Stolee @ 2026-05-13 19:46 UTC (permalink / raw)
To: Taylor Blau, Taylor Blau via GitGitGadget
Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agKFRV4TjInVOCaO@nand.local>
On 5/11/2026 9:41 PM, Taylor Blau wrote:
> On Mon, May 11, 2026 at 06:13:07PM +0000, Taylor Blau via GitGitGadget wrote:
>> @@ -534,6 +545,18 @@ static int setup_pending_objects(struct path_walk_info *info,
>> free(tagged_blobs);
>> }
>> }
>> + if (tagged_trees) {
>> + if (tagged_trees->oids.nr) {
>> + const char *tagged_tree_path = "/tagged-trees";
>> + tagged_trees->type = OBJ_TREE;
>> + tagged_trees->maybe_interesting = 1;
>> + strmap_put(&ctx->paths_to_lists, tagged_tree_path, tagged_trees);
>> + push_to_stack(ctx, tagged_tree_path);
>> + } else {
>> + oid_array_clear(&tagged_trees->oids);
>> + free(tagged_trees);
>> + }
>> + }
>> if (tags) {
>> if (tags->oids.nr) {
>> const char *tag_path = "/tags";
>
> It looks like there is some prior art here for enumerating a sentinel
> path for "/tags", but I am curious why we did the same for
> directly-listed trees in the presence of --filter=tree:0.
It turns out that this confusion is actually due to some major
flaws in how the path-walk API was treating tagged trees. The
test setup didn't catch this because the tagged trees were also
root trees from commits, so this wasn't caught before.
This means that in the next version, there will be a setup
patch that updates the test setup (and demonstrates the error
that those changes don't impact the rest of the tests) but
the new version of this patch will be substantial.
Thanks,
-Stolee
^ permalink raw reply
* [PATCH v9 3/5] branch: add --prune-merged <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Delete the local branches that --forked <remote> would list, but
only those whose tip is reachable from their configured upstream
remote-tracking branch (branch.<name>.merge): the work has already
landed on the upstream it tracks, so the local copy is no longer
needed.
A branch whose upstream no longer resolves locally is left alone --
its disappearance is not, on its own, evidence that the work was
integrated. With --force, skip the reachability check and delete
every branch in the candidate set. The currently checked-out
branch in any worktree is always preserved, as is the local branch
that mirrors <remote>'s default branch.
Reachability is read from whatever the remote-tracking refs say
locally, so the natural workflow is
git fetch <remote>
git branch --prune-merged <remote>
with no implicit cleanup driven by fetch itself.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 19 +++++
builtin/branch.c | 143 +++++++++++++++++++++++++++++-----
t/t3200-branch.sh | 83 ++++++++++++++++++++
3 files changed, 226 insertions(+), 19 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5773104cd3..375a0a68da 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <remote>...
+git branch --prune-merged <remote>...
DESCRIPTION
-----------
@@ -211,6 +212,24 @@ Each _<remote>_ may be either the name of a configured remote
`refs/remotes/origin/*` ref) or a specific remote-tracking branch
(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
+`--prune-merged`::
+ Delete the local branches that `--forked` would list for
+ the same _<remote>_ arguments, but only those whose tip is
+ reachable from their configured upstream remote-tracking
+ branch (`branch.<name>.merge`). In other words: the work on
+ the branch has already landed on the upstream it tracks, so
+ the local copy is no longer needed.
++
+Run `git fetch` first so the upstream remote-tracking branches
+reflect the current state of _<remote>_; reachability is checked
+against whatever the remote-tracking refs say locally.
++
+A branch whose upstream no longer resolves locally is left alone
+(its disappearance is not, on its own, evidence that the work was
+integrated). The currently checked-out branch in any worktree is
+always preserved, as is the local branch that mirrors _<remote>_'s
+default branch.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1941f8a9ad..6fe2ffd7e8 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -21,6 +21,7 @@
#include "branch.h"
#include "path.h"
#include "string-list.h"
+#include "strvec.h"
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
@@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,
* any of the following code, but during the transition period,
* a gentle reminder is in order.
*/
- if (head_rev != reference_rev) {
- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+ if (head_rev && head_rev != reference_rev) {
+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
if (expect < 0)
exit(128);
if (expect == merged)
@@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
-static int delete_branches(int argc, const char **argv, int force, int kinds,
+static int delete_branches(int argc, const char **argv,
+ int no_head_fallback,
+ int force, int kinds,
int quiet, int warn_only, int *n_not_merged)
{
struct commit *head_rev = NULL;
@@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
- if (!force)
+ if (!force && !no_head_fallback)
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force, warn_only, n_not_merged)) {
+ check_branch_commit(bname.buf, name, &oid, head_rev,
+ kinds, force, warn_only, n_not_merged)) {
if (!warn_only)
ret = 1;
goto next;
@@ -753,36 +756,131 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
return 0;
}
-static int list_forked_branches(int argc, const char **argv)
+static void collect_default_branch_refs(const struct string_list *remote_names,
+ struct string_list *out)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list_item *item;
+
+ for_each_string_list_item(item, remote_names) {
+ struct strbuf head = STRBUF_INIT;
+ const char *target;
+
+ strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
+ target = refs_resolve_ref_unsafe(refs, head.buf,
+ RESOLVE_REF_NO_RECURSE,
+ NULL, NULL);
+ if (target && starts_with(target, "refs/remotes/"))
+ string_list_insert(out, target);
+ strbuf_release(&head);
+ }
+}
+
+static void collect_forked_set(int argc, const char **argv,
+ struct string_list *protected_default_refs,
+ struct string_list *out)
{
struct string_list remote_names = STRING_LIST_INIT_NODUP;
struct string_list tracking_refs = STRING_LIST_INIT_DUP;
- struct string_list out = STRING_LIST_INIT_DUP;
- struct string_list_item *item;
struct forked_cb cb = {
.remote_names = &remote_names,
.tracking_refs = &tracking_refs,
- .out = &out,
+ .out = out,
};
- if (!argc)
- die(_("--forked requires at least one <remote>"));
-
parse_forked_args(argc, argv, &remote_names, &tracking_refs);
refs_for_each_branch_ref(get_main_ref_store(the_repository),
collect_forked_branch, &cb);
- string_list_sort(&out);
- for_each_string_list_item(item, &out)
- puts(item->string);
+ string_list_sort(out);
+
+ if (protected_default_refs)
+ collect_default_branch_refs(&remote_names, protected_default_refs);
string_list_clear(&remote_names, 0);
string_list_clear(&tracking_refs, 0);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, NULL, &out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
string_list_clear(&out, 0);
return 0;
}
+static int prune_merged_branches(int argc, const char **argv, int quiet)
+{
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int n_not_merged = 0;
+ int ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, &protected_default_refs, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
+ struct strbuf full = STRBUF_INIT;
+ struct branch *branch;
+ const char *upstream;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ if (branch_checked_out(full.buf)) {
+ strbuf_release(&full);
+ continue;
+ }
+ strbuf_release(&full);
+
+ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream ||
+ !refs_ref_exists(get_main_ref_store(the_repository),
+ upstream))
+ continue;
+ if (string_list_has_string(&protected_default_refs, upstream)) {
+ const char *leaf = strrchr(upstream, '/');
+ if (leaf && !strcmp(leaf + 1, short_name))
+ continue;
+ }
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ 1, 0,
+ FILTER_REFS_BRANCHES, quiet,
+ 1, &n_not_merged);
+
+ if (n_not_merged && !quiet)
+ fprintf(stderr,
+ Q_("Skipped %d branch that is not fully merged; "
+ "delete it with 'git branch -D' if you are sure.\n",
+ "Skipped %d branches that are not fully merged; "
+ "delete them with 'git branch -D' if you are sure.\n",
+ n_not_merged),
+ n_not_merged);
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
+ string_list_clear(&protected_default_refs, 0);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -825,6 +923,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
int forked = 0;
+ int prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -880,6 +979,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "forked", &forked,
N_("list local branches forked from the given <remote>s")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -924,7 +1025,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && !forked && argc == 0)
+ !show_current && !unset_upstream && !forked && !prune_merged &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -933,7 +1035,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream + !!forked;
+ !!unset_upstream + !!forked + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -971,12 +1073,15 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
quiet, 0, NULL);
goto out;
} else if (forked) {
ret = list_forked_branches(argc, argv);
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 24a3ec44ee..94ea493aee 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,4 +1771,87 @@ test_expect_success '--forked requires at least one <remote>' '
test_grep "at least one <remote>" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged origin &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged spares branches with un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep "Skipped 1 branch" err &&
+ test_grep "git branch -D" 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 skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged origin &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged origin &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares the local default branch' '
+ test_when_finished "rm -rf pm-default" &&
+ git clone pm-upstream pm-default &&
+ git -C pm-default checkout --detach &&
+ git -C pm-default branch --prune-merged origin &&
+ git -C pm-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
+ test_when_finished "rm -rf pm-default-alias" &&
+ git clone pm-upstream pm-default-alias &&
+ git -C pm-default-alias branch --track trunk origin/main &&
+ git -C pm-default-alias checkout --detach &&
+ git -C pm-default-alias branch --prune-merged origin &&
+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v9 5/5] branch: add --all-remotes flag
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Combined with --forked or --prune-merged, --all-remotes acts on
every configured remote, in addition to any explicit <remote>
arguments. Used alone, it errors out.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 9 +++++--
builtin/branch.c | 41 +++++++++++++++++++++++---------
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 81 insertions(+), 13 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 7435d38447..3cf3bf033f 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --forked <remote>...
-git branch --prune-merged <remote>...
+git branch --forked (<remote>... | --all-remotes)
+git branch --prune-merged (<remote>... | --all-remotes)
DESCRIPTION
-----------
@@ -231,6 +231,11 @@ always preserved, as is any branch with `branch.<name>.pruneMerged`
set to `false`, and the local branch that mirrors _<remote>_'s
default branch.
+`--all-remotes`::
+ With `--forked` or `--prune-merged`, act on every
+ configured remote in addition to any explicit _<remote>_
+ arguments.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index bc4f4a4a18..7d45bada45 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -687,6 +687,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static int collect_remote_name(struct remote *remote, void *cb_data)
+{
+ struct string_list *remote_names = cb_data;
+ string_list_insert(remote_names, remote->name);
+ return 0;
+}
+
static void parse_forked_args(int argc, const char **argv,
struct string_list *remote_names,
struct string_list *tracking_refs)
@@ -776,7 +783,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,
}
}
-static void collect_forked_set(int argc, const char **argv,
+static void collect_forked_set(int argc, const char **argv, int all_remotes,
struct string_list *protected_default_refs,
struct string_list *out)
{
@@ -789,6 +796,8 @@ static void collect_forked_set(int argc, const char **argv,
};
parse_forked_args(argc, argv, &remote_names, &tracking_refs);
+ if (all_remotes)
+ for_each_remote(collect_remote_name, &remote_names);
refs_for_each_branch_ref(get_main_ref_store(the_repository),
collect_forked_branch, &cb);
@@ -802,15 +811,15 @@ static void collect_forked_set(int argc, const char **argv,
string_list_clear(&tracking_refs, 0);
}
-static int list_forked_branches(int argc, const char **argv)
+static int list_forked_branches(int argc, const char **argv, int all_remotes)
{
struct string_list out = STRING_LIST_INIT_DUP;
struct string_list_item *item;
- if (!argc)
- die(_("--forked requires at least one <remote>"));
+ if (!argc && !all_remotes)
+ die(_("--forked requires at least one <remote> or --all-remotes"));
- collect_forked_set(argc, argv, NULL, &out);
+ collect_forked_set(argc, argv, all_remotes, NULL, &out);
for_each_string_list_item(item, &out)
puts(item->string);
@@ -818,7 +827,8 @@ static int list_forked_branches(int argc, const char **argv)
return 0;
}
-static int prune_merged_branches(int argc, const char **argv, int quiet)
+static int prune_merged_branches(int argc, const char **argv,
+ int all_remotes, int quiet)
{
struct string_list candidates = STRING_LIST_INIT_DUP;
struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
@@ -827,10 +837,11 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
int n_not_merged = 0;
int ret = 0;
- if (!argc)
- die(_("--prune-merged requires at least one <remote>"));
+ if (!argc && !all_remotes)
+ die(_("--prune-merged requires at least one <remote> or --all-remotes"));
- collect_forked_set(argc, argv, &protected_default_refs, &candidates);
+ collect_forked_set(argc, argv, all_remotes, &protected_default_refs,
+ &candidates);
for_each_string_list_item(item, &candidates) {
const char *short_name = item->string;
@@ -943,6 +954,7 @@ int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
int forked = 0;
int prune_merged = 0;
+ int all_remotes = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -1000,6 +1012,9 @@ int cmd_branch(int argc,
N_("list local branches forked from the given <remote>s")),
OPT_BOOL(0, "prune-merged", &prune_merged,
N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
+ OPT_BOOL_F(0, "all-remotes", &all_remotes,
+ N_("with --forked or --prune-merged, act on every configured remote"),
+ PARSE_OPT_NONEG),
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")),
@@ -1043,6 +1058,10 @@ int cmd_branch(int argc,
argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
0);
+ if (all_remotes && !forked && !prune_merged)
+ die(_("--all-remotes requires --forked or --prune-merged"));
+
+
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
!show_current && !unset_upstream && !forked && !prune_merged &&
argc == 0)
@@ -1096,10 +1115,10 @@ int cmd_branch(int argc,
quiet, 0, NULL);
goto out;
} else if (forked) {
- ret = list_forked_branches(argc, argv);
+ ret = list_forked_branches(argc, argv, all_remotes);
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
+ ret = prune_merged_branches(argc, argv, all_remotes, quiet);
goto out;
} else if (show_current) {
print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 885a275e36..a36e5ee80a 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '
test_grep "at least one <remote>" err
'
+test_expect_success '--forked --all-remotes covers every configured remote' '
+ git -C forked branch --forked --all-remotes >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked --all-remotes still validates explicit <remote>' '
+ test_must_fail git -C forked branch --forked nope --all-remotes 2>err &&
+ test_grep "neither a configured remote nor a remote-tracking branch" err
+'
+
+test_expect_success '--all-remotes alone is rejected' '
+ test_must_fail git -C forked branch --all-remotes 2>err &&
+ test_grep "requires --forked or --prune-merged" err
+'
+
test_expect_success '--prune-merged: setup' '
test_create_repo pm-upstream &&
test_commit -C pm-upstream base &&
@@ -1881,4 +1902,27 @@ 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 --all-remotes covers every configured remote' '
+ test_when_finished "rm -rf pm-allremotes pm-other" &&
+ git clone pm-upstream pm-allremotes &&
+ test_create_repo pm-other &&
+ test_commit -C pm-other other-base &&
+ git -C pm-other checkout -b stable &&
+ test_commit -C pm-other foreign-commit &&
+ git -C pm-other branch foreign HEAD &&
+ git -C pm-other checkout main &&
+
+ git -C pm-allremotes remote add other ../pm-other &&
+ git -C pm-allremotes fetch other &&
+ git -C pm-allremotes branch one one-commit &&
+ git -C pm-allremotes branch --set-upstream-to=origin/next one &&
+ git -C pm-allremotes branch foreign other/foreign &&
+ git -C pm-allremotes branch --set-upstream-to=other/stable foreign &&
+
+ git -C pm-allremotes branch --prune-merged --all-remotes &&
+
+ test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v9 4/5] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
--prune-merged, even with --force. Useful for keeping a topic
branch around between rounds.
Explicit deletion via 'git branch -d' is unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/branch.adoc | 7 +++++++
Documentation/git-branch.adoc | 3 ++-
builtin/branch.c | 23 +++++++++++++++++++++--
t/t3200-branch.sh | 27 +++++++++++++++++++++++++++
4 files changed, 57 insertions(+), 3 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..4662ef35c1 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 topic branches 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 375a0a68da..7435d38447 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -227,7 +227,8 @@ against whatever the remote-tracking refs say locally.
A branch whose upstream no longer resolves locally is left alone
(its disappearance is not, on its own, evidence that the work was
integrated). The currently checked-out branch in any worktree is
-always preserved, as is the local branch that mirrors _<remote>_'s
+always preserved, as is any branch with `branch.<name>.pruneMerged`
+set to `false`, and the local branch that mirrors _<remote>_'s
default branch.
`-v`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 6fe2ffd7e8..bc4f4a4a18 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -835,12 +835,15 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
for_each_string_list_item(item, &candidates) {
const char *short_name = item->string;
struct strbuf full = STRBUF_INIT;
+ struct strbuf key = STRBUF_INIT;
struct branch *branch;
const char *upstream;
+ int opt_out = 0;
strbuf_addf(&full, "refs/heads/%s", short_name);
if (branch_checked_out(full.buf)) {
strbuf_release(&full);
+ strbuf_release(&key);
continue;
}
strbuf_release(&full);
@@ -849,13 +852,29 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
if (!upstream ||
!refs_ref_exists(get_main_ref_store(the_repository),
- upstream))
+ upstream)) {
+ strbuf_release(&key);
continue;
+ }
if (string_list_has_string(&protected_default_refs, upstream)) {
const char *leaf = strrchr(upstream, '/');
- if (leaf && !strcmp(leaf + 1, short_name))
+ if (leaf && !strcmp(leaf + 1, short_name)) {
+ strbuf_release(&key);
continue;
+ }
+ }
+
+ 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 94ea493aee..885a275e36 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1854,4 +1854,31 @@ test_expect_success '--prune-merged protects only the default branch by name, no
test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ git -C pm-optout config branch.one.pruneMerged false &&
+
+ git -C pm-optout 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 &&
+ git -C pm-optout-d config 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 v9 2/5] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add two new parameters to delete_branches() and the helper
check_branch_commit():
* warn_only switches the per-branch refusal from a hard error
("error: the branch 'X' is not fully merged" plus a four-line
hint about 'git branch -D X') to a one-line warning, and
causes the function to skip those branches without setting its
exit code. Each refused branch is still skipped from deletion.
* n_not_merged, when non-NULL, is incremented for each branch
refused on the not-merged path, so a bulk caller can summarize
rather than print per-branch advice.
All existing call sites pass 0 / NULL and so are unaffected. Both
parameters are wired up so a bulk-deletion caller can suppress
the noise normally appropriate for a one-shot 'git branch -d'.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 29 ++++++++++++++++++++---------
1 file changed, 20 insertions(+), 9 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index b3289a8875..1941f8a9ad 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
+ int kinds, int force, int warn_only,
+ int *n_not_merged)
{
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,
return -1;
}
if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
- error(_("the branch '%s' is not fully merged"), branchname);
- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
- _("If you are sure you want to delete it, "
- "run 'git branch -D %s'"), branchname);
+ if (warn_only) {
+ warning(_("the branch '%s' is not fully merged"),
+ branchname);
+ } else {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+ _("If you are sure you want to delete it, "
+ "run 'git branch -D %s'"), branchname);
+ }
+ if (n_not_merged)
+ (*n_not_merged)++;
return -1;
}
return 0;
@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)
}
static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
+ int quiet, int warn_only, int *n_not_merged)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
- ret = 1;
+ force, warn_only, n_not_merged)) {
+ if (!warn_only)
+ ret = 1;
goto next;
}
@@ -961,7 +971,8 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ quiet, 0, NULL);
goto out;
} else if (forked) {
ret = list_forked_branches(argc, argv);
--
gitgitgadget
^ permalink raw reply related
* [PATCH v9 1/5] branch: add --forked <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
List local branches whose configured upstream falls within any of
the given <remote> arguments. <remote> may be either a configured
remote name (matching all of its remote-tracking branches) or a
single remote-tracking branch. Multiple <remote> arguments are
unioned.
This is the building block for --prune-merged, which deletes the
listed branches.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 12 ++++
builtin/branch.c | 110 +++++++++++++++++++++++++++++++++-
t/t3200-branch.sh | 54 +++++++++++++++++
3 files changed, 174 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..5773104cd3 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --forked <remote>...
DESCRIPTION
-----------
@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--forked`::
+ List local branches that fork from any of the given _<remote>_
+ arguments, that is, those whose configured upstream
+ (`branch.<name>.merge`) is one of those remotes' remote-tracking
+ branches.
++
+Each _<remote>_ may be either the name of a configured remote
+(e.g. `origin`, meaning any branch tracking a
+`refs/remotes/origin/*` ref) or a specific remote-tracking branch
+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..b3289a8875 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --forked <remote>..."),
NULL
};
@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static void parse_forked_args(int argc, const char **argv,
+ struct string_list *remote_names,
+ struct string_list *tracking_refs)
+{
+ int i;
+
+ for (i = 0; i < argc; i++) {
+ const char *arg = argv[i];
+ struct remote *remote;
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ remote = remote_get(arg);
+ if (remote && remote_is_configured(remote, 0)) {
+ string_list_insert(remote_names, remote->name);
+ continue;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ starts_with(full_ref, "refs/remotes/")) {
+ string_list_insert(tracking_refs, full_ref);
+ free(full_ref);
+ continue;
+ }
+ free(full_ref);
+
+ die(_("'%s' is neither a configured remote nor a "
+ "remote-tracking branch"), arg);
+ }
+}
+
+static int branch_is_forked(const char *short_name,
+ const struct string_list *remote_names,
+ const struct string_list *tracking_refs)
+{
+ struct branch *branch = branch_get(short_name);
+ const char *upstream;
+
+ if (!branch || !branch->remote_name)
+ return 0;
+
+ if (string_list_has_string(remote_names, branch->remote_name))
+ return 1;
+
+ upstream = branch_get_upstream(branch, NULL);
+ if (upstream && string_list_has_string(tracking_refs, upstream))
+ return 1;
+
+ return 0;
+}
+
+struct forked_cb {
+ const struct string_list *remote_names;
+ const struct string_list *tracking_refs;
+ struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+ struct forked_cb *cb = cb_data;
+
+ if (ref->flags & REF_ISSYMREF)
+ return 0;
+ if (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))
+ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list remote_names = STRING_LIST_INIT_NODUP;
+ struct string_list tracking_refs = STRING_LIST_INIT_DUP;
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ struct forked_cb cb = {
+ .remote_names = &remote_names,
+ .tracking_refs = &tracking_refs,
+ .out = &out,
+ };
+
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
+ parse_forked_args(argc, argv, &remote_names, &tracking_refs);
+
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ collect_forked_branch, &cb);
+
+ string_list_sort(&out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
+ string_list_clear(&remote_names, 0);
+ string_list_clear(&tracking_refs, 0);
+ string_list_clear(&out, 0);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -714,6 +814,7 @@ int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ int forked = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -767,6 +868,8 @@ int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
+ OPT_BOOL(0, "forked", &forked,
+ N_("list local branches forked from the given <remote>s")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -811,7 +914,7 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !forked && argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -820,7 +923,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!forked;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -860,6 +963,9 @@ int cmd_branch(int argc,
die(_("branch name required"));
ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
goto out;
+ } else if (forked) {
+ ret = list_forked_branches(argc, argv);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..24a3ec44ee 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached
+'
+
+test_expect_success '--forked <remote-name> lists branches tracking that remote' '
+ git -C forked branch --forked origin >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '
+ git -C forked branch --forked origin/one >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <remote> arguments' '
+ git -C forked branch --forked origin/one other >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown remote/ref' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "neither a configured remote nor a remote-tracking branch" err
+'
+
+test_expect_success '--forked requires at least one <remote>' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "at least one <remote>" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v9 0/5] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-13 19:34 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com>
* --force no longer has special meaning with --prune-merged; reachability
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Harald Nordgren (5):
branch: add --forked <remote>
branch: let delete_branches warn instead of error on bulk refusal
branch: add --prune-merged <remote>
branch: add branch.<name>.pruneMerged opt-out
branch: add --all-remotes flag
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 37 ++++
builtin/branch.c | 292 +++++++++++++++++++++++++++++--
t/t3200-branch.sh | 208 ++++++++++++++++++++++
4 files changed, 528 insertions(+), 16 deletions(-)
base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v9
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v9
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v8:
1: 22fa8515df = 1: 9324b26091 branch: add --forked <remote>
2: b443f0f367 = 2: 2a13e5d4bc branch: let delete_branches warn instead of error on bulk refusal
3: 3032e9c39a ! 3: f87e96e99d branch: add --prune-merged <remote>
@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <remote>...
-+git branch [-f] --prune-merged <remote>...
++git branch --prune-merged <remote>...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi
++
+A branch whose upstream no longer resolves locally is left alone
+(its disappearance is not, on its own, evidence that the work was
-+integrated). With `--force` (or `-f`), the reachability check is
-+skipped and every branch in the candidate set is deleted. The
-+currently checked-out branch in any worktree is always preserved,
-+as is the local branch that mirrors _<remote>_'s default branch.
++integrated). The currently checked-out branch in any worktree is
++always preserved, as is the local branch that mirrors _<remote>_'s
++default branch.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
return 0;
}
-+static int prune_merged_branches(int argc, const char **argv, int force,
-+ int quiet)
++static int prune_merged_branches(int argc, const char **argv, int quiet)
+{
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
-+ 1, force,
++ 1, 0,
+ FILTER_REFS_BRANCHES, quiet,
+ 1, &n_not_merged);
+
+ if (n_not_merged && !quiet)
+ fprintf(stderr,
+ Q_("Skipped %d branch that is not fully merged; "
-+ "re-run with --force to delete it anyway.\n",
++ "delete it with 'git branch -D' if you are sure.\n",
+ "Skipped %d branches that are not fully merged; "
-+ "re-run with --force to delete them anyway.\n",
++ "delete them with 'git branch -D' if you are sure.\n",
+ n_not_merged),
+ n_not_merged);
+
@@ builtin/branch.c: int cmd_branch(int argc,
ret = list_forked_branches(argc, argv);
goto out;
+ } else if (prune_merged) {
-+ ret = prune_merged_branches(argc, argv, force, quiet);
++ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+ git -C pm-unmerged branch --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep "Skipped 1 branch" err &&
-+ test_grep "re-run with --force" err &&
++ test_grep "git branch -D" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
-+test_expect_success '--prune-merged --force deletes branches regardless of reachability' '
-+ test_when_finished "rm -rf pm-force" &&
-+ git clone pm-upstream pm-force &&
-+ git -C pm-force checkout -b wip origin/wip &&
-+ git -C pm-force branch --set-upstream-to=origin/next wip &&
-+ test_commit -C pm-force local-only &&
-+ git -C pm-force checkout - &&
-+
-+ git -C pm-force branch --force --prune-merged origin &&
-+
-+ test_must_fail git -C pm-force rev-parse --verify refs/heads/wip
-+'
-+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
-+ git -C pm-head branch --force --prune-merged origin &&
++ git -C pm-head branch --prune-merged origin &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+ test_when_finished "rm -rf pm-default" &&
+ git clone pm-upstream pm-default &&
+ git -C pm-default checkout --detach &&
-+ git -C pm-default branch --force --prune-merged origin &&
++ git -C pm-default branch --prune-merged origin &&
+ git -C pm-default rev-parse --verify refs/heads/main
+'
+
4: dd33309344 ! 4: 19b6d94fa7 branch: add branch.<name>.pruneMerged opt-out
@@ Documentation/config/branch.adoc: for details).
+ Explicit deletion via `git branch -d` is unaffected.
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: A branch whose upstream no longer resolves locally is left alone
- integrated). With `--force` (or `-f`), the reachability check is
- skipped and every branch in the candidate set is deleted. The
- currently checked-out branch in any worktree is always preserved,
--as is the local branch that mirrors _<remote>_'s default branch.
-+as is any branch with `branch.<name>.pruneMerged` set to `false`,
-+and the local branch that mirrors _<remote>_'s default branch.
+@@ Documentation/git-branch.adoc: against whatever the remote-tracking refs say locally.
+ A branch whose upstream no longer resolves locally is left alone
+ (its disappearance is not, on its own, evidence that the work was
+ integrated). The currently checked-out branch in any worktree is
+-always preserved, as is the local branch that mirrors _<remote>_'s
++always preserved, as is any branch with `branch.<name>.pruneMerged`
++set to `false`, and the local branch that mirrors _<remote>_'s
+ default branch.
`-v`::
- `-vv`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
for_each_string_list_item(item, &candidates) {
const char *short_name = item->string;
struct strbuf full = STRBUF_INIT;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
continue;
}
strbuf_release(&full);
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
if (!upstream ||
!refs_ref_exists(get_main_ref_store(the_repository),
@@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default
+ test_grep "Skipping .one." err
+'
+
-+test_expect_success '--prune-merged --force still honours pruneMerged=false' '
-+ test_when_finished "rm -rf pm-optout-force" &&
-+ git clone pm-upstream pm-optout-force &&
-+ git -C pm-optout-force checkout -b wip origin/wip &&
-+ git -C pm-optout-force branch --set-upstream-to=origin/next wip &&
-+ test_commit -C pm-optout-force local-only &&
-+ git -C pm-optout-force checkout - &&
-+ git -C pm-optout-force config branch.wip.pruneMerged false &&
-+
-+ git -C pm-optout-force branch --force --prune-merged origin &&
-+
-+ git -C pm-optout-force rev-parse --verify refs/heads/wip
-+'
-+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+ test_when_finished "rm -rf pm-optout-d" &&
+ git clone pm-upstream pm-optout-d &&
5: 6e81ed3147 ! 5: 6ae95d3f98 branch: add --all-remotes flag
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --forked <remote>...
--git branch [-f] --prune-merged <remote>...
+-git branch --prune-merged <remote>...
+git branch --forked (<remote>... | --all-remotes)
-+git branch [-f] --prune-merged (<remote>... | --all-remotes)
++git branch --prune-merged (<remote>... | --all-remotes)
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: currently checked-out branch in any worktree is always preserved,
- as is any branch with `branch.<name>.pruneMerged` set to `false`,
- and the local branch that mirrors _<remote>_'s default branch.
+@@ Documentation/git-branch.adoc: always preserved, as is any branch with `branch.<name>.pruneMerged`
+ set to `false`, and the local branch that mirrors _<remote>_'s
+ default branch.
+`--all-remotes`::
+ With `--forked` or `--prune-merged`, act on every
@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
return 0;
}
--static int prune_merged_branches(int argc, const char **argv, int force,
-- int quiet)
+-static int prune_merged_branches(int argc, const char **argv, int quiet)
+static int prune_merged_branches(int argc, const char **argv,
-+ int all_remotes, int force, int quiet)
++ int all_remotes, int quiet)
{
struct string_list candidates = STRING_LIST_INIT_DUP;
struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
int n_not_merged = 0;
int ret = 0;
@@ builtin/branch.c: int cmd_branch(int argc,
+ if (all_remotes && !forked && !prune_merged)
+ die(_("--all-remotes requires --forked or --prune-merged"));
++
+
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
!show_current && !unset_upstream && !forked && !prune_merged &&
@@ builtin/branch.c: int cmd_branch(int argc,
+ ret = list_forked_branches(argc, argv, all_remotes);
goto out;
} else if (prune_merged) {
-- ret = prune_merged_branches(argc, argv, force, quiet);
-+ ret = prune_merged_branches(argc, argv, all_remotes, force, quiet);
+- ret = prune_merged_branches(argc, argv, quiet);
++ ret = prune_merged_branches(argc, argv, all_remotes, quiet);
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply
* Re: [PATCH v2 3/3] grep: prefetch necessary blobs
From: Elijah Newren @ 2026-05-13 19:21 UTC (permalink / raw)
To: Derrick Stolee; +Cc: Elijah Newren via GitGitGadget, git
In-Reply-To: <31763514-2602-4d8e-ac25-70590f090947@gmail.com>
On Mon, Apr 27, 2026 at 5:59 AM Derrick Stolee <stolee@gmail.com> wrote:
>
> On 4/17/2026 8:32 PM, Elijah Newren via GitGitGadget wrote:
> > From: Elijah Newren <newren@gmail.com>
> >
> > In partial clones, `git grep` fetches necessary blobs on-demand one
> > at a time, which can be very slow. In partial clones, add an extra
> > preliminary walk over the tree similar to grep_tree() which collects
> > the blobs of interest, and then prefetches them.
>
> A log of the code is about walking trees to find blobs matching
> the input pathspec, with this being the core method:
>
> > +static void collect_blob_oids_for_tree(struct repository *repo,
> > + const struct pathspec *pathspec,
> > + struct tree_desc *tree,
> > + struct strbuf *base,
> > + int tn_len,
> > + struct oidset *blob_oids)
>
> And in your test, you set up a repo to have three blobs with
> matches in two of the files:
>
> > +test_expect_success 'grep of revision in partial clone does bulk prefetch' '
> > + test_when_finished "rm -rf grep-partial-src grep-partial" &&
> > +
> > + git init grep-partial-src &&
> > + (
> > + cd grep-partial-src &&
> > + git config uploadpack.allowfilter 1 &&
> > + git config uploadpack.allowanysha1inwant 1 &&
> > + echo "needle in haystack" >searchme &&
> > + echo "no match here" >other &&
> > + mkdir subdir &&
> > + echo "needle again" >subdir/deep &&
> > + git add . &&
> > + git commit -m "initial"
> > + ) &&
>
> But then the command downloads all of the blobs, not using a
> pathspec:
>
> > + # grep HEAD should batch-prefetch all blobs in one request.
> > + GIT_TRACE2_EVENT="$(pwd)/grep-trace" \
> > + git -C grep-partial grep -c "needle" HEAD >result &&
> > +
> > + # Should find matches in two files.
> > + test_line_count = 2 result &&
> > +
> > + # Should have prefetched all 3 objects at once
> > + test_trace2_data promisor fetch_count 3 <grep-trace
> > +'
> I think your code is correct, but I'd like to see a test
> here that demonstrates a pathspec filter on the 'grep'
> command to help filter out a blob that has a matching string.
>
> Perhaps something like:
>
> * matches.txt (has needle)
> * nomatch.txt (does not have needle)
> * matches.md (has needle)
>
> and then 'git grep -c "needle" HEAD -- *.txt' would
> download two blobs and find one match. A second run without
> the pathspec would download one blob and find two matches.
>
> Does that make sense as a test?
Yes, absolutely. And thanks for suggesting it; although I was
handling pathspecs correctly, I discovered that I was unconditionally
requesting to download whatever objects matched the pathspecs (or all
blobs in the commit if no pathspec given), even if the blobs were
already local. I'll send an updated test in v2, along with the fix.
^ permalink raw reply
* Re: [PATCH] http: handle absolute-path alternates from server root
From: Jeff King @ 2026-05-13 18:58 UTC (permalink / raw)
To: Junio C Hamano; +Cc: git, slonkazoid
In-Reply-To: <xmqqo6ikjeqp.fsf@gitster.g>
On Wed, May 13, 2026 at 10:10:54AM +0900, Junio C Hamano wrote:
> Jeff King <peff@peff.net> writes:
>
> > ... Probably in a way that makes it totally invalid, but
> > if you were very unlucky you could turn something like:
> >
> > http://victim.com.evil.domain:8000
> >
> > into:
> >
> > http://victim.com
> >
> > Which looks like the start of a redirect attack, except that
> > the attacker could just have written "http://victim.com" in
> > the first place! Either way we feed it to
> > is_alternate_allowed(), which is where we check redirect and
> > protocol rules.
>
> Yuck. I know I am the guilty party who introduced the dumb HTTP
> walker but I wish we could kill it off after all these years. I did
> not even recall that we supported the alternate object store in the
> "protocol" until I saw this patch X-<.
Me too. It's been the source of many obscure bugs, and I think a couple
of vulnerabilities (even though clients never intend to use dumb clones
in the first place).
We talked about dropping it a few years ago, but Eric countered that
dumb clones are easier on the server in some cases (like gigantic
public-inbox repos that are packed to keep most of the old history in
one big pack that is never updated). The verbatim pack-reuse feature
tries to get smart clones closer to that, but it's hard to beat serving
a static file from the server's perspective. I haven't measured anything
in that area in a while, though.
-Peff
^ permalink raw reply
* [PATCH] fetch: add fetch.pruneLocalBranches config
From: Harald Nordgren @ 2026-05-13 18:57 UTC (permalink / raw)
To: gitster; +Cc: git, gitgitgadget, haraldnordgren, j6t, kristofferhaugsbakk
In-Reply-To: <xmqqqznfifrg.fsf@gitster.g>
> I am not sure if this is a good idea at all. The option is called
> prune-MERGED and with or without --force, mergedness should be what
> determines if a branch is deleted.
Well, when I started writing the feature it was "prune local branches",
and it evolved from there to prune merged.
But you're probably right. I did wipe up some branches with real work on my
side using this (I restored them), so it seems to be more of a foot-gun
than I first imagined.
Seems reasonable to remove the '--force' functionality.
Harald
^ permalink raw reply
* Re: [PATCH] pretty: drop strbuf pre-sizing from add_rfc2047()
From: Jeff King @ 2026-05-13 18:54 UTC (permalink / raw)
To: Junio C Hamano; +Cc: git, Luke Martin
In-Reply-To: <xmqqtsscjf30.fsf@gitster.g>
On Wed, May 13, 2026 at 10:03:31AM +0900, Junio C Hamano wrote:
> Someday we may want to go through the output from
>
> $ git grep -e 'strbuf_grow(' \*.c
>
> and remove this ineffective presizing. I think any call to
> strbuf_grow() that is immediately followed by a call to
> strbuf_addX() is suspect, like in the following illustration (there
> are others in these files).
Yup. I think this could be #leftoverbits material, but anybody who wants
to pick this up should be careful to read through the whole function and
make sure there's no subtle dependency on the grown buffer.
Skimming through, it looks like most are just leftovers from when old
code was converted to strbuf, and the pre-growth was kept mostly out of
conservatism.
Some of them are truly ugly to look at, like:
http-backend.c: strbuf_grow(&buf, cnt * 53 + 2);
and I think in some cases we can even drop some surrounding code, like:
diff --git a/builtin/fast-import.c b/builtin/fast-import.c
index 070a5af3e4..aa32ebc8ab 100644
--- a/builtin/fast-import.c
+++ b/builtin/fast-import.c
@@ -1337,21 +1337,14 @@ static int tecmp1 (const void *_a, const void *_b)
static void mktree(struct tree_content *t, int v, struct strbuf *b)
{
- size_t maxlen = 0;
unsigned int i;
if (!v)
QSORT(t->entries, t->entry_count, tecmp0);
else
QSORT(t->entries, t->entry_count, tecmp1);
- for (i = 0; i < t->entry_count; i++) {
- if (t->entries[i]->versions[v].mode)
- maxlen += t->entries[i]->name->str_len + 34;
- }
-
strbuf_reset(b);
- strbuf_grow(b, maxlen);
for (i = 0; i < t->entry_count; i++) {
struct tree_entry *e = t->entries[i];
if (!e->versions[v].mode)
So probably some satisfying cleanup opportunities available for somebody
who wants to spend a little time with it. ;)
-Peff
^ permalink raw reply related
* Re: [PATCH v3 07/12] path-walk: support blob size limit filter
From: Derrick Stolee @ 2026-05-13 18:35 UTC (permalink / raw)
To: Taylor Blau, Derrick Stolee via GitGitGadget
Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agKDbUoZeiux3jdj@nand.local>
On 5/11/2026 9:33 PM, Taylor Blau wrote:
> On Mon, May 11, 2026 at 06:13:04PM +0000, Derrick Stolee via GitGitGadget wrote:
>> [...] Blobs whose size cannot be determined (e.g. missing in a partial
>> clone) are conservatively included, matching the existing filter
>> behavior.
>
> Makes sense, but...
>
>> Notice that this inspection of object sizes requires the content to be
>> present in the repository. The odb_read_object_info() call will download
>> a missing blob on-demand.
>
> ... this says that we do download missing blobs on-demand. Should we be
> (temporarily) disabling 'fetch_if_missing' for this phase, or using
> odb_read_object_info_extended() with the OBJECT_INFO_SKIP_FETCH_OBJECT
> bit set?
I don't know that we'd want to set this everywhere. The case that is
technically correct, but probably not ideal, is someone running 'git
pack-objects --filter=blob:<size>' from a blobless partial clone. They
are asking for something inefficient to create, but we _can_ still
create it.
> I don't know enough about 'git backfill' to know whether the current
> behavior is more reasonable than the above suggestion, so please let me
> know if I'm missing something here!
For 'git backfill', a size limit filter doesn't make sense as it's all
about the client making decisions about how to download batches of
objects based on the local tree data. Filtering on size isn't something
that the client can infer in advance of downloading everything. Further,
the direct blob requests to the server can't be filtered by size, by
design.
Thanks,
-Stolee
^ permalink raw reply
* Re: [PATCH v3 04/12] path-walk: always emit directly-requested objects
From: Derrick Stolee @ 2026-05-13 18:29 UTC (permalink / raw)
To: Taylor Blau, Derrick Stolee via GitGitGadget
Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agKBJJvBP/PndPMP@nand.local>
On 5/11/2026 9:23 PM, Taylor Blau wrote:
> On Mon, May 11, 2026 at 06:13:01PM +0000, Derrick Stolee via GitGitGadget wrote:
>> We can tell that a path is part of the directly-referenced objects if its
>> path name starts with '/' (other paths, including root trees never have this
>> starting character). Create a path_is_for_direct_objects() to make this
>> meaning clear, especially as we add more references in the future as we
>> integrate the path-walk API with partial clone filter options.
>
> I don't know that I have anything in the way of a better suggestion, but
> I can't help but feel like the `path_is_for_direct_objects()` check is
> somewhat brittle as-is.
>
> I am not familiar enough with the path-walk.c internals to come up with
> a good suggestion off the top of my head, but I figured I'd raise it
> here in case you had thoughts on alternatives.
The brittle-ness is due to how trees and blobs without known paths are
added to sets that are emitted using distinct paths. The starting '/'
character does prevent the "/tagged-blobs" and "/tagged-trees" sets
from ever colliding with valid paths that were discovered starting from
root trees (with path "").
I could imagine a world where we store a more robust struct that has a
flag member to indicate that these objects were not discovered via
normal tree walking. Changing that could lead to this method not doing
what we expect it to do. But we are not in that future.
>> diff --git a/path-walk.c b/path-walk.c
>> index 6e426af433..59a7670c5b 100644
>> --- a/path-walk.c
>> +++ b/path-walk.c
>> @@ -248,6 +248,16 @@ static int add_tree_entries(struct path_walk_context *ctx,
>> return 0;
>> }
>>
>> +/*
>> + * Paths starting with '/' (e.g., "/tags", "/tagged-blobs") hold objects that
>> + * were directly requested by 'pending' objects rather than discovered during
>> + * tree traversal.
>> + */
>> +static int path_is_for_direct_objects(const char *path)
>> +{
>> + return path[0] == '/';
>
> If we do end up keeping this approach, should we have a NULL check on
> path itself here? I think that could even be an ASSERT(), since
> something has gone wrong if we have a NULL at this point, but I'd rather
> die by an assertion rather than a segfault here if so.
The ASSERT() is a good idea to prevent incorrect use.
Thanks,
-Stolee
^ permalink raw reply
* Re: [PATCH v3 03/12] t/perf: add pack-objects filter and path-walk benchmark
From: Derrick Stolee @ 2026-05-13 18:23 UTC (permalink / raw)
To: Taylor Blau, Derrick Stolee via GitGitGadget
Cc: git, christian.couder, gitster, johannes.schindelin, johncai86,
karthik.188, kristofferhaugsbakk, newren, peff, ps
In-Reply-To: <agJ+ORhy0O6fpJJm@nand.local>
On 5/11/2026 9:11 PM, Taylor Blau wrote:
> On Mon, May 11, 2026 at 06:13:00PM +0000, Derrick Stolee via GitGitGadget wrote:
>> + git ls-tree -d --name-only HEAD >top-dirs &&
>> + top_nr=$(wc -l <top-dirs) &&
(I'll come back to this)
>> + >depth2-dirs &&
>> + while read tdir
>> + do
>> + git ls-tree -d --name-only "HEAD:$tdir" 2>/dev/null |
>> + sed "s|^|$tdir/|" >>depth2-dirs || return 1
>> + done <top-dirs &&
>
> Not a huge deal, but I think we should avoid having ls-tree on the
> left-hand side of the pipe here. I would perhaps write this as:
>
> while read tdir
> do
> git ls-tree -d --name-only "HEAD:$tdir" || return 1
> done <top-dirs >depth2-dirs.raw &&
> sed -e "s|^|$tdir/|" depth2-dirs.raw >depth2-dirs &&
>
> (Moving sed outside of the main loop cuts down on the process count, but
> isn't a huge deal. I think the main thing I wanted to suggest here was
> avoiding 'git ls-tree ... | sed'.)
This is a good find, because the structure of this loop is hiding
a break when a "directory" emitted by 'git ls-tree -d' is actually
a submodule entry.
I've modified the loop as well as the first ls-tree command to be
more resilient to these cases.
Thanks,
-Stolee
^ permalink raw reply
* Re: [PATCH 1/4] strbuf: add strbuf_add_uint()
From: René Scharfe @ 2026-05-13 17:46 UTC (permalink / raw)
To: Jeff King; +Cc: git
In-Reply-To: <20260513162232.GB103037@coredump.intra.peff.net>
On 5/13/26 6:22 PM, Jeff King wrote:
> On Tue, May 12, 2026 at 09:32:09PM +0200, René Scharfe wrote:
>
>> The three variants were close in my tests, the no-copy variant slightly
>> winning on Apple silicon, but losing slightly more on an AMD Ryzen
>> laptop CPU. So I went with the solid choice of using an on-stack
>> buffer, same as in printf(3) (at least on BSD). Buffering at the end of
>> the strbuf was not really faster; perhaps memmove(3) is just that much
>> slower than memcpy(3).
>
> I'm not sure if you did these tests initially, or if I nerd-sniped you
> into it. Either way, I am happy to be able to hear the results. ;)
Did them before I sent the series.
>> Perhaps an optimized decimal_width() could change the picture somewhat,
>> but I don't expect a big win. On the other hand I just told you how
>> unreliable my expectations are, so there might be treasure after all. :)
>
> I got identical times for cat-file's %(objectsize:disk) running your
> version against the one below.
Similar here (git_stack is this series, git_clz is your patch on top,
git_dec uses pager.c::decimal_width()):
Benchmark 1: ./git_stack cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Time (mean ± σ): 383.5 ms ± 0.8 ms [User: 374.7 ms, System: 7.6 ms]
Range (min … max): 382.2 ms … 384.7 ms 10 runs
Benchmark 2: ./git_dec cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Time (mean ± σ): 382.5 ms ± 1.0 ms [User: 373.9 ms, System: 7.5 ms]
Range (min … max): 381.2 ms … 384.7 ms 10 runs
Benchmark 3: ./git_clz cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Time (mean ± σ): 382.5 ms ± 0.5 ms [User: 373.7 ms, System: 7.7 ms]
Range (min … max): 381.9 ms … 383.6 ms 10 runs
> Not wanting to figure out all of the
> off-by-one corner cases myself, I checked stack overflow for an easy
> recipe but couldn't find one. The version below was generated by
> chatgpt, which looks plausibly correct to me.
>
> -Peff
>
> diff --git a/strbuf.c b/strbuf.c
> index 9731ecdc1f..c26614a698 100644
> --- a/strbuf.c
> +++ b/strbuf.c
> @@ -361,16 +361,52 @@ void strbuf_addf(struct strbuf *sb, const char *fmt, ...)
> va_end(ap);
> }
>
> +static const uint64_t powers_of_10[] = {
> + 1ULL,
> + 10ULL,
> + 100ULL,
> + 1000ULL,
> + 10000ULL,
> + 100000ULL,
> + 1000000ULL,
> + 10000000ULL,
> + 100000000ULL,
> + 1000000000ULL,
> + 10000000000ULL,
> + 100000000000ULL,
> + 1000000000000ULL,
> + 10000000000000ULL,
> + 100000000000000ULL,
> + 1000000000000000ULL,
> + 10000000000000000ULL,
> + 100000000000000000ULL,
> + 1000000000000000000ULL,
> + 10000000000000000000ULL,
> +};
> +
> +unsigned decimal_length_u64(uint64_t n)
> +{
> + if (n == 0)
> + return 1;
> +
> + unsigned b = 63 - __builtin_clzll(n);
> + /* approximate floor(log10(n)) */
> + unsigned t = (b * 1233) >> 12;
> + /* correct if estimate was low */
> + return t + 1 + (n >= powers_of_10[t + 1]);
> +}
Clever. But it seems for smallish object size numbers at least dividing
by ten repeatedly is not causing a bottleneck.
René
^ permalink raw reply
* Re: [PATCH] hex: add and use strbuf_add_oid_hex()
From: René Scharfe @ 2026-05-13 16:55 UTC (permalink / raw)
To: Jeff King; +Cc: Git List
In-Reply-To: <20260513160155.GA103037@coredump.intra.peff.net>
On 5/13/26 6:01 PM, Jeff King wrote:
> On Wed, May 13, 2026 at 05:49:11PM +0200, René Scharfe wrote:
>
>> Add a function for adding the full hexadecimal hash value of an object
>> ID to a strbuf. It's thread-safe and slightly more efficient than using
>> strbuf_addstr() with oid_to_hex() because it doesn't have to determine
>> the length of the string or copy it from the intermediate static buffer.
>>
>> Add and apply a semantic patch to use it throughout the code base.
>>
>> I get a tiny speedup for git log showing a single hash per commit:
>>
>> Benchmark 1: ./git_main log --format=%H
>> Time (mean ± σ): 91.2 ms ± 0.7 ms [User: 51.9 ms, System: 38.6 ms]
>> Range (min … max): 89.8 ms … 92.6 ms 31 runs
>>
>> Benchmark 2: ./git log --format=%H
>> Time (mean ± σ): 90.5 ms ± 0.7 ms [User: 51.0 ms, System: 38.8 ms]
>> Range (min … max): 89.2 ms … 92.3 ms 32 runs
>
> Probably the most extreme benchmark would be:
>
> git cat-file --batch-all-objects --batch-check='%(objectname)'
>
> which is really just dumping the oids from packfiles. I got ~3% speedup,
> though like yours it's within the run-to-run noise.
Hmm, should've lead with that; on an Apple M1:
Benchmark 1: ./git_main cat-file --batch-all-objects --batch-check='%(objectname)'
Time (mean ± σ): 117.9 ms ± 0.2 ms [User: 111.3 ms, System: 5.6 ms]
Range (min … max): 117.5 ms … 118.5 ms 24 runs
Benchmark 2: ./git cat-file --batch-all-objects --batch-check='%(objectname)'
Time (mean ± σ): 109.9 ms ± 0.2 ms [User: 103.2 ms, System: 5.6 ms]
Range (min … max): 109.5 ms … 110.8 ms 26 runs
Summary
./git cat-file --batch-all-objects --batch-check='%(objectname)' ran
1.07 ± 0.00 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectname)'
... and on an M5:
Benchmark 1: ./git_main cat-file --batch-all-objects --batch-check='%(objectname)'
Time (mean ± σ): 76.8 ms ± 1.9 ms [User: 73.0 ms, System: 3.2 ms]
Range (min … max): 73.7 ms … 80.7 ms 38 runs
Benchmark 2: ./git cat-file --batch-all-objects --batch-check='%(objectname)'
Time (mean ± σ): 70.8 ms ± 1.0 ms [User: 66.9 ms, System: 3.3 ms]
Range (min … max): 69.4 ms … 73.7 ms 41 runs
Summary
./git cat-file --batch-all-objects --batch-check='%(objectname)' ran
1.08 ± 0.03 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectname)'‚
René
^ permalink raw reply
* Re: [PATCH 1/4] strbuf: add strbuf_add_uint()
From: Jeff King @ 2026-05-13 16:49 UTC (permalink / raw)
To: René Scharfe; +Cc: git
In-Reply-To: <20260513162232.GB103037@coredump.intra.peff.net>
On Wed, May 13, 2026 at 12:22:32PM -0400, Jeff King wrote:
> I guess it is not too surprising that they all come pretty close in
> whole-process benchmarks. These are all micro-optimizations of a
> relatively small portion of the total work the process is doing. Even
> the strbuf_grow() checks are probably slower!
And btw, one final thing to look at if you are interested in
micro-optimizing strbufs: using intrinsics for overflow detection.
Right now we use unsigned_add_overflows(), and then do the actual add.
Using __builtin_add_overflow() might be faster.
-Peff
^ permalink raw reply
* Re: [PATCH 1/4] strbuf: add strbuf_add_uint()
From: Jeff King @ 2026-05-13 16:47 UTC (permalink / raw)
To: René Scharfe; +Cc: git
In-Reply-To: <20260513162232.GB103037@coredump.intra.peff.net>
On Wed, May 13, 2026 at 12:22:32PM -0400, Jeff King wrote:
> void strbuf_add_uint(struct strbuf *sb, uintmax_t value)
> {
> - char buf[DIV_ROUND_UP(bitsizeof(value) * 10, 33)];
> - char *end = buf + sizeof(buf);
> - char *p = end;
> + unsigned digits = decimal_length_u64(value);
> + char *p;
>
> + strbuf_grow(sb, digits);
> + p = sb->buf + digits;
This last line should be "sb->buf + sb->len + digits", of course.
Naturally that was the part I wrote by hand. ;)
It doesn't matter for the cat-file test, since we're always formatting
into an empty buffer, but it does for the ls-files padding one.
-Peff
^ permalink raw reply
* Re: [PATCH 3/4] ls-files: use strbuf_add_uint()
From: Jeff King @ 2026-05-13 16:46 UTC (permalink / raw)
To: René Scharfe; +Cc: git
In-Reply-To: <2f45a33b-5945-431d-97a5-7d61e271cfba@web.de>
On Tue, May 12, 2026 at 10:44:21PM +0200, René Scharfe wrote:
> > ...also less nice. We are formatting into the strbuf, and then maybe
> > memmove()-ing the result to accommodate padding. I wonder how much that
> > affects the timing. It's extra shuffling, but memmove() etc is often
> > surprisingly fast.
>
> I gave my objectsize and objectsize:padded numbers; the difference was
> 1.2 ms, albeit with 1.0 ms noise in padded case.
Ah, right, that makes sense.
Applying the fast decimal-width from my earlier message, I came up with:
diff --git a/builtin/ls-files.c b/builtin/ls-files.c
index c142ad4156..e17e3517ff 100644
--- a/builtin/ls-files.c
+++ b/builtin/ls-files.c
@@ -251,22 +251,24 @@ static void expand_objectsize(struct repository *repo, struct strbuf *line,
const enum object_type type, unsigned int padded)
{
static const char padding[] = " ";
- size_t min_len = padded ? strlen(padding) : 0;
- size_t orig_len = line->len;
- size_t len;
+ static const unsigned padding_len = ARRAY_SIZE(padding) - 1;
if (type == OBJ_BLOB) {
unsigned long size;
if (odb_read_object_info(repo->objects, oid, &size) < 0)
die(_("could not get object info about '%s'"),
oid_to_hex(oid));
+ if (padded) {
+ unsigned digits = decimal_length_u64(size);
+ if (digits < padding_len)
+ strbuf_add(line, padding, padding_len - digits);
+ }
strbuf_add_uint(line, size);
} else {
+ if (padded)
+ strbuf_add(line, padding, padding_len - 1);
strbuf_addstr(line, "-");
}
- len = line->len - orig_len;
- if (len < min_len)
- strbuf_insert(line, orig_len, padding, min_len - len);
}
static void show_ce_fmt(struct repository *repo, const struct cache_entry *ce,
but it was not meaningfully faster than your version.
> > I have often wondered how hard it would be to implement our own
> > vsnprintf(), and whether we could do better than the libc ones. It would
> > be nice to be able to add shorthands for common types (instead of the
> > unreadable PRIuMAX mess), as well as custom ones (e.g., hex oids).
>
> C99 has %ju for uintmax_t and %zu for size_t. Hmm, do we actually
> still need to avoid them? CodingGuidelines says "the C library used
> by MinGW does not" support it. 82c36fa0a9 (submodule: hash the
> submodule name for the gitdir path, 2026-01-12) just added a %zu,
> and there are lots of them in compat/mimalloc/ in Git for Windows.
An accidental test-balloon, I guess. It's in v2.54.0, so maybe we will
see some reports, or maybe we can eventually use it as evidence that we
can relax a bit.
-Peff
^ permalink raw reply related
* Re: [PATCH 1/4] strbuf: add strbuf_add_uint()
From: Jeff King @ 2026-05-13 16:22 UTC (permalink / raw)
To: René Scharfe; +Cc: git
In-Reply-To: <60b1ef2a-3b12-449e-be0b-cb206425c80c@web.de>
On Tue, May 12, 2026 at 09:32:09PM +0200, René Scharfe wrote:
> The three variants were close in my tests, the no-copy variant slightly
> winning on Apple silicon, but losing slightly more on an AMD Ryzen
> laptop CPU. So I went with the solid choice of using an on-stack
> buffer, same as in printf(3) (at least on BSD). Buffering at the end of
> the strbuf was not really faster; perhaps memmove(3) is just that much
> slower than memcpy(3).
I'm not sure if you did these tests initially, or if I nerd-sniped you
into it. Either way, I am happy to be able to hear the results. ;)
I guess it is not too surprising that they all come pretty close in
whole-process benchmarks. These are all micro-optimizations of a
relatively small portion of the total work the process is doing. Even
the strbuf_grow() checks are probably slower!
> Perhaps an optimized decimal_width() could change the picture somewhat,
> but I don't expect a big win. On the other hand I just told you how
> unreliable my expectations are, so there might be treasure after all. :)
I got identical times for cat-file's %(objectsize:disk) running your
version against the one below. Not wanting to figure out all of the
off-by-one corner cases myself, I checked stack overflow for an easy
recipe but couldn't find one. The version below was generated by
chatgpt, which looks plausibly correct to me.
-Peff
diff --git a/strbuf.c b/strbuf.c
index 9731ecdc1f..c26614a698 100644
--- a/strbuf.c
+++ b/strbuf.c
@@ -361,16 +361,52 @@ void strbuf_addf(struct strbuf *sb, const char *fmt, ...)
va_end(ap);
}
+static const uint64_t powers_of_10[] = {
+ 1ULL,
+ 10ULL,
+ 100ULL,
+ 1000ULL,
+ 10000ULL,
+ 100000ULL,
+ 1000000ULL,
+ 10000000ULL,
+ 100000000ULL,
+ 1000000000ULL,
+ 10000000000ULL,
+ 100000000000ULL,
+ 1000000000000ULL,
+ 10000000000000ULL,
+ 100000000000000ULL,
+ 1000000000000000ULL,
+ 10000000000000000ULL,
+ 100000000000000000ULL,
+ 1000000000000000000ULL,
+ 10000000000000000000ULL,
+};
+
+unsigned decimal_length_u64(uint64_t n)
+{
+ if (n == 0)
+ return 1;
+
+ unsigned b = 63 - __builtin_clzll(n);
+ /* approximate floor(log10(n)) */
+ unsigned t = (b * 1233) >> 12;
+ /* correct if estimate was low */
+ return t + 1 + (n >= powers_of_10[t + 1]);
+}
+
void strbuf_add_uint(struct strbuf *sb, uintmax_t value)
{
- char buf[DIV_ROUND_UP(bitsizeof(value) * 10, 33)];
- char *end = buf + sizeof(buf);
- char *p = end;
+ unsigned digits = decimal_length_u64(value);
+ char *p;
+ strbuf_grow(sb, digits);
+ p = sb->buf + digits;
do
*--p = "0123456789"[value % 10];
while (value /= 10);
- strbuf_add(sb, p, end - p);
+ strbuf_setlen(sb, sb->len + digits);
}
static void add_lines(struct strbuf *out,
^ permalink raw reply related
* Re: What's cooking in git.git (May 2026, #03)
From: Junio C Hamano @ 2026-05-13 16:06 UTC (permalink / raw)
To: Pushkar Singh; +Cc: git
In-Reply-To: <CALE2CrT9=5mOauUdzxJAEjeeD77RWGyXjLGGTObsk9R1eOrP0g@mail.gmail.com>
Pushkar Singh <pushkarkumarsingh1970@gmail.com> writes:
> I noticed my stash test patch did not appear in the list, so I just
> wanted to gently follow up in case it got missed.
>
> Patch Link: https://lore.kernel.org/git/20260505103332.43702-2-pushkarkumarsingh1970@gmail.com/T/#u
When somebody else discussed on the topic or if I find the topic may
be interesting, I often pick up such patches to keep in 'seen'.
I am not sure if it qualifies as "got missed", but it seems nobody
discussed the topic, and I wasn't sure what the point was myself.
Surely we can pile on tests that demonstrates a command works in a
certain way, but is this particular test demonstrate something we
are likely to break, or something?
^ permalink raw reply
* Re: [PATCH] hex: add and use strbuf_add_oid_hex()
From: Jeff King @ 2026-05-13 16:01 UTC (permalink / raw)
To: René Scharfe; +Cc: Git List
In-Reply-To: <183aa0fd-d455-4ec9-9c42-d511fac8b3e4@web.de>
On Wed, May 13, 2026 at 05:49:11PM +0200, René Scharfe wrote:
> Add a function for adding the full hexadecimal hash value of an object
> ID to a strbuf. It's thread-safe and slightly more efficient than using
> strbuf_addstr() with oid_to_hex() because it doesn't have to determine
> the length of the string or copy it from the intermediate static buffer.
>
> Add and apply a semantic patch to use it throughout the code base.
>
> I get a tiny speedup for git log showing a single hash per commit:
>
> Benchmark 1: ./git_main log --format=%H
> Time (mean ± σ): 91.2 ms ± 0.7 ms [User: 51.9 ms, System: 38.6 ms]
> Range (min … max): 89.8 ms … 92.6 ms 31 runs
>
> Benchmark 2: ./git log --format=%H
> Time (mean ± σ): 90.5 ms ± 0.7 ms [User: 51.0 ms, System: 38.8 ms]
> Range (min … max): 89.2 ms … 92.3 ms 32 runs
Probably the most extreme benchmark would be:
git cat-file --batch-all-objects --batch-check='%(objectname)'
which is really just dumping the oids from packfiles. I got ~3% speedup,
though like yours it's within the run-to-run noise.
I think this is worth doing solely for removing more instances of global
buffers, though.
-Peff
^ 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