* [PATCH v7 0/5] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com>
* --prune-merged now checks if a branch is merged into its own upstream
first. If the upstream is gone, it checks against the remote's default
branch instead. If neither exists, the branch is refused (use --force to
delete anyway).
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 | 33 +++
builtin/branch.c | 332 +++++++++++++++++++++++++++++--
t/t3200-branch.sh | 280 ++++++++++++++++++++++++++
4 files changed, 635 insertions(+), 17 deletions(-)
base-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v7
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v7
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v6:
1: fb9817b220 = 1: 22fa8515df branch: add --forked <remote>
2: 42a2f93d44 = 2: b443f0f367 branch: let delete_branches warn instead of error on bulk refusal
3: 604ecb8965 ! 3: a245009893 branch: add --prune-merged <remote>
@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi
+ been pruned upstream.
++
+As a safety check, branches with commits not yet integrated into
-+the remote's default branch are refused. With `--force` (or `-f`),
-+delete them regardless. The currently checked-out branch in any
-+worktree is always preserved.
++their upstream remote-tracking branch are refused; if the upstream
++itself is gone, the remote's default branch is consulted instead.
++With `--force` (or `-f`), delete refused branches regardless. The
++currently checked-out branch in any worktree is always preserved.
+
`-v`::
`-vv`::
@@ builtin/branch.c
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
+@@ builtin/branch.c: static const char *branch_get_color(enum color_branch ix)
+ }
+
+ static int branch_merged(int kind, const char *name,
+- struct commit *rev, struct commit *head_rev)
++ struct commit *rev, struct commit *head_rev,
++ int no_head_fallback)
+ {
+ /*
+ * This checks whether the merge bases of branch and HEAD (or
+@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
+ &oid, NULL)) != NULL)
+ reference_rev = lookup_commit_reference(the_repository,
+ &oid);
++
++ if (!reference_rev && no_head_fallback && upstream &&
++ starts_with(upstream, "refs/remotes/")) {
++ const char *remote_name = upstream + strlen("refs/remotes/");
++ const char *slash = strchr(remote_name, '/');
++ if (slash) {
++ struct strbuf head_ref = STRBUF_INIT;
++ strbuf_add(&head_ref, "refs/remotes/", strlen("refs/remotes/"));
++ strbuf_add(&head_ref, remote_name, slash - remote_name);
++ strbuf_addstr(&head_ref, "/HEAD");
++ if (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
++ head_ref.buf,
++ RESOLVE_REF_READING,
++ &oid, NULL))
++ reference_rev = lookup_commit_reference(the_repository,
++ &oid);
++ strbuf_release(&head_ref);
++ }
++ }
+ }
+- if (!reference_rev)
++ if (!reference_rev) {
++ if (no_head_fallback) {
++ free(reference_name_to_free);
++ return 0;
++ }
+ reference_rev = head_rev;
++ }
+
+ merged = reference_rev ? repo_in_merge_bases(the_repository, rev,
+ reference_rev) : 0;
+@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
+ * any of the following code, but during the transition period,
+ * a gentle reminder is in order.
+ */
+- if (head_rev != reference_rev) {
++ if (!no_head_fallback && head_rev != reference_rev) {
+ int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+ if (expect < 0)
+ exit(128);
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
-+ struct commit *head_rev_override,
-+ int use_head_rev_override,
++ int no_head_fallback,
int kinds, int force, int warn_only,
int *n_not_merged)
{
- struct commit *rev = lookup_commit_reference(the_repository, oid);
-+ int merged;
-+
- if (!force && !rev) {
+@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-+ if (use_head_rev_override) {
-+ if (!head_rev_override)
-+ return 0;
-+ merged = repo_in_merge_bases(the_repository, rev,
-+ head_rev_override);
-+ if (merged < 0)
-+ exit(128);
-+ } else {
-+ merged = branch_merged(kinds, branchname, rev, head_rev);
-+ }
-+ if (!force && !merged) {
++ if (!force && !branch_merged(kinds, branchname, rev, head_rev,
++ no_head_fallback)) {
if (warn_only) {
warning(_("the branch '%s' is not fully merged"),
branchname);
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
-static int delete_branches(int argc, const char **argv, int force, int kinds,
+static int delete_branches(int argc, const char **argv,
-+ struct commit **head_rev_overrides,
++ int no_head_fallback,
+ int force, int kinds,
int quiet, int warn_only, int *n_not_merged)
{
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
- check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force, warn_only, n_not_merged)) {
+ check_branch_commit(bname.buf, name, &oid, head_rev,
-+ head_rev_overrides ? head_rev_overrides[i] : NULL,
-+ !!head_rev_overrides,
++ no_head_fallback,
+ kinds, force, warn_only, n_not_merged)) {
if (!warn_only)
ret = 1;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
return 0;
}
-+static struct commit *resolve_remote_head(const char *remote_name)
-+{
-+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct strbuf head_ref = STRBUF_INIT;
-+ struct object_id oid;
-+ struct commit *commit = NULL;
-+
-+ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote_name);
-+ if (refs_resolve_ref_unsafe(refs, head_ref.buf, RESOLVE_REF_READING,
-+ &oid, NULL))
-+ commit = lookup_commit_reference(the_repository, &oid);
-+ strbuf_release(&head_ref);
-+ return commit;
-+}
-+
+static int prune_merged_branches(int argc, const char **argv, int force,
+ int quiet)
+{
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
-+ struct commit **head_rev_overrides = NULL;
-+ size_t alloc = 0;
+ struct string_list_item *item;
+ int n_not_merged = 0;
+ int ret = 0;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ struct branch *branch;
+ const char *push_ref;
+ const char *upstream;
-+ const char *remote_name;
-+ const char *slash;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ if (branch_checked_out(full.buf)) {
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ if (string_list_has_string(&protected_default_refs, push_ref))
+ continue;
+
-+ ALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);
-+ remote_name = push_ref + strlen("refs/remotes/");
-+ slash = strchr(remote_name, '/');
-+ if (slash) {
-+ char *name = xstrndup(remote_name, slash - remote_name);
-+ head_rev_overrides[deletable.nr] = resolve_remote_head(name);
-+ free(name);
-+ } else {
-+ head_rev_overrides[deletable.nr] = NULL;
-+ }
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
-+ head_rev_overrides, force,
++ 1, force,
+ FILTER_REFS_BRANCHES, quiet,
+ 1, &n_not_merged);
+
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ n_not_merged);
+
+ strvec_clear(&deletable);
-+ free(head_rev_overrides);
+ string_list_clear(&candidates, 0);
+ string_list_clear(&protected_default_refs, 0);
+ return ret;
@@ builtin/branch.c: int cmd_branch(int argc,
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
-+ ret = delete_branches(argc, argv, NULL, delete > 1, filter.kind,
++ ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
quiet, 0, NULL);
goto out;
} else if (forked) {
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+ test_must_fail git -C pm-force rev-parse --verify refs/heads/one
+'
+
-+test_expect_success '--prune-merged measures merged-ness against <remote>/HEAD, not local HEAD' '
-+ test_when_finished "rm -rf pm-head-indep" &&
-+ git clone pm-upstream pm-head-indep &&
-+ git -C pm-head-indep branch one --track origin/one &&
-+ git -C pm-head-indep update-ref -d refs/remotes/origin/one &&
++test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '
++ test_when_finished "rm -rf pm-fallback" &&
++ git clone pm-upstream pm-fallback &&
++ git -C pm-fallback branch one --track origin/one &&
++ git -C pm-fallback update-ref -d refs/remotes/origin/one &&
+ # Detach HEAD to an unrelated commit so the candidate is not
-+ # reachable from local HEAD; it is still reachable from
-+ # refs/remotes/origin/HEAD, which is what should matter.
-+ git -C pm-head-indep commit --allow-empty -m unrelated &&
-+ git -C pm-head-indep checkout --detach &&
-+ git -C pm-head-indep reset --hard HEAD^ &&
++ # reachable from local HEAD. The upstream origin/one is now
++ # gone; the merged-ness check should fall back to
++ # refs/remotes/origin/HEAD, against which "one" is reachable.
++ git -C pm-fallback commit --allow-empty -m unrelated &&
++ git -C pm-fallback checkout --detach &&
++ git -C pm-fallback reset --hard HEAD^ &&
+
-+ git -C pm-head-indep branch --prune-merged origin &&
++ git -C pm-fallback branch --prune-merged origin &&
+
-+ test_must_fail git -C pm-head-indep rev-parse --verify refs/heads/one
++ test_must_fail git -C pm-fallback rev-parse --verify refs/heads/one
+'
+
-+test_expect_success '--prune-merged skips merged-ness check when <remote>/HEAD is unset' '
-+ test_when_finished "rm -rf pm-no-head" &&
-+ git clone pm-upstream pm-no-head &&
-+ git -C pm-no-head checkout -b one --track origin/one &&
-+ test_commit -C pm-no-head unpushed &&
-+ git -C pm-no-head checkout - &&
++test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '
++ test_when_finished "rm -rf pm-both-gone" &&
++ git clone pm-upstream pm-both-gone &&
++ git -C pm-both-gone checkout -b one --track origin/one &&
++ test_commit -C pm-both-gone unpushed &&
++ git -C pm-both-gone checkout - &&
+
-+ git -C pm-no-head update-ref -d refs/remotes/origin/HEAD &&
-+ git -C pm-no-head update-ref -d refs/remotes/origin/one &&
-+ git -C pm-no-head branch --prune-merged origin &&
++ git -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&
++ git -C pm-both-gone update-ref -d refs/remotes/origin/one &&
++ git -C pm-both-gone branch --prune-merged origin 2>err &&
++ test_grep "not fully merged" err &&
+
-+ test_must_fail git -C pm-no-head rev-parse --verify refs/heads/one
++ git -C pm-both-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
4: 717fc6758e ! 4: 2c3f751569 branch: add branch.<name>.pruneMerged opt-out
@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi
+ that name has since been pruned upstream.
+
As a safety check, branches with commits not yet integrated into
- the remote's default branch are refused. With `--force` (or `-f`),
- delete them regardless. The currently checked-out branch in any
--worktree is always preserved.
-+worktree is always preserved, as is any branch with
-+`branch.<name>.pruneMerged` set to `false`.
+ their upstream remote-tracking branch are refused; if the upstream
+ itself is gone, the remote's default branch is consulted instead.
+ With `--force` (or `-f`), delete refused branches regardless. The
+-currently checked-out branch in any worktree is always preserved.
++currently checked-out branch in any worktree is always preserved,
++as is any branch with `branch.<name>.pruneMerged` set to `false`.
`-v`::
`-vv`::
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
struct branch *branch;
const char *push_ref;
const char *upstream;
- const char *remote_name;
- const char *slash;
+ int opt_out = 0;
strbuf_addf(&full, "refs/heads/%s", short_name);
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ }
+ strbuf_release(&key);
- ALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);
- remote_name = push_ref + strlen("refs/remotes/");
+ strvec_push(&deletable, short_name);
+ }
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul
5: be25572957 ! 5: f79707ce7c branch: add --all-remotes flag
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: delete them regardless. The currently checked-out branch in any
- worktree is always preserved, as is any branch with
- `branch.<name>.pruneMerged` set to `false`.
+@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete refused branches regardless. The
+ currently checked-out branch in any worktree is always preserved,
+ as is any branch with `branch.<name>.pruneMerged` set to `false`.
+`--all-remotes`::
+ With `--forked` or `--prune-merged`, act on every
@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
for_each_string_list_item(item, &out)
puts(item->string);
-@@ builtin/branch.c: static struct commit *resolve_remote_head(const char *remote_name)
- return commit;
+@@ 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,
--
gitgitgadget
^ permalink raw reply
* [PATCH v7 1/5] branch: add --forked <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.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 v7 2/5] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.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 v7 3/5] branch: add --prune-merged <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Delete the local branches that --forked <remote> would list,
refusing any whose tip is not reachable from the remote's default
branch. With --force, delete unconditionally. The currently
checked-out branch in any worktree is always preserved.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 16 +++
builtin/branch.c | 181 ++++++++++++++++++++++++++++++----
t/t3200-branch.sh | 146 +++++++++++++++++++++++++++
3 files changed, 323 insertions(+), 20 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5773104cd3..1a5a5a9a54 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <remote>...
+git branch [-f] --prune-merged <remote>...
DESCRIPTION
-----------
@@ -211,6 +212,21 @@ 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 when the branch's
+ push destination remote-tracking branch (the branch `git push`
+ would update; see `branch_get_push` semantics) no longer
+ resolves locally. In other words: the branch was pushed
+ under some name on _<remote>_, and that name has since
+ been pruned upstream.
++
+As a safety check, branches with commits not yet integrated into
+their upstream remote-tracking branch are refused; if the upstream
+itself is gone, the remote's default branch is consulted instead.
+With `--force` (or `-f`), delete refused branches regardless. The
+currently checked-out branch in any worktree is always preserved.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1941f8a9ad..2eb7433b28 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"
@@ -131,7 +132,8 @@ static const char *branch_get_color(enum color_branch ix)
}
static int branch_merged(int kind, const char *name,
- struct commit *rev, struct commit *head_rev)
+ struct commit *rev, struct commit *head_rev,
+ int no_head_fallback)
{
/*
* This checks whether the merge bases of branch and HEAD (or
@@ -155,9 +157,33 @@ static int branch_merged(int kind, const char *name,
&oid, NULL)) != NULL)
reference_rev = lookup_commit_reference(the_repository,
&oid);
+
+ if (!reference_rev && no_head_fallback && upstream &&
+ starts_with(upstream, "refs/remotes/")) {
+ const char *remote_name = upstream + strlen("refs/remotes/");
+ const char *slash = strchr(remote_name, '/');
+ if (slash) {
+ struct strbuf head_ref = STRBUF_INIT;
+ strbuf_add(&head_ref, "refs/remotes/", strlen("refs/remotes/"));
+ strbuf_add(&head_ref, remote_name, slash - remote_name);
+ strbuf_addstr(&head_ref, "/HEAD");
+ if (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+ head_ref.buf,
+ RESOLVE_REF_READING,
+ &oid, NULL))
+ reference_rev = lookup_commit_reference(the_repository,
+ &oid);
+ strbuf_release(&head_ref);
+ }
+ }
}
- if (!reference_rev)
+ if (!reference_rev) {
+ if (no_head_fallback) {
+ free(reference_name_to_free);
+ return 0;
+ }
reference_rev = head_rev;
+ }
merged = reference_rev ? repo_in_merge_bases(the_repository, rev,
reference_rev) : 0;
@@ -171,7 +197,7 @@ static int branch_merged(int kind, const char *name,
* any of the following code, but during the transition period,
* a gentle reminder is in order.
*/
- if (head_rev != reference_rev) {
+ if (!no_head_fallback && head_rev != reference_rev) {
int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
if (expect < 0)
exit(128);
@@ -192,6 +218,7 @@ static int branch_merged(int kind, const char *name,
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
+ int no_head_fallback,
int kinds, int force, int warn_only,
int *n_not_merged)
{
@@ -200,7 +227,8 @@ static int check_branch_commit(const char *branchname, const char *refname,
error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
+ if (!force && !branch_merged(kinds, branchname, rev, head_rev,
+ no_head_fallback)) {
if (warn_only) {
warning(_("the branch '%s' is not fully merged"),
branchname);
@@ -227,7 +255,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;
@@ -317,8 +347,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, warn_only, n_not_merged)) {
+ check_branch_commit(bname.buf, name, &oid, head_rev,
+ no_head_fallback,
+ kinds, force, warn_only, n_not_merged)) {
if (!warn_only)
ret = 1;
goto next;
@@ -753,36 +784,139 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
return 0;
}
-static int list_forked_branches(int argc, const char **argv)
+static void collect_default_branch_refs(const struct string_list *remote_names,
+ struct string_list *out)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list_item *item;
+
+ for_each_string_list_item(item, remote_names) {
+ struct strbuf head = STRBUF_INIT;
+ const char *target;
+
+ strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
+ target = refs_resolve_ref_unsafe(refs, head.buf,
+ RESOLVE_REF_NO_RECURSE,
+ NULL, NULL);
+ if (target && starts_with(target, "refs/remotes/"))
+ string_list_insert(out, target);
+ strbuf_release(&head);
+ }
+}
+
+static void collect_forked_set(int argc, const char **argv,
+ struct string_list *protected_default_refs,
+ struct string_list *out)
{
struct string_list remote_names = STRING_LIST_INIT_NODUP;
struct string_list tracking_refs = STRING_LIST_INIT_DUP;
- struct string_list out = STRING_LIST_INIT_DUP;
- struct string_list_item *item;
struct forked_cb cb = {
.remote_names = &remote_names,
.tracking_refs = &tracking_refs,
- .out = &out,
+ .out = out,
};
- if (!argc)
- die(_("--forked requires at least one <remote>"));
-
parse_forked_args(argc, argv, &remote_names, &tracking_refs);
refs_for_each_branch_ref(get_main_ref_store(the_repository),
collect_forked_branch, &cb);
- string_list_sort(&out);
- for_each_string_list_item(item, &out)
- puts(item->string);
+ string_list_sort(out);
+
+ if (protected_default_refs)
+ collect_default_branch_refs(&remote_names, protected_default_refs);
string_list_clear(&remote_names, 0);
string_list_clear(&tracking_refs, 0);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, NULL, &out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
string_list_clear(&out, 0);
return 0;
}
+static int prune_merged_branches(int argc, const char **argv, int force,
+ int quiet)
+{
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int n_not_merged = 0;
+ int ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <remote>"));
+
+ collect_forked_set(argc, argv, &protected_default_refs, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
+ struct strbuf full = STRBUF_INIT;
+ struct branch *branch;
+ const char *push_ref;
+ 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 &&
+ string_list_has_string(&protected_default_refs, upstream)) {
+ const char *leaf = strrchr(upstream, '/');
+ if (leaf && !strcmp(leaf + 1, short_name))
+ continue;
+ }
+
+ push_ref = branch ? branch_get_push(branch, NULL) : NULL;
+ if (!push_ref)
+ continue;
+ if (refs_ref_exists(get_main_ref_store(the_repository),
+ push_ref))
+ continue;
+ if (string_list_has_string(&protected_default_refs, push_ref))
+ continue;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ 1, force,
+ FILTER_REFS_BRANCHES, quiet,
+ 1, &n_not_merged);
+
+ if (n_not_merged && !quiet)
+ fprintf(stderr,
+ Q_("Skipped %d branch that is not fully merged; "
+ "re-run with --force to delete it anyway.\n",
+ "Skipped %d branches that are not fully merged; "
+ "re-run with --force to delete them anyway.\n",
+ n_not_merged),
+ n_not_merged);
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
+ string_list_clear(&protected_default_refs, 0);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -825,6 +959,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 +1015,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 +1061,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 +1071,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 +1109,15 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
quiet, 0, NULL);
goto out;
} else if (forked) {
ret = list_forked_branches(argc, argv);
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, force, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 24a3ec44ee..f0d1250dbf 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,4 +1771,150 @@ 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 branch one base &&
+ git -C pm-upstream branch two base
+'
+
+test_expect_success '--prune-merged deletes branches whose push ref is gone' '
+ test_when_finished "rm -rf pm-clean" &&
+ git clone pm-upstream pm-clean &&
+ git -C pm-clean branch one --track origin/one &&
+ git -C pm-clean branch two --track origin/two &&
+
+ git -C pm-clean update-ref -d refs/remotes/origin/one &&
+ git -C pm-clean branch --prune-merged origin &&
+
+ test_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&
+ git -C pm-clean rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '
+ test_when_finished "rm -rf pm-inflight" &&
+ git clone pm-upstream pm-inflight &&
+ git -C pm-inflight branch one --track origin/one &&
+
+ git -C pm-inflight branch --prune-merged origin &&
+
+ git -C pm-inflight rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged skips branches with unpushed commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged checkout -b one --track origin/one &&
+ test_commit -C pm-unmerged unpushed &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged update-ref -d refs/remotes/origin/one &&
+ git -C pm-unmerged branch --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep "Skipped 1 branch" err &&
+ test_grep "re-run with --force" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '
+ test_when_finished "rm -rf pm-force" &&
+ git clone pm-upstream pm-force &&
+ git -C pm-force checkout -b one --track origin/one &&
+ test_commit -C pm-force unpushed &&
+ git -C pm-force checkout - &&
+
+ git -C pm-force update-ref -d refs/remotes/origin/one &&
+ git -C pm-force branch --force --prune-merged origin &&
+
+ test_must_fail git -C pm-force rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '
+ test_when_finished "rm -rf pm-fallback" &&
+ git clone pm-upstream pm-fallback &&
+ git -C pm-fallback branch one --track origin/one &&
+ git -C pm-fallback update-ref -d refs/remotes/origin/one &&
+ # Detach HEAD to an unrelated commit so the candidate is not
+ # reachable from local HEAD. The upstream origin/one is now
+ # gone; the merged-ness check should fall back to
+ # refs/remotes/origin/HEAD, against which "one" is reachable.
+ git -C pm-fallback commit --allow-empty -m unrelated &&
+ git -C pm-fallback checkout --detach &&
+ git -C pm-fallback reset --hard HEAD^ &&
+
+ git -C pm-fallback branch --prune-merged origin &&
+
+ test_must_fail git -C pm-fallback rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '
+ test_when_finished "rm -rf pm-both-gone" &&
+ git clone pm-upstream pm-both-gone &&
+ git -C pm-both-gone checkout -b one --track origin/one &&
+ test_commit -C pm-both-gone unpushed &&
+ git -C pm-both-gone checkout - &&
+
+ git -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&
+ git -C pm-both-gone update-ref -d refs/remotes/origin/one &&
+ git -C pm-both-gone branch --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+
+ git -C pm-both-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head checkout -b one --track origin/one &&
+
+ git -C pm-head update-ref -d refs/remotes/origin/one &&
+ git -C pm-head branch --force --prune-merged origin &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged deletes when push ref differs from upstream' '
+ test_when_finished "rm -rf pm-pushdiff" &&
+ git clone pm-upstream pm-pushdiff &&
+ git -C pm-pushdiff config push.default current &&
+ git -C pm-pushdiff branch --track topic-a origin/one &&
+
+ git -C pm-pushdiff branch --force --prune-merged origin &&
+
+ test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
+'
+
+test_expect_success '--prune-merged spares the local default branch' '
+ test_when_finished "rm -rf pm-default" &&
+ git clone pm-upstream pm-default &&
+ git -C pm-default config push.default current &&
+ git -C pm-default checkout --detach &&
+ git -C pm-default branch --prune-merged origin &&
+ git -C pm-default rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
+ test_when_finished "rm -rf pm-default-alias" &&
+ git clone pm-upstream pm-default-alias &&
+ git -C pm-default-alias config push.default current &&
+ git -C pm-default-alias branch --track trunk origin/main &&
+ git -C pm-default-alias checkout --detach &&
+ git -C pm-default-alias branch --force --prune-merged origin &&
+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
+'
+
+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '
+ test_when_finished "rm -rf pm-pushdefault" &&
+ git clone pm-upstream pm-pushdefault &&
+ git -C pm-pushdefault branch --track topic origin/one &&
+ git -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&
+ git -C pm-pushdefault update-ref -d refs/remotes/origin/one &&
+ git -C pm-pushdefault update-ref -d refs/remotes/origin/main &&
+ git -C pm-pushdefault checkout --detach &&
+ git -C pm-pushdefault branch --prune-merged origin &&
+ git -C pm-pushdefault rev-parse --verify refs/heads/topic
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v7 4/5] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
--prune-merged (and from fetch --prune-merged), even with --force.
Useful for keeping a topic branch around between rounds.
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 | 10 ++++----
builtin/branch.c | 31 +++++++++++++++++++++----
t/t3200-branch.sh | 40 ++++++++++++++++++++++++++++++++
4 files changed, 79 insertions(+), 9 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 1a5a5a9a54..87a26da0cc 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -216,16 +216,16 @@ Each _<remote>_ may be either the name of a configured remote
Delete the local branches that `--forked` would list for
the same _<remote>_ arguments, but only when the branch's
push destination remote-tracking branch (the branch `git push`
- would update; see `branch_get_push` semantics) no longer
- resolves locally. In other words: the branch was pushed
- under some name on _<remote>_, and that name has since
- been pruned upstream.
+ would update) no longer resolves locally. In other words:
+ the branch was pushed under some name on _<remote>_, and
+ that name has since been pruned upstream.
+
As a safety check, branches with commits not yet integrated into
their upstream remote-tracking branch are refused; if the upstream
itself is gone, the remote's default branch is consulted instead.
With `--force` (or `-f`), delete refused branches regardless. The
-currently checked-out branch in any worktree is always preserved.
+currently checked-out branch in any worktree is always preserved,
+as is any branch with `branch.<name>.pruneMerged` set to `false`.
`-v`::
`-vv`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 2eb7433b28..c48af54301 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -864,13 +864,16 @@ static int prune_merged_branches(int argc, const char **argv, int force,
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 *push_ref;
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);
@@ -880,18 +883,38 @@ static int prune_merged_branches(int argc, const char **argv, int force,
if (upstream &&
string_list_has_string(&protected_default_refs, upstream)) {
const char *leaf = strrchr(upstream, '/');
- if (leaf && !strcmp(leaf + 1, short_name))
+ if (leaf && !strcmp(leaf + 1, short_name)) {
+ strbuf_release(&key);
continue;
+ }
}
push_ref = branch ? branch_get_push(branch, NULL) : NULL;
- if (!push_ref)
+ if (!push_ref) {
+ strbuf_release(&key);
continue;
+ }
if (refs_ref_exists(get_main_ref_store(the_repository),
- push_ref))
+ push_ref)) {
+ strbuf_release(&key);
+ continue;
+ }
+ if (string_list_has_string(&protected_default_refs, push_ref)) {
+ strbuf_release(&key);
continue;
- if (string_list_has_string(&protected_default_refs, push_ref))
+ }
+
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+ 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 f0d1250dbf..23b82615f5 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1917,4 +1917,44 @@ test_expect_success '--prune-merged spares branches whose push ref is the defaul
git -C pm-pushdefault rev-parse --verify refs/heads/topic
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout branch one --track origin/one &&
+ git -C pm-optout branch two --track origin/two &&
+ git -C pm-optout config branch.one.pruneMerged false &&
+
+ git -C pm-optout update-ref -d refs/remotes/origin/one &&
+ git -C pm-optout update-ref -d refs/remotes/origin/two &&
+ git -C pm-optout branch --prune-merged origin 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ 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 one --track origin/one &&
+ test_commit -C pm-optout-force unpushed &&
+ git -C pm-optout-force checkout - &&
+ git -C pm-optout-force config branch.one.pruneMerged false &&
+
+ git -C pm-optout-force update-ref -d refs/remotes/origin/one &&
+ git -C pm-optout-force branch --force --prune-merged origin &&
+
+ git -C pm-optout-force rev-parse --verify refs/heads/one
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+ test_when_finished "rm -rf pm-optout-d" &&
+ git clone pm-upstream pm-optout-d &&
+ git -C pm-optout-d branch one --track origin/one &&
+ git -C pm-optout-d 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 v7 5/5] branch: add --all-remotes flag
From: Harald Nordgren via GitGitGadget @ 2026-05-12 8:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v7.git.git.1778574229.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 | 40 ++++++++++++++++++++++++++++++++++
3 files changed, 76 insertions(+), 14 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 87a26da0cc..6fde8f642e 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 [-f] --prune-merged <remote>...
+git branch --forked (<remote>... | --all-remotes)
+git branch [-f] --prune-merged (<remote>... | --all-remotes)
DESCRIPTION
-----------
@@ -227,6 +227,11 @@ With `--force` (or `-f`), delete refused branches regardless. The
currently checked-out branch in any worktree is always preserved,
as is any branch with `branch.<name>.pruneMerged` set to `false`.
+`--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 c48af54301..22c30164ca 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -715,6 +715,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)
@@ -804,7 +811,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)
{
@@ -817,6 +824,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);
@@ -830,15 +839,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);
@@ -846,8 +855,8 @@ 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 all_remotes, int force, int quiet)
{
struct string_list candidates = STRING_LIST_INIT_DUP;
struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
@@ -856,10 +865,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,
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;
@@ -983,6 +993,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 */
@@ -1040,6 +1051,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")),
@@ -1083,6 +1097,9 @@ 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)
@@ -1136,10 +1153,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, force, quiet);
+ ret = prune_merged_branches(argc, argv, all_remotes, force, quiet);
goto out;
} else if (show_current) {
print_current_branch_name();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 23b82615f5..4bd92fe430 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 &&
@@ -1957,4 +1978,23 @@ 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" &&
+ git clone pm-upstream pm-allremotes &&
+ test_create_repo pm-other &&
+ test_commit -C pm-other other-base &&
+ git -C pm-other branch foreign other-base &&
+ git -C pm-allremotes remote add other ../pm-other &&
+ git -C pm-allremotes fetch other &&
+ git -C pm-allremotes branch one --track origin/one &&
+ git -C pm-allremotes branch foreign --track other/foreign &&
+
+ git -C pm-allremotes update-ref -d refs/remotes/origin/one &&
+ git -C pm-allremotes update-ref -d refs/remotes/other/foreign &&
+ git -C pm-allremotes branch --force --prune-merged --all-remotes &&
+
+ 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 v2 0/2] builtin/maintenance: fix locking and respect "gc.auto"
From: Patrick Steinhardt @ 2026-05-12 8:30 UTC (permalink / raw)
To: git
Cc: Jean-Christophe Manciot, Mikael Magnusson, Jeff King, Taylor Blau,
Derrick Stolee, Junio C Hamano
In-Reply-To: <20260511-pks-maintenance-fix-lock-with-detach-v1-0-ccd7d62c9a40@pks.im>
Hi,
this patch series addresses the issues reported in [1]. The series is
built on top of Git 2.54.0.
Changes in v2:
- Clarify comment when dropping ownership of the lock in the parent
process.
- Properly treat "gc.auto" as an integer, not a boolean.
- Link to v1: https://patch.msgid.link/20260511-pks-maintenance-fix-lock-with-detach-v1-0-ccd7d62c9a40@pks.im
Thanks!
Patrick
[1]: <CAKcFC3arsYExb5dCMQspo4V9UFDadFaj8Q4PUsMWZJw_eYrMzA@mail.gmail.com>
---
Patrick Steinhardt (2):
builtin/maintenance: fix locking with "--detach"
run-command: honor "gc.auto" for auto-maintenance
builtin/gc.c | 27 ++++++++++++++--
lockfile.c | 9 ++++++
lockfile.h | 10 ++++++
run-command.c | 10 ++++--
setup.c | 31 +++++++++++++------
setup.h | 1 +
t/t7900-maintenance.sh | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 157 insertions(+), 14 deletions(-)
Range-diff versus v1:
1: d0609c03b4 ! 1: 8dda16ec8d builtin/maintenance: fix locking with "--detach"
@@ builtin/gc.c: static int maintenance_run_tasks(struct maintenance_run_opts *opts
+ lock_file_reassign_owner(&lk, getpid());
+ } else if (child_pid > 0) {
+ /*
-+ * We're in the parent process, so we assign ownership
-+ * of the lockfile to the child and then exit immediately.
++ * We're in the parent process, so we drop ownership of
++ * the lockfile to prevent us from removing it upon
++ * exit.
+ */
+ lock_file_reassign_owner(&lk, child_pid);
+ exit(0);
2: 959ce46f7d < -: ---------- run-command: honor "gc.auto" for auto-maintenance
-: ---------- > 2: d5507b5dd2 run-command: honor "gc.auto" for auto-maintenance
---
base-commit: 13ef77ce6e222bef3ab145642e6ef1486075211c
change-id: 20260511-pks-maintenance-fix-lock-with-detach-a608e9b6adeb
^ permalink raw reply
* [PATCH v2 1/2] builtin/maintenance: fix locking with "--detach"
From: Patrick Steinhardt @ 2026-05-12 8:30 UTC (permalink / raw)
To: git
Cc: Jean-Christophe Manciot, Mikael Magnusson, Jeff King, Taylor Blau,
Derrick Stolee, Junio C Hamano
In-Reply-To: <20260512-pks-maintenance-fix-lock-with-detach-v2-0-dc6f2d284b6d@pks.im>
When running git-maintenance(1), we create a lockfile that is supposed
to keep other maintenance processes from running at the same time. This
lockfile is broken though in case the "--detach" flag is passed: the
lockfile is created by the parent process and will be cleaned up either
manually or on exit. But when detaching, the parent will exit before all
of the background maintenance tasks have been ran, and consequently the
lock only covers a smaller part of the whole maintenance process.
Fix this bug by introducing two new functions:
- `daemonize_without_exit()` is the same as `daemonize()`, but doesn't
call exit(3p) for the parent process.
- `lock_file_reassign_owner()` reassigns the owner of its owned
tempfiles so that they don't get unlinked anymore when the previous
owner exits.
Together this allows us to reassign ownership of the lockfile after we
have daemonized so that the lockfile is now owned by the child process.
Reported-by: Jean-Christophe Manciot <actionmystique@gmail.com>
Helped-by: Jeff King <peff@peff.net>
Helped-by: Taylor Blau <me@ttaylorr.com>
Helped-by: Derrick Stolee <stolee@gmail.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
builtin/gc.c | 27 +++++++++++++++++++++--
lockfile.c | 9 ++++++++
lockfile.h | 10 +++++++++
setup.c | 31 +++++++++++++++++++--------
setup.h | 1 +
t/t7900-maintenance.sh | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 125 insertions(+), 11 deletions(-)
diff --git a/builtin/gc.c b/builtin/gc.c
index 3a71e314c9..d866c19b92 100644
--- a/builtin/gc.c
+++ b/builtin/gc.c
@@ -1810,10 +1810,33 @@ static int maintenance_run_tasks(struct maintenance_run_opts *opts,
TASK_PHASE_FOREGROUND))
result = 1;
- /* Failure to daemonize is ok, we'll continue in foreground. */
if (opts->detach > 0) {
+ pid_t child_pid;
+
trace2_region_enter("maintenance", "detach", the_repository);
- daemonize();
+
+ child_pid = daemonize_without_exit();
+ if (!child_pid) {
+ /*
+ * We're in the child process, so we take ownership of
+ * the lockfile.
+ */
+ lock_file_reassign_owner(&lk, getpid());
+ } else if (child_pid > 0) {
+ /*
+ * We're in the parent process, so we drop ownership of
+ * the lockfile to prevent us from removing it upon
+ * exit.
+ */
+ lock_file_reassign_owner(&lk, child_pid);
+ exit(0);
+ } else {
+ /*
+ * Failure to daemonize is ok, we'll continue in
+ * foreground.
+ */
+ }
+
trace2_region_leave("maintenance", "detach", the_repository);
}
diff --git a/lockfile.c b/lockfile.c
index 7add2f136a..96aab3c885 100644
--- a/lockfile.c
+++ b/lockfile.c
@@ -356,3 +356,12 @@ int rollback_lock_file(struct lock_file *lk)
delete_tempfile(&lk->pid_tempfile);
return delete_tempfile(&lk->tempfile);
}
+
+void lock_file_reassign_owner(struct lock_file *lk, pid_t owner)
+{
+ if (!is_lock_file_locked(lk))
+ BUG("cannot reassign ownership of unlocked lockfile");
+ lk->tempfile->owner = owner;
+ if (lk->pid_tempfile)
+ lk->pid_tempfile->owner = owner;
+}
diff --git a/lockfile.h b/lockfile.h
index e7233f28de..0b10b624fa 100644
--- a/lockfile.h
+++ b/lockfile.h
@@ -341,4 +341,14 @@ static inline int commit_lock_file_to(struct lock_file *lk, const char *path)
*/
int rollback_lock_file(struct lock_file *lk);
+/*
+ * Reassign ownership of the lockfile to a different process.
+ *
+ * This is intended for use after `fork(2)`-ing. The parent transfers ownership
+ * to the daemonized child so that its atexit handler does not unlink the lock
+ * that should outlive it, and the child claims the inherited tempfiles so that
+ * they are cleaned up when the daemon exits.
+ */
+void lock_file_reassign_owner(struct lock_file *lk, pid_t owner);
+
#endif /* LOCKFILE_H */
diff --git a/setup.c b/setup.c
index 7ec4427368..34deb6e985 100644
--- a/setup.c
+++ b/setup.c
@@ -2156,20 +2156,18 @@ void sanitize_stdfds(void)
close(fd);
}
-int daemonize(void)
+pid_t daemonize_without_exit(void)
{
#ifdef NO_POSIX_GOODIES
errno = ENOSYS;
return -1;
#else
- switch (fork()) {
- case 0:
- break;
- case -1:
- die_errno(_("fork failed"));
- default:
- exit(0);
- }
+ pid_t pid = fork();
+ if (pid < 0)
+ return -1;
+ if (pid > 0)
+ return pid;
+
if (setsid() == -1)
die_errno(_("setsid failed"));
close(0);
@@ -2180,6 +2178,21 @@ int daemonize(void)
#endif
}
+int daemonize(void)
+{
+#ifdef NO_POSIX_GOODIES
+ errno = ENOSYS;
+ return -1;
+#else
+ pid_t pid = daemonize_without_exit();
+ if (pid < 0)
+ die_errno(_("fork failed"));
+ if (pid > 0)
+ exit(0);
+ return 0;
+#endif
+}
+
struct template_dir_cb_data {
char *path;
int initialized;
diff --git a/setup.h b/setup.h
index 80bc6e5f07..396af8d808 100644
--- a/setup.h
+++ b/setup.h
@@ -150,6 +150,7 @@ int path_inside_repo(const char *prefix, const char *path);
void sanitize_stdfds(void);
int daemonize(void);
+pid_t daemonize_without_exit(void);
/*
* GIT_REPO_VERSION is the version we write by default. The
diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh
index 4700beacc1..df0bbc1669 100755
--- a/t/t7900-maintenance.sh
+++ b/t/t7900-maintenance.sh
@@ -1438,6 +1438,64 @@ test_expect_success '--no-detach causes maintenance to not run in background' '
)
'
+test_expect_success PIPE '--detach holds maintenance lock until daemonized child exits' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ git config maintenance.auto false &&
+ git config core.lockfilepid true &&
+
+ git remote add origin /does/not/exist &&
+ git config set remote.origin.uploadpack "cat fifo-uploadpack" &&
+
+ mkfifo fifo-uploadpack fifo-maint &&
+
+ # Open the maintenance FIFO, as otherwise spawning
+ # git-maintenance(1) would block. Note that we need to open it
+ # as read-write, as otherwise we would block here already.
+ exec 9<>fifo-maint &&
+
+ { git maintenance run --task=prefetch --detach 7>&9 & } &&
+ parent="$!" &&
+
+ # Reap the parent process so that the exec call below will not
+ # get SIGCHLD.
+ wait "$parent" &&
+
+ # Open the git-upload-pack(1) FIFO for writing, which will
+ # block until the upload-pack script opens it for reading. Once
+ # exec returns, we know that the daemonized child is alive and
+ # pinned.
+ exec 8>fifo-uploadpack &&
+
+ test_path_is_file .git/objects/maintenance.lock &&
+ test_path_is_file .git/objects/"maintenance~pid.lock" &&
+
+ # Verify that the maintenance.lock still exists, and
+ # that it was created by the parent process, not the
+ # child.
+ echo "pid $parent" >expect &&
+ test_cmp expect .git/objects/"maintenance~pid.lock" &&
+
+ # Reopen the maintenance FIFO as read-only so that
+ # git-maintenance(1) is the only writer. This will cause it to
+ # close the FIFO once the process exits.
+ exec 9<&- &&
+ exec 9<fifo-maint &&
+
+ # Close the FIFO used by git-upload-pack(1) to unblock it and
+ # then wait until the maintenance FIFO is closed by
+ # git-maintenance(1), indicating that it has exited.
+ exec 8>&- &&
+ cat <&9 &&
+
+ test_path_is_missing .git/objects/maintenance.lock &&
+ test_path_is_missing .git/objects/"maintenance~pid.lock"
+ )
+'
+
test_expect_success '--detach causes maintenance to run in background' '
test_when_finished "rm -rf repo" &&
git init repo &&
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related
* [PATCH v2 2/2] run-command: honor "gc.auto" for auto-maintenance
From: Patrick Steinhardt @ 2026-05-12 8:30 UTC (permalink / raw)
To: git
Cc: Jean-Christophe Manciot, Mikael Magnusson, Jeff King, Taylor Blau,
Derrick Stolee, Junio C Hamano
In-Reply-To: <20260512-pks-maintenance-fix-lock-with-detach-v2-0-dc6f2d284b6d@pks.im>
The "gc.auto" configuration has traditionally been used to turn off
running git-gc(1) as part of our auto-maintenance. We have eventually
switched over to git-maintenance(1) in a95ce12430 (maintenance: replace
run_auto_gc(), 2020-09-17), and with 1942d48380 (maintenance: optionally
skip --auto process, 2020-08-28) we have introduced "maintenance.auto"
to control whether or not to run auto-maintenance.
At that point though we still shelled out to git-gc(1) internally. So
if "gc.auto=0" was set we would still _execute_ git-maintenance(1), but
the command would have exited fast because git-gc(1) itself knew to
honor the config key.
This has recently changed though, as we have adapted the default
maintenance strategy to not use git-gc(1) anymore. The consequence is
that "gc.auto=0" doesn't have an effect anymore, which is a somewhat
surprising change in behaviour for our users.
Adapt `run_auto_maintenance()` so that it knows to also read "gc.auto",
similar to how it also reads both "maintenance.autoDetach" and
"gc.autoDetach".
Reported-by: Jean-Christophe Manciot <actionmystique@gmail.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
run-command.c | 10 +++++++---
t/t7900-maintenance.sh | 25 +++++++++++++++++++++++++
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/run-command.c b/run-command.c
index c146a56532..28202a81d8 100644
--- a/run-command.c
+++ b/run-command.c
@@ -1944,10 +1944,14 @@ void run_processes_parallel(const struct run_process_parallel_opts *opts)
int prepare_auto_maintenance(struct repository *r, int quiet,
struct child_process *maint)
{
- int enabled, auto_detach;
+ int enabled = 1, auto_detach;
- if (!repo_config_get_bool(r, "maintenance.auto", &enabled) &&
- !enabled)
+ if (repo_config_get_bool(r, "maintenance.auto", &enabled)) {
+ int gc_threshold;
+ if (!repo_config_get_int(r, "gc.auto", &gc_threshold))
+ enabled = gc_threshold > 0;
+ }
+ if (!enabled)
return 0;
/*
diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh
index df0bbc1669..97c8c701bb 100755
--- a/t/t7900-maintenance.sh
+++ b/t/t7900-maintenance.sh
@@ -73,6 +73,31 @@ test_expect_success 'maintenance.auto config option' '
test_subcommand ! git maintenance run --auto --quiet --detach <false
'
+test_expect_success 'gc.auto config option' '
+ GIT_TRACE2_EVENT="$(pwd)/default" git commit --quiet --allow-empty -m 1 &&
+ test_subcommand git maintenance run --auto --quiet --detach <default &&
+ GIT_TRACE2_EVENT="$(pwd)/true" \
+ git -c gc.auto=1 commit --quiet --allow-empty -m 2 &&
+ test_subcommand git maintenance run --auto --quiet --detach <true &&
+ GIT_TRACE2_EVENT="$(pwd)/false" \
+ git -c gc.auto=0 commit --quiet --allow-empty -m 3 &&
+ test_subcommand ! git maintenance run --auto --quiet --detach <false
+'
+
+test_expect_success 'maintenance.auto overrides gc.auto' '
+ test_when_finished "rm -f trace" &&
+
+ test_config maintenance.auto false &&
+ test_config gc.auto 1 &&
+ GIT_TRACE2_EVENT="$(pwd)/trace" git commit --quiet --allow-empty -m 1 &&
+ test_subcommand ! git maintenance run --auto --quiet --detach <trace &&
+
+ test_config maintenance.auto true &&
+ test_config gc.auto 0 &&
+ GIT_TRACE2_EVENT="$(pwd)/trace" git commit --quiet --allow-empty -m 1 &&
+ test_subcommand git maintenance run --auto --quiet --detach <trace
+'
+
for cfg in maintenance.autoDetach gc.autoDetach
do
test_expect_success "$cfg=true config option" '
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related
* Re: [PATCH v3 0/8] builtin: implement, document and test url-parse
From: Torsten Bögershausen @ 2026-05-12 8:57 UTC (permalink / raw)
To: Junio C Hamano
Cc: Matheus Afonso Martins Moreira, Matheus Moreira via GitGitGadget,
git, Ghanshyam Thakkar
In-Reply-To: <xmqqjyt9p9pk.fsf@gitster.g>
On Tue, May 12, 2026 at 12:50:47PM +0900, Junio C Hamano wrote:
> Matheus Afonso Martins Moreira <matheus@matheusmoreira.com> writes:
>
> >> Reviewers comment: Nicely done.
> >
> > Thank you!
> >
> >> More a question to myself, may be, about t9904 (and may be other parts)
> >> I have in mind that the parser learned to handle
> >>
> >> file://server/share/repo
> >> correctly under Windows.
> >> I don't know if this needs to be addressed here or in a follow-up commit ?
> >
> > I'd be happy to revisit this in a follow-up. It's been a while
> > since I used MSYS but I do remember the fact it rewrites paths
> > internally. I wasn't sure how to handle it properly in the tests.
>
> So the only potential thing that is missing from the series is the
> above, which we are fine to postpone in a follow-up series? I think
> that is a good stopping point. Given that this command is new, it
> is fine that it has known and documented short-comings that will be
> improved (of course on the other hand, we are not in any urgent need
> for this new command, so we do not have to ship it half-baked).
>
> Is everybody happy with the patches in the current shape and should
> I mark it for 'next'?
>
> Thanks.
>
I am happy with merging to next.
^ permalink raw reply
* What's cooking in git.git (May 2026, #03)
From: Junio C Hamano @ 2026-05-12 9:23 UTC (permalink / raw)
To: git
Here are the topics that have been cooking in my tree. Commits
prefixed with '+' are in 'next' (being in 'next' is a sign that a
topic is stable enough to be used and is a candidate to be in a
future release). Commits prefixed with '-' are only in 'seen', and
aren't considered "accepted" at all and may be annotated with a URL
to a message that raises issues but they are by no means exhaustive.
A topic without enough support may be discarded after a long period
of no activity (of course they can be resubmitted when new interests
arise).
The first batch of topics marked for graduation for quite a while
since 2.54-rc2 have all been merged to 'master'.
Copies of the source code to Git live in many repositories, and the
following is a list of the ones I push into or their mirrors. Some
repositories have only a subset of branches.
With maint, master, next, seen, todo:
git://git.kernel.org/pub/scm/git/git.git/
git://repo.or.cz/alt-git.git/
https://kernel.googlesource.com/pub/scm/git/git/
https://github.com/git/git/
https://gitlab.com/git-scm/git/
With all the integration branches and topics broken out:
https://github.com/gitster/git/
Even though the preformatted documentation in HTML and man format
are not sources, they are published in these repositories for
convenience (replace "htmldocs" with "manpages" for the manual
pages):
git://git.kernel.org/pub/scm/git/git-htmldocs.git/
https://github.com/gitster/git-htmldocs.git/
Release tarballs are available at:
https://www.kernel.org/pub/software/scm/git/
--------------------------------------------------
[New Topics]
* sp/shallow-deepen-on-non-shallow-repo-fix (2026-05-11) 1 commit
- shallow: fix relative deepen on non-shallow repositories
"git fetch --deepen=<n>" in a full clone truncated the history to <n>
commits deep, which has been corrected to be a no-op instead.
Will merge to 'next'.
source: <20260511192044.169557-1-samo_pogacnik@t-2.net>
* ag/sequencer-remove-unused-struct-member (2026-05-11) 1 commit
- sequencer: remove todo_add_branch_context.commit
Code clean-up.
Will merge to 'next'.
source: <pull.2111.git.1778502113485.gitgitgadget@gmail.com>
* ps/maintenance-daemonize-lockfix (2026-05-11) 2 commits
- run-command: honor "gc.auto" for auto-maintenance
- builtin/maintenance: fix locking with "--detach"
"git maintenance" that goes background did not use the lockfile to
prevent multiple maintenance processes from running at the same
time, which has been corrected..
Comments?
source: <20260511-pks-maintenance-fix-lock-with-detach-v1-0-ccd7d62c9a40@pks.im>
--------------------------------------------------
[Graduated to 'master']
* jc/neuter-sideband-fixup (2026-03-05) 6 commits
(merged to 'next' on 2026-03-13 at 5a4098b0cd)
+ sideband: drop 'default' configuration
+ sideband: offer to configure sanitizing on a per-URL basis
+ sideband: add options to allow more control sequences to be passed through
+ sideband: do allow ANSI color sequences by default
+ sideband: introduce an "escape hatch" to allow control characters
+ sideband: mask control characters
(this branch is used by jc/neuter-sideband-post-3.0.)
Try to resurrect and reboot a stalled "avoid sending risky escape
sequences taken from sideband to the terminal" topic by Dscho.
Merged early in the cycle to 'master', *without* the final "for
now, let's tone it down and allow everything" step, which would
give us a chance to prove that the more stricter form that we were
planning to postpone until Git 3.0 may not be needed.
Or people may find issues in the stricter form already. We'll see.
source: <20260305233452.3727126-1-gitster@pobox.com>
* jc/t5551-fix-expensive (2026-05-07) 1 commit
(merged to 'next' on 2026-05-11 at 1bafedde58)
+ t5551: "GIT_TEST_LONG=Yes make test" is broken
Test fix.
source: <xmqqqznmeaco.fsf@gitster.g>
* jk/revert-aa-reap-transport-child-processes (2026-04-22) 1 commit
(merged to 'next' on 2026-05-11 at b41904c1d3)
+ Revert "transport-helper, connect: use clean_on_exit to reap children on abnormal exit"
Revert a recent change that introduced a regression to help mksh users.
source: <20260422230020.GA1839627@coredump.intra.peff.net>
* js/ci-github-actions-update (2026-04-30) 6 commits
(merged to 'next' on 2026-05-11 at ba87f84054)
+ l10n: bump mshick/add-pr-comment from v2 to v3
+ ci: bump git-for-windows/setup-git-for-windows-sdk from v1 to v2
+ ci: bump actions/checkout from v5 to v6
+ ci: bump actions/github-script from v8 to v9
+ ci: bump actions/{upload,download}-artifact to v7 and v8
+ ci: bump microsoft/setup-msbuild from v2 to v3
Update various GitHub Actions versions.
source: <pull.2097.v3.git.1777534500.gitgitgadget@gmail.com>
* js/maintenance-fix-deadlock-on-win10 (2026-05-07) 2 commits
(merged to 'next' on 2026-05-11 at e1b05301d1)
+ maintenance(geometric): do release the `.idx` files before repacking
+ mingw: optionally use legacy (non-POSIX) delete semantics
To help Windows 10 installations, avoid removing files whose
contents are still mmap()'ed.
source: <pull.2103.v2.git.1778158273.gitgitgadget@gmail.com>
* js/t5564-socks-use-short-path (2026-04-29) 1 commit
(merged to 'next' on 2026-05-11 at fbb23860ca)
+ t5564: use a short path for the SOCKS proxy socket
Avoid hitting the pathname limit for socks proxy socket during the
test.
source: <pull.2100.v2.git.1777450974159.gitgitgadget@gmail.com>
--------------------------------------------------
[Cooking]
* js/mingw-no-nedmalloc (2026-05-08) 3 commits
- mingw: remove the vendored compat/nedmalloc/ subtree
- mingw: drop the build-system plumbing for nedmalloc
- mingw: stop using nedmalloc
Stop using unmaintained custom allocator in Windows build which was
the last user of the code.
Will merge to 'next'.
source: <pull.2104.v3.git.1778244661.gitgitgadget@gmail.com>
* sj/submodule-update-clone-config-fix (2026-05-09) 1 commit
(merged to 'next' on 2026-05-12 at 5a0094838a)
+ submodule-config: fix reading submodule.fetchJobs
The configuration variable submodule.fetchJobs was not read correctly,
which has been corrected.
Will merge to 'master'.
source: <pull.2287.v4.git.git.1778385022964.gitgitgadget@gmail.com>
* aw/validate-proxy-url-scheme (2026-05-05) 1 commit
- http: reject unsupported proxy URL schemes
Misspelt proxy URL (e.g., httt://...) did not trigger any warning
or failure, which has been corrected.
Will merge to 'next'.
source: <20260505091941.1825-2-aminnimaj@gmail.com>
* hn/branch-prune-merged (2026-05-11) 5 commits
- branch: add --all-remotes flag
- branch: add branch.<name>.pruneMerged opt-out
- branch: add --prune-merged <remote>
- branch: let delete_branches warn instead of error on bulk refusal
- branch: add --forked <remote>
"git branch" command learned "--prune-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.
Expecting a reroll.
cf. <xmqqse7xr0t7.fsf@gitster.g>
source: <pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com>
* kh/doc-restore-double-underscores-fix (2026-05-05) 1 commit
(merged to 'next' on 2026-05-12 at 2e8fc7cdac)
+ doc: restore: remove double underscore
Doc update.
Will merge to 'master'.
source: <double_underscore.670@msgid.xyz>
* mm/diff-U-takes-no-negative-values (2026-05-05) 3 commits
- xdiff: guard against negative context lengths
- diff: reject negative values for -U/--unified
- diff: reject negative values for --inter-hunk-context
The command line parser for "git diff" learned a few options take
only non-negative integers.
Expecting a (hopefully small and final) reroll.
cf. <CAC2QwmLH19+LHNaP=13c9Ov8x1zXvyDmEhN69_RpbOY+OqEVZA@mail.gmail.com>
source: <pull.2105.git.1778022144.gitgitgadget@gmail.com>
* ps/clang-w-glibc-2.43-and-_Generic (2026-05-10) 1 commit
(merged to 'next' on 2026-05-12 at fca23344f2)
+ build: tolerate use of _Generic from glibc 2.43 with Clang
Headers from glibc 2.43 when used with clang does not allow
disabling C11 language features, causing build failures..
Will merge to 'master'.
source: <xmqqqzniset2.fsf@gitster.g>
* mm/git-url-parse (2026-05-01) 8 commits
- t9904: add tests for the new url-parse builtin
- doc: describe the url-parse builtin
- builtin: create url-parse command
- urlmatch: define url_parse function
- url: return URL_SCHEME_UNKNOWN instead of dying
- url: move scheme detection to URL header/source
- url: move url_is_local_not_ssh to url.h
- connect: rename enum protocol to url_scheme
The internal URL parsing logic has been made accessible via a new
subcommand "git url-parse".
Will merge to 'next'?
cf. <xmqqjyt9p9pk.fsf@gitster.g>
source: <pull.1715.v3.git.git.1777699722.gitgitgadget@gmail.com>
* kh/doc-commit-graph (2026-05-07) 1 commit
(merged to 'next' on 2026-05-12 at b9cafeb32d)
+ doc: add caveat about turning off commit-graph
Ramifications of turning off commit-graph has been documented a bit
more clearly.
Will merge to 'master'.
source: <V3_caveat_commit-graph.6b6@msgid.xyz>
* dk/doc-exclude-is-shared-per-repo (2026-05-08) 1 commit
- ignore: note info/exclude lives in GIT_COMMON_DIR, not GIT_DIR
Document the fact that .git/info/exclude is shared across worktrees
linked to the same repository.
Needs more coverage.
cf. <fd05eba1-d821-4cbe-ada6-ccab97308e56@gmail.com>
source: <d58b6e921d3005c6170fc6c47f175214acb3fa68.1778249267.git.ben.knoble+github@gmail.com>
* kk/paint-down-to-common-optim (2026-05-11) 2 commits
- commit-reach: early exit paint_down_to_common for single merge-base
- commit-reach: introduce merge_base_flags enum
"git merge-base" optimization.
Will merge to 'next'.
source: <pull.2109.v4.git.1778504352.gitgitgadget@gmail.com>
* st/daemon-sockaddr-fixes (2026-05-08) 1 commit
- daemon: fix network address handling bugs
Correct use of sockaddr API in "git daemon".
Waiting for response(s) to review comment(s).
cf. <agGLRC1ziF5F8Okh@pks.im>
source: <pull.2299.git.git.1778291290159.gitgitgadget@gmail.com>
* ag/rebase-update-refs-limit-to-branches (2026-05-10) 1 commit
(merged to 'next' on 2026-05-12 at 5222da09bb)
+ rebase: ignore non-branch update-refs
"git rebase --update-refs", when used with an rebase.instructionFormat
with "%d" (describe) in it, tried to update local branch HEAD by
mistake, which has been corrected.
Will merge to 'master'.
source: <20260510224111.64467-2-mail@abhinavg.net>
* jc/ci-enable-expensive (2026-05-10) 2 commits
- ci: enable EXPENSIVE for contributor builds
- Merge branch 'js/objects-larger-than-4gb-on-windows' into jc/ci-enable-expensive
(this branch uses js/objects-larger-than-4gb-on-windows.)
Enable expensive tests to catch topics that may cause breakages on
integration branches closer to their origin in the contributor PR
builds.
Will merge to 'next'.
source: <xmqqjyta9630.fsf@gitster.g>
* rs/sideband-clear-line-before-print (2026-05-10) 1 commit
(merged to 'next' on 2026-05-12 at 83880f8ce6)
+ sideband: clear full line when printing remote messages
Tweak the way how sideband messages from remote are printed while
we talk with a remote repository to avoid tickling terminal
emulator glitches.
Will merge to 'master'.
source: <9826dabf-c9a6-4397-8ae6-a24f9c507f1b@web.de>
* jh/alias-i18n-fixes (2026-04-24) 1 commit
- alias: restore support for simple dotted aliases
Further update to the i18n alias support to avoid regressions.
Will merge to 'next'.
source: <20260424161707.1514255-1-jonatan@jontes.page>
* kn/refs-generic-helpers (2026-05-04) 9 commits
- refs: use peeled tag values in reference backends
- refs: add peeled object ID to the `ref_update` struct
- refs: move object parsing to the generic layer
- update-ref: handle rejections while adding updates
- update-ref: move `print_rejected_refs()` up
- refs: return `ref_transaction_error` from `ref_transaction_update()`
- refs: extract out reflog config to generic layer
- refs: introduce `ref_store_init_options`
- refs: remove unused typedef 'ref_transaction_commit_fn'
Refactor service routines in the ref subsystem backends.
Will merge to 'next'.
cf. <afmFmGo_Sg33Rv6V@pks.im>
cf. <87o6isqq4q.fsf@toon--20250203-5JQV3.mail-host-address-is-not-set>
source: <20260504-refs-move-to-generic-layer-v4-0-936ac2f0b1a3@gmail.com>
* ob/more-repo-config-values (2026-04-23) 8 commits
- env: move "warn_on_object_refname_ambiguity" into `struct repo_config_values`
- env: move "sparse_expect_files_outside_of_patterns" into `repo_config_values`
- env: move "core_sparse_checkout_cone" into `struct repo_config_values`
- environment: move "precomposed_unicode" into `struct repo_config_values`
- environment: move "pack_compression_level" into `struct repo_config_values`
- environment: move `zlib_compression_level` into `struct repo_config_values`
- environment: move "check_stat" into `struct repo_config_values`
- environment: move "trust_ctime" into `struct repo_config_values`
Expecting a reroll.
cf. <CAD=f0L8-_3sDGGkCzF4WA0xmUtaY_qiz__3zq5AemLgwTsqvsg@mail.gmail.com>
cf. <xmqqlddqu013.fsf@gitster.g>
source: <20260423165432.143598-1-belkid98@gmail.com>
* ps/history-fixup (2026-04-26) 3 commits
- builtin/history: introduce "fixup" subcommand
- builtin/history: generalize function to commit trees
- replay: allow callers to control what happens with empty commits
"git history" learned "fixup" command.
Will merge to 'next'.
cf. <xmqq33zxp4aq.fsf@gitster.g>
source: <20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im>
* rs/grep-column-only-match-fix (2026-04-24) 1 commit
(merged to 'next' on 2026-05-11 at a0d87e3104)
+ grep: fix --column --only-match for 2nd and later matches
"git grep" update.
Will merge to 'master'.
source: <9bd69678-f04b-41d2-ad74-a386820d34c8@web.de>
* sb/unpack-index-pack-buffer-resize (2026-04-28) 1 commit
- index-pack, unpack-objects: increase input buffer from 4 KiB to 128 KiB
Use a larger buffer size in the code paths to ingest pack stream.
Will merge to 'next'.
cf. <xmqqy0hpnpkb.fsf@gitster.g>
source: <pull.2282.v4.git.git.1777387660841.gitgitgadget@gmail.com>
* bc/sign-commit-with-custom-encoding (2026-04-27) 2 commits
- commit: sign commit after mutating buffer
- commit: name UTF-8 function appropriately
Signing commit with custom encoding was passing the data to be
signed at a wrong stage in the pipeline, which has been corrected.
Will merge to 'next'.
cf. <xmqqtssdnpf7.fsf@gitster.g>
source: <20260427221834.1824543-1-sandals@crustytoothpaste.net>
* cc/promisor-auto-config-url-more (2026-04-27) 9 commits
- doc: promisor: improve acceptFromServer entry
- promisor-remote: auto-configure unknown remotes
- promisor-remote: trust known remotes matching acceptFromServerUrl
- promisor-remote: introduce promisor.acceptFromServerUrl
- promisor-remote: add 'local_name' to 'struct promisor_info'
- urlmatch: add url_normalize_pattern() helper
- urlmatch: change 'allow_globs' arg to bool
- t5710: simplify 'mkdir X' followed by 'git -C X init'
- Merge branch 'cc/promisor-auto-config-url' into cc/promisor-auto-config-url-more
The handling of promisor-remote protocol capability has been
loosened to allow the other side to add to the list of promisor
remotes via the promisor.acceptFromServerURL configuration
variable.
Waiting for response(s) to review comment(s).
cf. <875x4yoys5.fsf@toon--20250203-5JQV3.mail-host-address-is-not-set>
source: <20260427124108.3524129-1-christian.couder@gmail.com>
* js/objects-larger-than-4gb-on-windows (2026-05-08) 11 commits
- ci: run expensive tests on push builds to integration branches
- t5608: mark >4GB tests as EXPENSIVE
- test-tool synthesize: add precomputed SHA-256 pack for 4 GiB + 1
- test-tool synthesize: precompute pack for 4 GiB + 1
- test-tool synthesize: use the unsafe hash for speed
- t5608: add regression test for >4GB object clone
- test-tool: add a helper to synthesize large packfiles
- delta, packfile: use size_t for delta header sizes
- odb, packfile: use size_t for streaming object sizes
- git-zlib: handle data streams larger than 4GB
- index-pack, unpack-objects: use size_t for object size
(this branch is used by jc/ci-enable-expensive.)
Update code paths that assumed "unsigned long" was long enough for
"size_t".
Will merge to 'next'.
source: <pull.2102.v3.git.1778228209.gitgitgadget@gmail.com>
* kh/doc-log-decorate-list (2026-04-27) 2 commits
- doc: log: use the same delimiter in description list
- doc: log: fix --decorate description list
Doc update.
Will merge to 'next'.
cf. <xmqqpl31np0l.fsf@gitster.g>
source: <CV_doc_log_--decorate_list.626@msgid.xyz>
* za/t2000-modernise-more (2026-04-29) 1 commit
- t2000: consolidate second scenario into a single test block
Test update.
Will merge to 'next'.
cf. <xmqqfr3xnofn.fsf@gitster.g>
source: <20260429103607.406339-1-zakariyahali100@gmail.com>
* hn/checkout-track-fetch (2026-05-11) 1 commit
- checkout: extend --track with a "fetch" mode to refresh start-point
"git checkout --track=..." learned to optionally fetch the branch
from the remote the new branch will work with.
Comments?
cf. <xmqqh5odqxh2.fsf@gitster.g>
source: <pull.2281.v8.git.git.1778507225500.gitgitgadget@gmail.com>
* mf/revision-max-count-oldest (2026-05-09) 2 commits
- SQUASH??? tac is not portable
- revision.c: implement --max-count-oldest
"git rev-list" (and "git log" family of commands) learned a new "--max-count-oldest"
that picks oldest N commits in the range instead of the usual newest.
Expecting a (hopefully small and final) reroll.
cf. <af_SX9mQPLxolg4k@exploit>
source: <2f71a00b035e25b971641b77a6fa7626f1e2459c.1777578676.git.mroik@delayed.space>
* mm/line-log-cleanup (2026-04-27) 3 commits
- line-log: allow non-patch diff formats with -L
- line-log: integrate -L output with the standard log-tree pipeline
- revision: move -L setup before output_format-to-diff derivation
Code clean-up.
Comments?
cf. <xmqqfr3xp98b.fsf@gitster.g>
source: <pull.2094.git.1777349126.gitgitgadget@gmail.com>
* pw/rename-to-get-current-worktree (2026-05-01) 1 commit
(merged to 'next' on 2026-05-11 at 36ca1d49e2)
+ worktree: rename get_worktree_from_repository()
Code clean-up.
Will merge to 'master'.
source: <bd48396137f8d1352d11b3bd2dca2848f24a347d.1777648798.git.phillip.wood@dunelm.org.uk>
* ds/path-walk-filters (2026-05-11) 13 commits
- path-walk: support `combine` filter
- path-walk: support `object:type` filter
- path-walk: support `tree:0` filter
- pack-objects: support sparse:oid filter with path-walk
- path-walk: add pl_sparse_trees to control tree pruning
- path-walk: support blob size limit filter
- backfill: die on incompatible filter options
- path-walk: support blobless filter
- path-walk: always emit directly-requested objects
- t/perf: add pack-objects filter and path-walk benchmark
- pack-objects: pass --objects with --path-walk
- t5620: make test work with path-walk var
- Merge branch 'en/backfill-fixes-and-edges' into ds/path-walk-filters
(this branch uses en/backfill-fixes-and-edges.)
The "git pack-objects --path-walk" traversal has been integrated
with several object filters, including blobless and sparse filters.
Expecting a (hopefully small and final) reroll.
cf. <0b4a8595-63e3-427c-8920-af4271ae1714@gmail.com>
source: <pull.2101.v3.git.1778523189.gitgitgadget@gmail.com>
* en/ort-cached-rename-with-trivial-resolution (2026-04-20) 1 commit
(merged to 'next' on 2026-05-11 at 9fe24668d9)
+ merge-ort: handle cached rename & trivial resolution interaction better
"ort" merge backend improvements.
Will merge to 'master'.
source: <pull.2095.git.1776724214171.gitgitgadget@gmail.com>
* en/ort-harden-against-corrupt-trees (2026-04-20) 5 commits
- cache-tree: fix verify_cache() to catch non-adjacent D/F conflicts
- merge-ort: abort merge when trees have duplicate entries
- merge-ort: free diff pairs queue in clear_or_reinit_internal_opts()
- merge-ort: drop unnecessary show_all_errors from collect_merge_info()
- merge-ort: propagate callback errors from traverse_trees_wrapper()
"ort" merge backend handles merging corrupt trees better by
aborting when it should.
Needs review.
source: <pull.2096.git.1776731171.gitgitgadget@gmail.com>
* mf/format-patch-cover-letter-format-docfix (2026-04-22) 1 commit
(merged to 'next' on 2026-05-11 at 3cca9cc117)
+ Fix docs for format.commitListFormat
Docfix.
Will merge to 'master'.
source: <576d29f15e016889e02c253713656cd8cbf1f04c.1776894255.git.mroik@delayed.space>
* sg/t6112-unwanted-tilde-expansion-fix (2026-04-21) 1 commit
(merged to 'next' on 2026-05-12 at ad2d08eb44)
+ t6112: avoid tilde expansion
Test fix.
Will merge to 'master'.
source: <20260421192132.51172-1-szeder.dev@gmail.com>
* pw/status-rebase-todo (2026-05-01) 2 commits
- status: improve rebase todo list parsing
- sequencer: factor out parsing of todo commands
The display of the rebase todo list in "git status" has been
improved to correctly abbreviate object IDs for more commands and
avoid misinterpreting refs as object IDs.
Needs review.
source: <cover.1777648598.git.phillip.wood@dunelm.org.uk>
* ss/t7004-unhide-git-failures (2026-04-20) 3 commits
(merged to 'next' on 2026-05-11 at 9ee9feacb7)
+ t7004: avoid subshells to capture git exit codes
+ t7004: dynamically grab expected state in tests
+ t7004: drop hardcoded tag count for state verification
Test clean-up.
Will merge to 'master'.
cf. <aecNc-BNwaqFlg5c@pks.im>
source: <20260421053334.5414-1-r.siddharth.shrimali@gmail.com>
* tb/pseudo-merge-bugfixes (2026-05-11) 9 commits
- pack-bitmap: prevent pattern leak on pseudo-merge re-assignment
- Documentation: fix broken `sampleRate` in gitpacking(7)
- pack-bitmap: reject pseudo-merge "sampleRate" of 0
- pack-bitmap: parse commits in `find_pseudo_merge_group_for_ref()`
- pack-bitmap: fix pseudo-merge lookup for shared commits
- pack-bitmap: fix inverted binary search in `pseudo_merge_at()`
- pack-bitmap-write: sort pseudo-merge commit lookup table in pack order
- t5333: demonstrate various pseudo-merge bugs
- t/helper: add 'test-tool bitmap write' subcommand
Fixes many bugs in pseudo-merge code.
Will merge to 'next'.
source: <cover.1778546804.git.me@ttaylorr.com>
* ds/fetch-negotiation-options (2026-04-22) 7 commits
- send-pack: pass negotiation config in push
- remote: add remote.*.negotiationInclude config
- fetch: add --negotiation-include option for negotiation
- remote: add remote.*.negotiationRestrict config
- transport: rename negotiation_tips
- fetch: add --negotiation-restrict option
- t5516: fix test order flakiness
The negotiation tip options in "git fetch" have been reworked to
allow requiring certain refs to be sent as "have" lines, and to
restrict negotiation to a specific set of refs.
Needs review.
source: <pull.2085.v3.git.1776871546.gitgitgadget@gmail.com>
* en/backfill-fixes-and-edges (2026-04-15) 3 commits
(merged to 'next' on 2026-05-11 at baccbdf26d)
+ backfill: default to grabbing edge blobs too
+ backfill: document acceptance of revision-range in more standard manner
+ backfill: reject rev-list arguments that do not make sense
(this branch is used by ds/path-walk-filters.)
The 'git backfill' command now rejects revision-limiting options that
are incompatible with its operation, uses standard documentation for
revision ranges, and includes blobs from boundary commits by default
to improve performance of subsequent operations.
Will merge to 'master'.
cf. <6e95b82a-19e3-460e-86f7-f899c2df261d@gmail.com>
source: <pull.2088.git.1776297482.gitgitgadget@gmail.com>
* mc/http-emptyauth-negotiate-fix (2026-04-30) 4 commits
(merged to 'next' on 2026-05-12 at 843ae82cd0)
+ doc: clarify http.emptyAuth values
(merged to 'next' on 2026-04-20 at 6539524ca2)
+ t5563: add tests for http.emptyAuth with Negotiate
+ http: attempt Negotiate auth in http.emptyAuth=auto mode
+ http: extract http_reauth_prepare() from retry paths
The 'http.emptyAuth=auto' configuration now correctly attempts
Negotiate authentication before falling back to manual credentials.
This allows seamless Kerberos ticket-based authentication without
requiring users to explicitly set 'http.emptyAuth=true'.
Will merge to 'master'.
source: <e0f236767f81ea60f90749d1bc00ab78081efd0e.1777546472.git.gitgitgadget@gmail.com>
source: <pull.2087.git.1776331259.gitgitgadget@gmail.com>
* en/batch-prefetch (2026-04-17) 3 commits
- grep: prefetch necessary blobs
- builtin/log: prefetch necessary blobs for `git cherry`
- patch-ids.h: add missing trailing parenthesis in documentation comment
In a lazy clone, "git cherry" and "git grep" often fetch necessary
blob objects one by one from promisor remotes. It has been corrected
to collect necessary object names and fetch them in bulk to gain
reasonable performance.
Expecting a reroll.
cf. <CABPp-BFdYjnjhSrjEBf8kjYYY2jtrQ_=w0jYR+DDWh3szmtvqQ@mail.gmail.com>
source: <pull.2089.v2.git.1776472347.gitgitgadget@gmail.com>
* en/diffstat-utf8-truncation-fix (2026-04-20) 1 commit
- diff: fix out-of-bounds reads and NULL deref in diffstat UTF-8 truncation
The computation to shorten the filenames shown in diffstat measured
width of individual UTF-8 characters to add up, but forgot to take
into account error cases (e.g., an invalid UTF-8 sequence, or a
control character).
Will merge to 'next'.
source: <pull.2093.v3.git.1776699778177.gitgitgadget@gmail.com>
* ps/odb-in-memory (2026-04-10) 18 commits
- t/unit-tests: add tests for the in-memory object source
- odb: generic in-memory source
- odb/source-inmemory: stub out remaining functions
- odb/source-inmemory: implement `freshen_object()` callback
- odb/source-inmemory: implement `count_objects()` callback
- odb/source-inmemory: implement `find_abbrev_len()` callback
- odb/source-inmemory: implement `for_each_object()` callback
- odb/source-inmemory: convert to use oidtree
- oidtree: add ability to store data
- cbtree: allow using arbitrary wrapper structures for nodes
- odb/source-inmemory: implement `write_object_stream()` callback
- odb/source-inmemory: implement `write_object()` callback
- odb/source-inmemory: implement `read_object_stream()` callback
- odb/source-inmemory: implement `read_object_info()` callback
- odb: fix unnecessary call to `find_cached_object()`
- odb/source-inmemory: implement `free()` callback
- odb: introduce "in-memory" source
- Merge branch 'jt/odb-transaction-write' into ps/odb-in-memory
(this branch uses jt/odb-transaction-write.)
Add a new odb "in-memory" source that is meant to only hold
tentative objects (like the virtual blob object that represents the
working tree file used by "git blame").
Need to wait for the base topioc.
source: <20260410-b4-pks-odb-source-inmemory-v3-0-22fd0fad58fe@pks.im>
* js/adjust-tests-to-explicitly-access-bare-repo (2026-04-26) 8 commits
- safe.bareRepository: default to "explicit" with WITH_BREAKING_CHANGES
- status tests: filter `.gitconfig` from status output
- ls-files tests: filter `.gitconfig` from `--others` output
- t5601: restore `.gitconfig` after includeIf test
- t1305: use `--git-dir=.` for bare repo in include cycle test
- t1300: remove global config settings injected by test-lib.sh
- t7900: do not let `$HOME/.gitconfig` interfere with XDG tests
- test-lib: allow bare repository access when breaking changes are enabled
Some tests assume that bare repository accesses are by default
allowed; rewrite some of them to avoid the assumption, rewrite
others to explicitly set safe.bareRepository to allow them.
Will merge to 'next'.
source: <pull.2098.v2.git.1777214316.gitgitgadget@gmail.com>
* cl/conditional-config-on-worktree-path (2026-04-03) 2 commits
- config: add "worktree" and "worktree/i" includeIf conditions
- config: refactor include_by_gitdir() into include_by_path()
The [includeIf "condition"] conditional inclusion facility for
configuration files has learned to use the location of worktree
in its condition.
Needs review.
cf. <xmqq4ikdnn8g.fsf@gitster.g>
source: <20260403-includeif-worktree-v3-0-109ce5782b03@black-desk.cn>
* ps/shift-root-in-graph (2026-04-27) 1 commit
- graph: add indentation for commits preceded by a parentless commit
In a history with more than one root commit, "git log --graph
--oneline" stuffed an unrelated commit immediately below a root
commit, which has been corrected by making the spot below a root
unavailable.
Will merge to 'next'.
source: <20260427102838.44867-2-pabloosabaterr@gmail.com>
* lp/repack-propagate-promisor-debugging-info (2026-04-18) 6 commits
- repack-promisor: add missing headers
- t7703: test for promisor file content after geometric repack
- t7700: test for promisor file content after repack
- repack-promisor: preserve content of promisor files after repack
- repack-promisor add helper to fill promisor file after repack
- pack-write: add explanation to promisor file content
When fetching objects into a lazily cloned repository, .promisor
files are created with information meant to help debugging. "git
repack" has been taught to carry this information forward to
packfiles that are newly created.
Needs review.
cf. <xmqqse7xm8av.fsf@gitster.g>
source: <cover.1776384902.git.lorenzo.pegorari2002@gmail.com>
* th/promisor-quiet-per-repo (2026-04-06) 1 commit
- promisor-remote: fix promisor.quiet to use the correct repository
The "promisor.quiet" configuration variable was not used from
relevant submodules when commands like "grep --recurse-submodules"
triggered a lazy fetch, which has been corrected.
Comments?
source: <20260406183041.783800-1-vikingtc4@gmail.com>
* jt/odb-transaction-write (2026-04-02) 7 commits
- odb/transaction: make `write_object_stream()` pluggable
- object-file: generalize packfile writes to use odb_write_stream
- object-file: avoid fd seekback by checking object size upfront
- object-file: remove flags from transaction packfile writes
- odb: update `struct odb_write_stream` read() callback
- odb/transaction: use pluggable `begin_transaction()`
- odb: split `struct odb_transaction` into separate header
(this branch is used by ps/odb-in-memory.)
ODB transaction interface is being reworked to explicitly handle
object writes.
Needs leakfix.
cf. <20260511175835.GA4811@coredump.intra.peff.net>
source: <20260402213220.2651523-1-jltobler@gmail.com>
* sa/cat-file-batch-mailmap-switch (2026-04-15) 1 commit
- cat-file: add mailmap subcommand to --batch-command
"git cat-file --batch" learns an in-line command "mailmap"
that lets the user toggle use of mailmap.
Will merge to 'next'?
source: <20260416033250.4327-2-siddharthasthana31@gmail.com>
* tb/incremental-midx-part-3.3 (2026-04-29) 16 commits
- repack: allow `--write-midx=incremental` without `--geometric`
- repack: introduce `--write-midx=incremental`
- repack: implement incremental MIDX repacking
- packfile: ensure `close_pack_revindex()` frees in-memory revindex
- builtin/repack.c: convert `--write-midx` to an `OPT_CALLBACK`
- repack-geometry: prepare for incremental MIDX repacking
- repack-midx: extract `repack_fill_midx_stdin_packs()`
- repack-midx: factor out `repack_prepare_midx_command()`
- midx: expose `midx_layer_contains_pack()`
- repack: track the ODB source via existing_packs
- midx: support custom `--base` for incremental MIDX writes
- midx: introduce `--no-write-chain-file` for incremental MIDX writes
- midx: use `strvec` for `keep_hashes`
- midx: build `keep_hashes` array in order
- midx: use `strset` for retained MIDX files
- midx-write: handle noop writes when converting incremental chains
The repacking code has been refactored and compaction of MIDX layers
have been implemented, and incremental strategy that does not require
all-into-one repacking has been introduced.
Will merge to 'next'.
source: <cover.1777507303.git.me@ttaylorr.com>
* jd/unpack-trees-wo-the-repository (2026-03-31) 2 commits
- unpack-trees: use repository from index instead of global
- unpack-trees: use repository from index instead of global
A handful of inappropriate uses of the_repository have been
rewritten to use the right repository structure instance in the
unpack-trees.c codepath.
Comments?
source: <pull.2258.v2.git.git.1774971267.gitgitgadget@gmail.com>
* ps/setup-wo-the-repository (2026-04-20) 18 commits
- setup: stop using `the_repository` in `init_db()`
- setup: stop using `the_repository` in `create_reference_database()`
- setup: stop using `the_repository` in `initialize_repository_version()`
- setup: stop using `the_repository` in `check_repository_format()`
- setup: stop using `the_repository` in `upgrade_repository_format()`
- setup: stop using `the_repository` in `setup_git_directory()`
- setup: stop using `the_repository` in `setup_git_directory_gently()`
- setup: stop using `the_repository` in `setup_git_env()`
- setup: stop using `the_repository` in `set_git_work_tree()`
- setup: stop using `the_repository` in `setup_work_tree()`
- setup: stop using `the_repository` in `enter_repo()`
- setup: stop using `the_repository` in `verify_non_filename()`
- setup: stop using `the_repository` in `verify_filename()`
- setup: stop using `the_repository` in `path_inside_repo()`
- setup: stop using `the_repository` in `prefix_path()`
- setup: stop using `the_repository` in `is_inside_git_dir()`
- setup: stop using `the_repository` in `is_inside_worktree()`
- setup: replace use of `the_repository` in static functions
Many uses of the_repository has been updated to use a more
appropriate struct repository instance in setup.c codepath.
Needs review.
source: <20260420-pks-setup-wo-the-repository-v1-0-f4a81c4988e8@pks.im>
* kh/doc-trailers (2026-04-13) 9 commits
- doc: interpret-trailers: document comment line treatment
- doc: interpret-trailers: commit to “trailer block” term
- doc: interpret-trailers: add key format example
- doc: interpret-trailers: explain key format
- doc: interpret-trailers: explain the format after the intro
- doc: interpret-trailers: not just for commit messages
- doc: interpret-trailers: use “metadata” in Name as well
- doc: interpret-trailers: replace “lines” with “metadata”
- doc: interpret-trailers: stop fixating on RFC 822
Documentation updates.
Needs review.
cf. <xmqq1pfivfa3.fsf@gitster.g>
source: <V2_CV_doc_int-tr_key_format.613@msgid.xyz>
* ps/graph-lane-limit (2026-03-27) 3 commits
- graph: add truncation mark to capped lanes
- graph: add --graph-lane-limit option
- graph: limit the graph width to a hard-coded max
The graph output from commands like "git log --graph" can now be
limited to a specified number of lanes, preventing overly wide output
in repositories with many branches.
Will merge to 'next'?
cf. <bdff0a5d-b738-4053-9b72-08eba88156de@kdbg.org>
source: <20260328001113.1275291-1-pabloosabaterr@gmail.com>
* jr/bisect-custom-terms-in-output (2026-04-17) 2 commits
- rev-parse: use selected alternate terms to look up refs
- bisect: use selected alternate terms in status output
"git bisect" now uses the selected terms (e.g., old/new) more
consistently in its output.
Waiting for response(s) to review comment(s).
cf. <xmqqjyu5weli.fsf@gitster.g>
source: <20260417-bisect-terms-v3-0-d659fa547261@schlaraffenlan.de>
* ua/push-remote-group (2026-05-03) 3 commits
- push: support pushing to a remote group
- remote: move remote group resolution to remote.c
- remote: fix sign-compare warnings in push_cas_option
"git push" learned to take a "remote group" name to push to, which
causes pushes to multiple places, just like "git fetch" would do.
Comments?
source: <20260503153402.1333220-1-usmanakinyemi202@gmail.com>
* hn/git-checkout-m-with-stash (2026-04-28) 5 commits
(merged to 'next' on 2026-05-11 at 46adae5e5d)
+ checkout -m: autostash when switching branches
+ checkout: rollback lock on early returns in merge_working_tree
+ sequencer: teach autostash apply to take optional conflict marker labels
+ sequencer: allow create_autostash to run silently
+ stash: add --label-ours, --label-theirs, --label-base for apply
"git checkout -m another-branch" was invented to deal with local
changes to paths that are different between the current and the new
branch, but it gave only one chance to resolve conflicts. The command
was taught to create a stash to save the local changes.
Will merge to 'master'.
source: <pull.2234.v16.git.git.1777401552.gitgitgadget@gmail.com>
* kh/name-rev-custom-format (2026-05-11) 5 commits
(merged to 'next' on 2026-05-12 at c944d6131e)
+ format-rev: introduce builtin for on-demand pretty formatting
+ name-rev: make dedicated --annotate-stdin --name-only test
+ name-rev: factor code for sharing with a new command
+ name-rev: run clang-format before factoring code
+ name-rev: wrap both blocks in braces
A new builtin "git format-rev" is introduced for pretty formatting
one revision expression per line or commit object names found in
running text.
Will merge to 'master'.
source: <V5_CV_format-rev.6c9@msgid.xyz>
* js/parseopt-subcommand-autocorrection (2026-04-27) 11 commits
- SQUASH???
- doc: document autocorrect API
- parseopt: add tests for subcommand autocorrection
- parseopt: enable subcommand autocorrection for git-remote and git-notes
- parseopt: autocorrect mistyped subcommands
- autocorrect: provide config resolution API
- autocorrect: rename AUTOCORRECT_SHOW to AUTOCORRECT_HINT
- autocorrect: use mode and delay instead of magic numbers
- help: move tty check for autocorrection to autocorrect.c
- help: make autocorrect handling reusable
- parseopt: extract subcommand handling from parse_options_step()
The parse-options library learned to auto-correct misspelled
subcommand names.
Expecting a reroll.
cf. <xmqqcxz2tzpr.fsf@gitster.g>
source: <SY0P300MB0801677A2A1E0FD38D06A841CE2A2@SY0P300MB0801.AUSP300.PROD.OUTLOOK.COM>
* jc/neuter-sideband-post-3.0 (2026-03-05) 2 commits
- sideband: delay sanitizing by default to Git v3.0
- Merge branch 'jc/neuter-sideband-fixup' into jc/neuter-sideband-post-3.0
The final step, split from earlier attempt by Dscho, to loosen the
sideband restriction for now and tighten later at Git v3.0 boundary.
On hold to help the base topic with wider exposure.
(this branch uses jc/neuter-sideband-fixup.)
source: <20260305233452.3727126-8-gitster@pobox.com>
* cs/subtree-split-recursion (2026-03-05) 3 commits
- contrib/subtree: reduce recursion during split
- contrib/subtree: functionalize split traversal
- contrib/subtree: reduce function side-effects
When processing large history graphs on Debian or Ubuntu, "git
subtree" can die with a "recursion depth reached" error.
Comments?
source: <20260305-cs-subtree-split-recursion-v2-0-7266be870ba9@howdoi.land>
* pt/fsmonitor-linux (2026-04-15) 13 commits
- fsmonitor: convert shown khash to strset in do_handle_client
- fsmonitor: add tests for Linux
- fsmonitor: add timeout to daemon stop command
- fsmonitor: close inherited file descriptors and detach in daemon
- run-command: add close_fd_above_stderr option
- fsmonitor: implement filesystem change listener for Linux
- fsmonitor: rename fsm-settings-darwin.c to fsm-settings-unix.c
- fsmonitor: rename fsm-ipc-darwin.c to fsm-ipc-unix.c
- fsmonitor: use pthread_cond_timedwait for cookie wait
- compat/win32: add pthread_cond_timedwait
- fsmonitor: fix hashmap memory leak in fsmonitor_run_daemon
- fsmonitor: fix khash memory leak in do_handle_client
- t9210, t9211: disable GIT_TEST_SPLIT_INDEX for scalar clone tests
The fsmonitor daemon has been implemented for Linux.
Will merge to 'next'?
cf. <xmqqa4u5nnxq.fsf@gitster.g>
source: <pull.2147.v15.git.git.1776259657.gitgitgadget@gmail.com>
* pw/xdiff-shrink-memory-consumption (2026-05-04) 5 commits
- xdiff: reduce the size of array
- xprepare: simplify error handling
- xdiff: cleanup xdl_clean_mmatch()
- xdiff: reduce size of action arrays
- Merge branch 'en/xdiff-cleanup-3' into pw/xdiff-shrink-memory-consumption
(this branch uses en/xdiff-cleanup-3.)
Shrink wasted memory in Myers diff that does not account for common
prefix and suffix removal.
Will merge to 'next'.
source: <cover.1777903579.git.phillip.wood@dunelm.org.uk>
* en/xdiff-cleanup-3 (2026-04-29) 6 commits
(merged to 'next' on 2026-05-12 at e4e72e0f34)
+ xdiff/xdl_cleanup_records: make execution of action easier to follow
+ xdiff/xdl_cleanup_records: make setting action easier to follow
+ xdiff/xdl_cleanup_records: make limits more clear
+ xdiff/xdl_cleanup_records: use unambiguous types
+ xdiff: use unambiguous types in xdl_bogo_sqrt()
+ xdiff/xdl_cleanup_records: delete local recs pointer
(this branch is used by pw/xdiff-shrink-memory-consumption.)
Preparation of the xdiff/ codebase to work with Rust.
Will merge to 'master'.
source: <pull.2156.v6.git.git.1777500495.gitgitgadget@gmail.com>
--------------------------------------------------
[Discarded]
* pt/promisor-lazy-fetch-no-recurse (2026-03-11) 1 commit
. promisor-remote: prevent lazy-fetch recursion in child fetch
The mechanism to avoid recursive lazy-fetch from promisor remotes
was not propagated properly to child "git fetch" processes, which
has been corrected.
Dropped due to a long silence and inactivity.
cf. <xmqqik9s6qvd.fsf@gitster.g>
source: <pull.2224.v3.git.git.1773238778894.gitgitgadget@gmail.com>
* jt/config-lock-timeout (2026-04-03) 1 commit
. config: retry acquiring config.lock for 100ms
The code path to update the configuration file has been taught to
use a short timeout to retry.
Dropped due to a long silence and inactivity.
cf. <adYvSZeN0ZVqwRhi@pks.im>
cf. <xmqqcxz2vfpa.fsf@gitster.g>
source: <20260403100135.3901610-1-joerg@thalheim.io>
* ab/clone-default-object-filter (2026-03-14) 1 commit
. clone: add clone.<url>.defaultObjectFilter config
"git clone" learns to pay attention to "clone.<url>.defaultObjectFilter"
configuration and behave as if the "--filter=<filter-spec>" option
was given on the command line.
Dropped due to a long silence and inactivity.
cf. <abe1l8ONmFIhzaxi@pks.im>
cf. <xmqq8q9qvffs.fsf@gitster.g>
source: <pull.2058.v6.git.1773553022381.gitgitgadget@gmail.com>
^ permalink raw reply
* Re: [PATCH v3 1/7] t5516: fix test order flakiness
From: Matthew John Cheetham @ 2026-05-12 10:50 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <466c56abe002ed2ac8cf0da2d6d1d8c288cd232b.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> The 'fetch follows tags by default' test sorts using 'sort -k 4', but
> for-each-ref output only has 3 columns. This relies on sort treating
> records with fewer fields as having an empty fourth field, which may
> produce unstable results depending on locale. Use 'sort -k 3' to match
> the actual number of columns in the output.
>
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> t/t5516-fetch-push.sh | 2 +-
> 1 file changed, 1 insertion(+), 1 deletion(-)
>
> diff --git a/t/t5516-fetch-push.sh b/t/t5516-fetch-push.sh
> index 29e2f17608..ac8447f21e 100755
> --- a/t/t5516-fetch-push.sh
> +++ b/t/t5516-fetch-push.sh
> @@ -1349,7 +1349,7 @@ test_expect_success 'fetch follows tags by default' '
> git for-each-ref >tmp1 &&
> sed -n "p; s|refs/heads/main$|refs/remotes/origin/main|p" tmp1 |
> sed -n "p; s|refs/heads/main$|refs/remotes/origin/HEAD|p" |
> - sort -k 4 >../expect
> + sort -k 3 >../expect
> ) &&
> test_when_finished "rm -rf dst" &&
> git init dst &&
Makes sense. Looks like 3f763ddf28 ("fetch: set remote/HEAD if it does
not exist") originally changed it from -k3 to -k4 by mistake.
Thanks,
Matthew
^ permalink raw reply
* Re: [PATCH/RFC 4/5] test-tool: add a "historian" subcommand for building merge fixtures
From: Toon Claes @ 2026-05-12 10:54 UTC (permalink / raw)
To: Johannes Schindelin via GitGitGadget, git
Cc: Elijah Newren, Patrick Steinhardt, Johannes Schindelin,
Johannes Schindelin
In-Reply-To: <72c486312cde9a9fd2dedb60bc43c5c3e40a0d64.1778107405.git.gitgitgadget@gmail.com>
"Johannes Schindelin via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Johannes Schindelin <johannes.schindelin@gmx.de>
>
> The merge-replay tests added in a follow-up commit need a way to set
> up specific topologies with full control over blob contents, parent
> order, and per-side trees. Sequencing plumbing commands or driving
> plain `git fast-import` from shell quickly becomes unreadable for
> the kinds of scenarios that exercise non-trivial merge resolution
> (textual conflicts, semantic edits outside the conflict region,
> intentional limitations such as new content on one side).
>
> Add a small `test-tool historian` subcommand that reads a tight,
> shell-quoted, one-line-per-object DSL and feeds an equivalent stream
> to a `git fast-import` child process. Each blob and commit is given
> a logical name; the helper allocates fast-import marks on first use
> and emits a lightweight tag for every commit so tests can refer to
> the resulting object via `refs/tags/<name>`.
>
> The DSL has just two directives:
>
> blob NAME LINE...
> commit NAME BRANCH SUBJECT [from=NAME] [merge=NAME]... [PATH=BLOB]...
>
> A blob's content is the listed lines joined with `\n` (and a final
> `\n`); a commit's tree is exactly the listed PATH=BLOB pairs (the
> helper emits a `deleteall` so nothing leaks in from the implicit
> parent). Token splitting is delegated to `split_cmdline()` so quoted
> arguments work as in shell. Marks for parent references and file
> contents go through the same `strintmap`-backed name resolver, which
> keeps the helper itself trivially small: blob writing, tree
> construction, commit creation and merge-base computation are all
> handled by `git fast-import`.
>
> Note that the DSL reserves the names `from` and `merge` (with a
> trailing `=`) for parent specification; a tree path called `from` or
> `merge` cannot be expressed via this helper. That is acceptable here
> because every input is a tightly controlled test fixture and the
> filenames are chosen by the test author.
>
> The helper trusts its caller: malformed input results in a
> fast-import error rather than a friendly diagnostic.
>
> Wire the new subcommand into the Makefile and meson build, register
> it in `t/helper/test-tool.{c,h}`.
>
> Assisted-by: Claude Opus 4.7
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> ---
> Makefile | 1 +
> t/helper/meson.build | 1 +
> t/helper/test-historian.c | 189 ++++++++++++++++++++++++++++++++++++++
> t/helper/test-tool.c | 1 +
> t/helper/test-tool.h | 1 +
> 5 files changed, 193 insertions(+)
> create mode 100644 t/helper/test-historian.c
>
> diff --git a/Makefile b/Makefile
> index cedc234173..b38678b484 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -832,6 +832,7 @@ TEST_BUILTINS_OBJS += test-hash-speed.o
> TEST_BUILTINS_OBJS += test-hash.o
> TEST_BUILTINS_OBJS += test-hashmap.o
> TEST_BUILTINS_OBJS += test-hexdump.o
> +TEST_BUILTINS_OBJS += test-historian.o
> TEST_BUILTINS_OBJS += test-json-writer.o
> TEST_BUILTINS_OBJS += test-lazy-init-name-hash.o
> TEST_BUILTINS_OBJS += test-match-trees.o
> diff --git a/t/helper/meson.build b/t/helper/meson.build
> index 675e64c010..704edd1e1f 100644
> --- a/t/helper/meson.build
> +++ b/t/helper/meson.build
> @@ -29,6 +29,7 @@ test_tool_sources = [
> 'test-hash.c',
> 'test-hashmap.c',
> 'test-hexdump.c',
> + 'test-historian.c',
> 'test-json-writer.c',
> 'test-lazy-init-name-hash.c',
> 'test-match-trees.c',
> diff --git a/t/helper/test-historian.c b/t/helper/test-historian.c
> new file mode 100644
> index 0000000000..2250d420c0
> --- /dev/null
> +++ b/t/helper/test-historian.c
> @@ -0,0 +1,189 @@
> +/*
> + * Build a small history out of a tiny declarative input. Used by tests
> + * that need specific merge topologies without long sequences of
> + * plumbing commands or fragile shell helpers.
> + *
> + * The historian reads stdin line by line and emits an equivalent
> + * stream to a `git fast-import` child process. It also allocates marks
> + * for named objects so tests can refer to commits and blobs by name.
Really appreciate you're introducing this command. I'm actually
surprised no else did before.
> + *
> + * Input directives (one per line, shell-style quoting):
> + *
> + * blob NAME LINE1 LINE2 ...
> + * Each LINE becomes a content line in the blob; lines are
> + * joined with '\n' and the blob ends with a final '\n'. With
> + * no LINEs, the blob is empty.
> + *
> + * commit NAME BRANCH SUBJECT [from=PARENT] [merge=PARENT]... [PATH=BLOB]...
I'm not sure how I feel about mixing named arguments (like `from=PARENT`) with
the `PATH=BLOB` arguments? Obviously this tool isn't made for anything
that's even close to production, but still feels strange. How about
putting a double dash (`--`) before the paths, or using the `PATH:BLOB`
syntax instead?
> + * Creates a commit on refs/heads/BRANCH using the listed
> + * file=blob mappings as the entire tree (no inheritance from
> + * parents). Up to one `from=` and any number of `merge=`
> + * parents may be given. `from=` defaults to the current branch
> + * tip; if BRANCH has no tip yet, the commit becomes a root.
At GitLab in our Gitaly suite we have a similar tool as what you're
introducing here, but there you have to specify the parent(s) for each
commit and if you want to assign a ref to a commit, you have to be
explicit about it. So I would replace `from=` and `merge=` with
`parent=` and allow that to be occur zero or more times (this would also
allow creating unrelated histories). And remove the mandatory argument
BRANCH, and instead allow the command to accept a `branch=` argument.
If we'd take an example from the follow-up commit:
# Setup:
# A (a) --- C (a, h) ----+--- M (a, g, h)
# \ /
# +-- B (a, g) ------+
#
# Topic touches `g` only; main touches `h` only. The auto-merge
# at M is clean.
blob a "shared content"
blob g guarded
blob h host
commit A main "A" a=a
commit B topic "B (introduces g)" from=A a=a g=g
commit C main "C (introduces h)" a=a h=h
commit M main "Merge topic" merge=B a=a g=g h=h
I would suggest to rewrite that to:
blob a "shared content"
blob g guarded
blob h host
commit A "A" a:a
commit B "B (introduces g)" parent=A branch=topic a:a g:g
commit C "C (introduces h)" parent=A a:a h:h
commit M "Merge topic" parent=A parent=B ref=main a:a g:g h:h
I realize this is less alike to git-fast-import(1), so I'd understand if
you'd reject this idea.
--
Cheers,
Toon
^ permalink raw reply
* [PATCH v9] checkout: extend --track with a "fetch" mode to refresh start-point
From: Harald Nordgren via GitGitGadget @ 2026-05-12 10:55 UTC (permalink / raw)
To: git
Cc: Ramsay Jones, D. Ben Knoble, Kristoffer Haugsbakk, Marc Branchaud,
Phillip Wood, Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2281.v8.git.git.1778507225500.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
If you want to fork your topic branch from the very latest of the
tip of a branch your remote has, you would do:
git fetch origin some-branch
git checkout -b new_branch --track origin/some-branch
Extend the "--track" option of "git checkout" and allow users to
write
git checkout -b new_branch --track=fetch origin/some-branch
to (1) fetch 'some-branch' from the remote 'origin', updating the
remote-tracking branch 'origin/some-branch', (2) arrange subsequent
'git pull' on 'new_branch' to interact with 'origin/some-branch' and
(3) fork 'new_branch' from it.
In the value of the '--track' option, 'fetch' can be combined with
the existing 'direct' (default) and 'inherit' modes via a
comma-separated list. Examples:
git checkout -b new_branch --track=fetch,inherit some_local_branch
git switch -c new_branch --track=fetch origin/some-branch
When "fetch" is requested and <start-point> is in <remote>/<branch>
form, run "git fetch <remote> <branch>" before resolving the ref, so
that other remote-tracking branches are left untouched. If
<start-point> is a bare remote name like "origin" (which resolves to
that remote's default branch), "git fetch <remote>" is run instead,
since the target branch is not known up front. Abort the checkout if
the fetch fails.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
checkout: --track=fetch
* Support hierarchical remote names (e.g. nested/remote) by trying the
longest prefix first.
* Fold the existing-ref lookup into resolve_fetch_target() so it
returns the ref name directly.
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2281%2FHaraldNordgren%2Fcheckout-fetch-start-point-v9
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2281/HaraldNordgren/checkout-fetch-start-point-v9
Pull-Request: https://github.com/git/git/pull/2281
Range-diff vs v8:
1: 61c2199fd5 ! 1: 021375e4cc checkout: extend --track with a "fetch" mode to refresh start-point
@@ builtin/checkout.c: struct branch_info {
};
+static int resolve_fetch_target(const char *arg, char **remote_out,
-+ char **src_ref_out)
++ char **src_ref_out, char **existing_ref_out)
+{
+ const char *slash;
-+ char *remote_name;
-+ struct remote *remote;
++ char *remote_name = NULL;
++ struct remote *remote = NULL;
+ struct refspec_item query = { 0 };
+ struct strbuf dst = STRBUF_INIT;
-+ const char *rest;
++ struct object_id oid;
++ const char *rest = NULL;
++ const char *head_target = NULL;
++ const char *short_target;
+
+ *remote_out = NULL;
+ *src_ref_out = NULL;
++ *existing_ref_out = NULL;
+
-+ if (!arg || !*arg)
-+ return -1;
-+
-+ slash = strchr(arg, '/');
-+ if (slash == arg)
++ if (!arg || !*arg || *arg == '/')
+ return -1;
-+ remote_name = slash ? xstrndup(arg, slash - arg) : xstrdup(arg);
+
-+ remote = remote_get(remote_name);
-+ if (!remote || !remote_is_configured(remote, 1)) {
++ slash = arg + strlen(arg);
++ while (1) {
+ free(remote_name);
-+ return -1;
++ remote_name = xstrndup(arg, slash - arg);
++ remote = remote_get(remote_name);
++ if (remote && remote_is_configured(remote, 1))
++ break;
++ while (slash > arg && *--slash != '/')
++ ;
++ if (slash == arg) {
++ free(remote_name);
++ return -1;
++ }
+ }
+
-+ rest = (slash && slash[1]) ? slash + 1 : NULL;
++ if (*slash == '/' && slash[1])
++ rest = slash + 1;
+ if (!rest) {
-+ struct object_id oid;
-+ const char *head_target;
-+ const char *short_target;
-+
+ strbuf_addf(&dst, "refs/remotes/%s/HEAD", remote_name);
+ head_target = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+ dst.buf,
+ RESOLVE_REF_READING |
+ RESOLVE_REF_NO_RECURSE,
+ &oid, NULL);
++ if (head_target) {
++ *existing_ref_out = xstrdup(dst.buf);
++ if (skip_prefix(head_target, "refs/remotes/", &short_target) &&
++ skip_prefix(short_target, remote_name, &short_target) &&
++ *short_target == '/')
++ rest = short_target + 1;
++ }
+ strbuf_reset(&dst);
-+ if (head_target &&
-+ skip_prefix(head_target, "refs/remotes/", &short_target) &&
-+ skip_prefix(short_target, remote_name, &short_target) &&
-+ *short_target == '/')
-+ rest = short_target + 1;
+ }
+
+ if (rest) {
@@ builtin/checkout.c: struct branch_info {
+ } else {
+ *src_ref_out = xstrdup(rest);
+ }
++ if (!*existing_ref_out) {
++ strbuf_reset(&dst);
++ strbuf_addf(&dst, "refs/remotes/%s", arg);
++ if (!refs_read_ref(get_main_ref_store(the_repository),
++ dst.buf, &oid))
++ *existing_ref_out = xstrdup(dst.buf);
++ }
+ }
+
+ strbuf_release(&dst);
@@ builtin/checkout.c: struct branch_info {
+{
+ char *remote_name = NULL;
+ char *src_ref = NULL;
++ char *existing_ref = NULL;
+ struct child_process cmd = CHILD_PROCESS_INIT;
-+ struct strbuf dst_ref = STRBUF_INIT;
-+ int have_existing_ref = 0;
+
-+ if (resolve_fetch_target(arg, &remote_name, &src_ref))
++ if (resolve_fetch_target(arg, &remote_name, &src_ref, &existing_ref))
+ return;
+
-+ {
-+ struct object_id oid;
-+
-+ if (strchr(arg, '/'))
-+ strbuf_addf(&dst_ref, "refs/remotes/%s", arg);
-+ else
-+ strbuf_addf(&dst_ref, "refs/remotes/%s/HEAD", arg);
-+ if (!refs_read_ref(get_main_ref_store(the_repository),
-+ dst_ref.buf, &oid))
-+ have_existing_ref = 1;
-+ }
-+
+ strvec_pushl(&cmd.args, "fetch", remote_name, NULL);
+ if (src_ref)
+ strvec_push(&cmd.args, src_ref);
+ cmd.git_cmd = 1;
+ if (run_command(&cmd)) {
-+ if (have_existing_ref)
++ if (existing_ref)
+ warning(_("failed to fetch start-point '%s'; "
+ "using existing '%s'"),
-+ arg, dst_ref.buf);
++ arg, existing_ref);
+ else
+ die(_("failed to fetch start-point '%s'"), arg);
+ }
+
+ free(remote_name);
+ free(src_ref);
-+ strbuf_release(&dst_ref);
++ free(existing_ref);
+}
+
+static int parse_opt_checkout_track(const struct option *opt,
@@ builtin/checkout.c: struct branch_info {
static void branch_info_release(struct branch_info *info)
{
free(info->name);
-@@ builtin/checkout.c: static int git_checkout_config(const char *var, const char *value,
- opts->dwim_new_local_branch = git_config_bool(var, value);
- return 0;
- }
--
- if (starts_with(var, "submodule."))
- return git_default_submodule_config(var, value, NULL);
-
@@ builtin/checkout.c: static struct option *add_common_switch_branch_options(
{
struct option options[] = {
@@ t/t7201-co.sh: test_expect_success 'tracking info copied with autoSetupMerge=inh
+ test_cmp_rev refs/remotes/custom-ns/fetch_refspec HEAD
+'
+
++test_expect_success 'checkout --track=fetch handles hierarchical remote name' '
++ git checkout main &&
++ git -C fetch_upstream checkout -b fetch_hier &&
++ test_commit -C fetch_upstream u_hier &&
++ git remote add nested/remote ./fetch_upstream &&
++ test_when_finished "git remote remove nested/remote" &&
++ git fetch nested/remote fetch_hier &&
++ test_commit -C fetch_upstream u_hier_post &&
++ git checkout --track=fetch -b local_hier nested/remote/fetch_hier &&
++ test_cmp_rev refs/remotes/nested/remote/fetch_hier HEAD
++'
++
+test_expect_success 'checkout --track=inherit,direct is rejected' '
+ test_must_fail git checkout --track=inherit,direct -b bad fetch_upstream/fetch_new 2>err &&
+ test_grep "cannot combine" err
Documentation/git-checkout.adoc | 13 ++-
Documentation/git-switch.adoc | 13 ++-
builtin/checkout.c | 168 +++++++++++++++++++++++++++++++-
t/t7201-co.sh | 144 +++++++++++++++++++++++++++
4 files changed, 332 insertions(+), 6 deletions(-)
diff --git a/Documentation/git-checkout.adoc b/Documentation/git-checkout.adoc
index 43ccf47cf6..28f17f427e 100644
--- a/Documentation/git-checkout.adoc
+++ b/Documentation/git-checkout.adoc
@@ -158,11 +158,22 @@ of it").
resets _<branch>_ to the start point instead of failing.
`-t`::
-`--track[=(direct|inherit)]`::
+`--track[=(direct|inherit|fetch)[,...]]`::
When creating a new branch, set up "upstream" configuration. See
`--track` in linkgit:git-branch[1] for details. As a convenience,
--track without -b implies branch creation.
+
+The argument is a comma-separated list. `direct` (the default) and
+`inherit` select the tracking mode and are mutually exclusive. Adding
+`fetch` requests that the remote be fetched before _<start-point>_ is
+resolved, so the new branch starts from a fresh tip: when
+_<start-point>_ is in _<remote>/<branch>_ form, only that branch is
+updated; when _<start-point>_ is a bare remote name (e.g. `origin`),
+only the remote's default branch is updated. If the fetch fails and the
+corresponding remote-tracking ref already exists, a warning is printed
+and the checkout proceeds from the existing tip; otherwise the checkout
+is aborted.
++
If no `-b` option is given, the name of the new branch will be
derived from the remote-tracking branch, by looking at the local part of
the refspec configured for the corresponding remote, and then stripping
diff --git a/Documentation/git-switch.adoc b/Documentation/git-switch.adoc
index 87707e9265..3f54cf39e9 100644
--- a/Documentation/git-switch.adoc
+++ b/Documentation/git-switch.adoc
@@ -154,11 +154,22 @@ should result in deletion of the path).
attached to a terminal, regardless of `--quiet`.
`-t`::
-`--track[ (direct|inherit)]`::
+`--track[=(direct|inherit|fetch)[,...]]`::
When creating a new branch, set up "upstream" configuration.
`-c` is implied. See `--track` in linkgit:git-branch[1] for
details.
+
+The argument is a comma-separated list. `direct` (the default) and
+`inherit` select the tracking mode and are mutually exclusive. Adding
+`fetch` requests that the remote be fetched before _<start-point>_ is
+resolved, so the new branch starts from a fresh tip: when
+_<start-point>_ is in _<remote>/<branch>_ form, only that branch is
+updated; when _<start-point>_ is a bare remote name (e.g. `origin`),
+only the remote's default branch is updated. If the fetch fails and the
+corresponding remote-tracking ref already exists, a warning is printed
+and the switch proceeds from the existing tip; otherwise the switch is
+aborted.
++
If no `-c` option is given, the name of the new branch will be derived
from the remote-tracking branch, by looking at the local part of the
refspec configured for the corresponding remote, and then stripping
diff --git a/builtin/checkout.c b/builtin/checkout.c
index ac0186a33e..aff442c526 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -26,10 +26,12 @@
#include "preload-index.h"
#include "read-cache.h"
#include "refs.h"
+#include "refspec.h"
#include "remote.h"
#include "repo-settings.h"
#include "resolve-undo.h"
#include "revision.h"
+#include "run-command.h"
#include "setup.h"
#include "strvec.h"
#include "submodule.h"
@@ -62,6 +64,7 @@ struct checkout_opts {
int count_checkout_paths;
int overlay_mode;
int dwim_new_local_branch;
+ int fetch;
int discard_changes;
int accept_ref;
int accept_pathspec;
@@ -113,6 +116,158 @@ struct branch_info {
char *checkout;
};
+static int resolve_fetch_target(const char *arg, char **remote_out,
+ char **src_ref_out, char **existing_ref_out)
+{
+ const char *slash;
+ char *remote_name = NULL;
+ struct remote *remote = NULL;
+ struct refspec_item query = { 0 };
+ struct strbuf dst = STRBUF_INIT;
+ struct object_id oid;
+ const char *rest = NULL;
+ const char *head_target = NULL;
+ const char *short_target;
+
+ *remote_out = NULL;
+ *src_ref_out = NULL;
+ *existing_ref_out = NULL;
+
+ if (!arg || !*arg || *arg == '/')
+ return -1;
+
+ slash = arg + strlen(arg);
+ while (1) {
+ free(remote_name);
+ remote_name = xstrndup(arg, slash - arg);
+ remote = remote_get(remote_name);
+ if (remote && remote_is_configured(remote, 1))
+ break;
+ while (slash > arg && *--slash != '/')
+ ;
+ if (slash == arg) {
+ free(remote_name);
+ return -1;
+ }
+ }
+
+ if (*slash == '/' && slash[1])
+ rest = slash + 1;
+ if (!rest) {
+ strbuf_addf(&dst, "refs/remotes/%s/HEAD", remote_name);
+ head_target = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
+ dst.buf,
+ RESOLVE_REF_READING |
+ RESOLVE_REF_NO_RECURSE,
+ &oid, NULL);
+ if (head_target) {
+ *existing_ref_out = xstrdup(dst.buf);
+ if (skip_prefix(head_target, "refs/remotes/", &short_target) &&
+ skip_prefix(short_target, remote_name, &short_target) &&
+ *short_target == '/')
+ rest = short_target + 1;
+ }
+ strbuf_reset(&dst);
+ }
+
+ if (rest) {
+ strbuf_addf(&dst, "refs/remotes/%s/%s", remote_name, rest);
+ query.dst = dst.buf;
+ if (!remote_find_tracking(remote, &query) && query.src) {
+ *src_ref_out = xstrdup(query.src);
+ free(query.src);
+ } else {
+ *src_ref_out = xstrdup(rest);
+ }
+ if (!*existing_ref_out) {
+ strbuf_reset(&dst);
+ strbuf_addf(&dst, "refs/remotes/%s", arg);
+ if (!refs_read_ref(get_main_ref_store(the_repository),
+ dst.buf, &oid))
+ *existing_ref_out = xstrdup(dst.buf);
+ }
+ }
+
+ strbuf_release(&dst);
+ *remote_out = remote_name;
+ return 0;
+}
+
+static void fetch_remote_for_start_point(const char *arg)
+{
+ char *remote_name = NULL;
+ char *src_ref = NULL;
+ char *existing_ref = NULL;
+ struct child_process cmd = CHILD_PROCESS_INIT;
+
+ if (resolve_fetch_target(arg, &remote_name, &src_ref, &existing_ref))
+ return;
+
+ strvec_pushl(&cmd.args, "fetch", remote_name, NULL);
+ if (src_ref)
+ strvec_push(&cmd.args, src_ref);
+ cmd.git_cmd = 1;
+ if (run_command(&cmd)) {
+ if (existing_ref)
+ warning(_("failed to fetch start-point '%s'; "
+ "using existing '%s'"),
+ arg, existing_ref);
+ else
+ die(_("failed to fetch start-point '%s'"), arg);
+ }
+
+ free(remote_name);
+ free(src_ref);
+ free(existing_ref);
+}
+
+static int parse_opt_checkout_track(const struct option *opt,
+ const char *arg, int unset)
+{
+ struct checkout_opts *opts = opt->value;
+ struct string_list tokens = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ int saw_direct = 0, saw_inherit = 0;
+ int ret = 0;
+
+ opts->fetch = 0;
+
+ if (unset) {
+ opts->track = BRANCH_TRACK_NEVER;
+ return 0;
+ }
+
+ opts->track = BRANCH_TRACK_EXPLICIT;
+ if (!arg)
+ return 0;
+
+ string_list_split(&tokens, arg, ",", -1);
+ for_each_string_list_item(item, &tokens) {
+ if (!strcmp(item->string, "fetch")) {
+ opts->fetch = 1;
+ } else if (!strcmp(item->string, "direct")) {
+ saw_direct = 1;
+ opts->track = BRANCH_TRACK_EXPLICIT;
+ } else if (!strcmp(item->string, "inherit")) {
+ saw_inherit = 1;
+ opts->track = BRANCH_TRACK_INHERIT;
+ } else {
+ ret = error(_("option `%s' expects \"%s\", \"%s\", "
+ "or \"%s\""),
+ "--track", "direct", "inherit", "fetch");
+ goto out;
+ }
+ }
+
+ if (saw_direct && saw_inherit)
+ ret = error(_("option `%s' cannot combine \"%s\" and \"%s\""),
+ "--track", "direct", "inherit");
+
+out:
+ string_list_clear(&tokens, 0);
+ return ret;
+}
+
static void branch_info_release(struct branch_info *info)
{
free(info->name);
@@ -1741,10 +1896,10 @@ static struct option *add_common_switch_branch_options(
{
struct option options[] = {
OPT_BOOL('d', "detach", &opts->force_detach, N_("detach HEAD at named commit")),
- OPT_CALLBACK_F('t', "track", &opts->track, "(direct|inherit)",
+ OPT_CALLBACK_F('t', "track", opts, "(direct|inherit|fetch)[,...]",
N_("set branch tracking configuration"),
PARSE_OPT_OPTARG,
- parse_opt_tracking_mode),
+ parse_opt_checkout_track),
OPT__FORCE(&opts->force, N_("force checkout (throw away local modifications)"),
PARSE_OPT_NOCOMPLETE),
OPT_STRING(0, "orphan", &opts->new_orphan_branch, N_("new-branch"), N_("new unborn branch")),
@@ -1949,8 +2104,13 @@ static int checkout_main(int argc, const char **argv, const char *prefix,
opts->dwim_new_local_branch &&
opts->track == BRANCH_TRACK_UNSPECIFIED &&
!opts->new_branch;
- int n = parse_branchname_arg(argc, argv, dwim_ok, which_command,
- &new_branch_info, opts, &rev);
+ int n;
+
+ if (opts->fetch)
+ fetch_remote_for_start_point(argv[0]);
+
+ n = parse_branchname_arg(argc, argv, dwim_ok, which_command,
+ &new_branch_info, opts, &rev);
argv += n;
argc -= n;
} else if (!opts->accept_ref && opts->from_treeish) {
diff --git a/t/t7201-co.sh b/t/t7201-co.sh
index 9bcf7c0b40..6dfe9ec931 100755
--- a/t/t7201-co.sh
+++ b/t/t7201-co.sh
@@ -801,4 +801,148 @@ test_expect_success 'tracking info copied with autoSetupMerge=inherit' '
test_cmp_config "" --default "" branch.main2.merge
'
+test_expect_success 'setup upstream for --track=fetch tests' '
+ git checkout main &&
+ git init fetch_upstream &&
+ test_commit -C fetch_upstream u_main &&
+ git remote add fetch_upstream fetch_upstream &&
+ git fetch fetch_upstream &&
+ git -C fetch_upstream checkout -b fetch_new &&
+ test_commit -C fetch_upstream u_new
+'
+
+test_expect_success 'checkout --track=fetch -b picks up branch created upstream after clone' '
+ git checkout main &&
+ test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_new &&
+ git checkout --track=fetch -b local_new fetch_upstream/fetch_new &&
+ test_cmp_rev refs/remotes/fetch_upstream/fetch_new HEAD &&
+ test_cmp_config fetch_upstream branch.local_new.remote &&
+ test_cmp_config refs/heads/fetch_new branch.local_new.merge
+'
+
+test_expect_success 'checkout --track=fetch <remote>/<branch> leaves other tracking branches untouched' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_target &&
+ test_commit -C fetch_upstream u_target_pre &&
+ git -C fetch_upstream checkout -b fetch_other &&
+ test_commit -C fetch_upstream u_other_pre &&
+ git fetch fetch_upstream &&
+ other_before=$(git rev-parse refs/remotes/fetch_upstream/fetch_other) &&
+ git -C fetch_upstream checkout fetch_target &&
+ test_commit -C fetch_upstream u_target_post &&
+ git -C fetch_upstream checkout fetch_other &&
+ test_commit -C fetch_upstream u_other_post &&
+ git checkout --track=fetch -b local_target fetch_upstream/fetch_target &&
+ test_cmp_rev refs/remotes/fetch_upstream/fetch_target HEAD &&
+ test "$(git rev-parse refs/remotes/fetch_upstream/fetch_other)" = "$other_before"
+'
+
+test_expect_success 'checkout --track=fetch with bare remote name fetches only <remote>/HEAD target' '
+ git checkout main &&
+ git -C fetch_upstream checkout main &&
+ git remote set-head fetch_upstream main &&
+ git -C fetch_upstream checkout -b fetch_unrelated &&
+ test_commit -C fetch_upstream u_unrelated_pre &&
+ git fetch fetch_upstream fetch_unrelated &&
+ unrelated_before=$(git rev-parse refs/remotes/fetch_upstream/fetch_unrelated) &&
+ git -C fetch_upstream checkout main &&
+ test_commit -C fetch_upstream u_main_post &&
+ git -C fetch_upstream checkout fetch_unrelated &&
+ test_commit -C fetch_upstream u_unrelated_post &&
+ git checkout --track=fetch -b local_from_remote fetch_upstream &&
+ test_cmp_rev refs/remotes/fetch_upstream/main HEAD &&
+ test "$(git rev-parse refs/remotes/fetch_upstream/fetch_unrelated)" = "$unrelated_before"
+'
+
+test_expect_success 'checkout --track=fetch aborts and does not create branch when no existing ref' '
+ git checkout main &&
+ test_might_fail git branch -D bogus &&
+ test_must_fail git checkout --track=fetch -b bogus fetch_upstream/does_not_exist &&
+ test_must_fail git rev-parse --verify refs/heads/bogus
+'
+
+test_expect_success 'checkout --track=fetch warns and proceeds when fetch fails but ref exists' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_offline &&
+ test_commit -C fetch_upstream u_offline &&
+ git fetch fetch_upstream fetch_offline &&
+ saved_url=$(git config remote.fetch_upstream.url) &&
+ test_when_finished "git config remote.fetch_upstream.url \"$saved_url\"" &&
+ git config remote.fetch_upstream.url ./does-not-exist &&
+ git checkout --track=fetch -b local_offline fetch_upstream/fetch_offline 2>err &&
+ test_grep "failed to fetch" err &&
+ test_cmp_rev refs/remotes/fetch_upstream/fetch_offline HEAD
+'
+
+test_expect_success 'checkout --track=fetch resolves through configured fetch refspec' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_refspec &&
+ test_commit -C fetch_upstream u_refspec &&
+ git fetch fetch_upstream fetch_refspec &&
+ git remote add fetch_custom ./fetch_upstream &&
+ test_when_finished "git remote remove fetch_custom" &&
+ git config --replace-all remote.fetch_custom.fetch \
+ "+refs/heads/*:refs/remotes/custom-ns/*" &&
+ git fetch fetch_custom &&
+ test_commit -C fetch_upstream u_refspec_post &&
+ git checkout --track=fetch -b local_refspec custom-ns/fetch_refspec &&
+ test_cmp_rev refs/remotes/custom-ns/fetch_refspec HEAD
+'
+
+test_expect_success 'checkout --track=fetch handles hierarchical remote name' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_hier &&
+ test_commit -C fetch_upstream u_hier &&
+ git remote add nested/remote ./fetch_upstream &&
+ test_when_finished "git remote remove nested/remote" &&
+ git fetch nested/remote fetch_hier &&
+ test_commit -C fetch_upstream u_hier_post &&
+ git checkout --track=fetch -b local_hier nested/remote/fetch_hier &&
+ test_cmp_rev refs/remotes/nested/remote/fetch_hier HEAD
+'
+
+test_expect_success 'checkout --track=inherit,direct is rejected' '
+ test_must_fail git checkout --track=inherit,direct -b bad fetch_upstream/fetch_new 2>err &&
+ test_grep "cannot combine" err
+'
+
+test_expect_success 'checkout --track=fetch then --track=direct drops fetch (last-one-wins)' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_lastwin &&
+ test_commit -C fetch_upstream u_lastwin &&
+ test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_lastwin &&
+ test_must_fail git checkout --track=fetch --track=direct \
+ -b local_lastwin fetch_upstream/fetch_lastwin &&
+ test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_lastwin
+'
+
+test_expect_success 'checkout --track=fetch,inherit fetches and inherits' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_inherit &&
+ test_commit -C fetch_upstream u_inherit &&
+ git fetch fetch_upstream fetch_inherit &&
+ git checkout -b base_inherit fetch_upstream/fetch_inherit &&
+ test_commit -C fetch_upstream u_inherit2 &&
+ git checkout main &&
+ git checkout --track=fetch,inherit -b local_inherit base_inherit &&
+ test_cmp_rev refs/remotes/fetch_upstream/fetch_inherit HEAD &&
+ test_cmp_config fetch_upstream branch.local_inherit.remote &&
+ test_cmp_config refs/heads/fetch_inherit branch.local_inherit.merge
+'
+
+test_expect_success 'checkout --track=bogus reports an error' '
+ git checkout main &&
+ test_must_fail git checkout --track=bogus -b bogus_branch fetch_upstream/fetch_new 2>err &&
+ test_grep "expects" err
+'
+
+test_expect_success 'switch --track=fetch -c picks up branch created upstream after clone' '
+ git checkout main &&
+ git -C fetch_upstream checkout -b fetch_switch &&
+ test_commit -C fetch_upstream u_switch &&
+ test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_switch &&
+ git switch --track=fetch -c local_switch fetch_upstream/fetch_switch &&
+ test_cmp_rev refs/remotes/fetch_upstream/fetch_switch HEAD
+'
+
test_done
base-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594
--
gitgitgadget
^ permalink raw reply related
* Re: [PATCH v3 2/7] fetch: add --negotiation-restrict option
From: Matthew John Cheetham @ 2026-05-12 11:11 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <fe875399a851ba27ab193463cb6a1faf62aa6835.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> The --negotiation-tip option to 'git fetch' and 'git pull' allows users
> to specify that they want to focus negotiation on a small set of
> references. This is a _restriction_ on the negotiation set, helping to
> focus the negotiation when the ref count is high. However, it doesn't
> allow for the ability to opportunistically select references beyond that
> list.
>
> This subtle detail that this is a 'maximum set' and not a 'minimum set'
> is not immediately clear from the option name. This makes it more
> complicated to add a new option that provides the complementary behavior
> of a minimum set.
>
> For now, create a new synonym option, --negotiation-restrict, that
> behaves identically to --negotiation-tip. Update the documentation to
> make it clear that this new name is the preferred option, but we keep
> the old name for compatibility. Mark --negotiation-tip as an alias of the
> new, preferred option.
This motivation reads well. Preparing the new naming convention before
introducing the new, complementary option, is the right order to do this
in, IMO.
> Update a few warning messages with the new option, but also make them
> translatable with the option name inserted by formatting. At least one
> of these messages will be reused later for a new option.
Nice extra win!
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> Documentation/fetch-options.adoc | 4 ++++
> builtin/fetch.c | 13 ++++++++-----
> builtin/pull.c | 3 +++
> t/t5510-fetch.sh | 25 +++++++++++++++++++++++++
> t/t5702-protocol-v2.sh | 4 ++--
> 5 files changed, 42 insertions(+), 7 deletions(-)
>
> diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
> index 81a9d7f9bb..c07b85499f 100644
> --- a/Documentation/fetch-options.adoc
> +++ b/Documentation/fetch-options.adoc
> @@ -49,6 +49,7 @@ the current repository has the same history as the source repository.
> `.git/shallow`. This option updates `.git/shallow` and accepts such
> refs.
>
> +`--negotiation-restrict=(<commit>|<glob>)`::
> `--negotiation-tip=(<commit>|<glob>)`::
> By default, Git will report, to the server, commits reachable
> from all local refs to find common commits in an attempt to
> @@ -58,6 +59,9 @@ the current repository has the same history as the source repository.
> local ref is likely to have commits in common with the
> upstream ref being fetched.
> +
> +`--negotiation-restrict` is the preferred name for this option;
> +`--negotiation-tip` is accepted as a synonym.
> ++
> This option may be specified more than once; if so, Git will report
> commits reachable from any of the given commits.
> +
By my eyes it looks like two other references to the old name remain and
could also be updated for consistency (since --negotiation-restrict is
now the preferred name):
1. Documentation/fetch-options.adoc, under `--negotiate-only`:
"ancestors of the provided `--negotiation-tip=` arguments"
2. Documentation/config/fetch.adoc:
"See also the `--negotiate-only` and `--negotiation-tip` options"
Of course the old name will still work, so this is more a nit-pick :-)
> diff --git a/builtin/fetch.c b/builtin/fetch.c
> index 4795b2a13c..fc950fe35b 100644
> --- a/builtin/fetch.c
> +++ b/builtin/fetch.c
> @@ -1558,8 +1558,8 @@ static void add_negotiation_tips(struct git_transport_options *smart_options)
> refs_for_each_ref_ext(get_main_ref_store(the_repository),
> add_oid, oids, &opts);
> if (old_nr == oids->nr)
> - warning("ignoring --negotiation-tip=%s because it does not match any refs",
> - s);
> + warning(_("ignoring %s=%s because it does not match any refs"),
> + "--negotiation-restrict", s);
> }
> smart_options->negotiation_tips = oids;
> }
Keeping the option name out of the translation string prevents
accidental translation of a fixed symbol - good.
> @@ -1599,7 +1599,8 @@ static struct transport *prepare_transport(struct remote *remote, int deepen,
> if (transport->smart_options)
> add_negotiation_tips(transport->smart_options);
> else
> - warning("ignoring --negotiation-tip because the protocol does not support it");
> + warning(_("ignoring %s because the protocol does not support it"),
> + "--negotiation-restrict");
> }
> return transport;
> }
Same as above - good.
> @@ -2565,8 +2566,9 @@ int cmd_fetch(int argc,
> N_("specify fetch refmap"), PARSE_OPT_NONEG, parse_refmap_arg),
> OPT_STRING_LIST('o', "server-option", &server_options, N_("server-specific"), N_("option to transmit")),
> OPT_IPVERSION(&family),
> - OPT_STRING_LIST(0, "negotiation-tip", &negotiation_tip, N_("revision"),
> + OPT_STRING_LIST(0, "negotiation-restrict", &negotiation_tip, N_("revision"),
> N_("report that we have only objects reachable from this object")),
> + OPT_ALIAS(0, "negotiation-tip", "negotiation-restrict"),
> OPT_BOOL(0, "negotiate-only", &negotiate_only,
> N_("do not fetch a packfile; instead, print ancestors of negotiation tips")),
> OPT_PARSE_LIST_OBJECTS_FILTER(&filter_options),
Good. Makes the --negotiate-restrict name the primary and the 'tip' the
alias, matching the docs' preference.
Keeping the variable named `negotiate_tip` helps reduce churn in this
patch (and has no outwardly visible impact anyway). I see a future patch
renames the variable - nice choice for reviewability.
> @@ -2657,7 +2659,8 @@ int cmd_fetch(int argc,
> }
>
> if (negotiate_only && !negotiation_tip.nr)
> - die(_("--negotiate-only needs one or more --negotiation-tip=*"));
> + die(_("%s needs one or more %s"), "--negotiate-only",
> + "--negotiation-restrict=*");
>
> if (deepen_relative) {
> if (deepen_relative < 0)
Much love for i18n!
> diff --git a/builtin/pull.c b/builtin/pull.c
> index 7e67fdce97..821cc6699a 100644
> --- a/builtin/pull.c
> +++ b/builtin/pull.c
> @@ -999,6 +999,9 @@ int cmd_pull(int argc,
> OPT_PASSTHRU_ARGV(0, "negotiation-tip", &opt_fetch, N_("revision"),
> N_("report that we have only objects reachable from this object"),
> 0),
> + OPT_PASSTHRU_ARGV(0, "negotiation-restrict", &opt_fetch, N_("revision"),
> + N_("report that we have only objects reachable from this object"),
> + 0),
> OPT_BOOL(0, "show-forced-updates", &opt_show_forced_updates,
> N_("check for forced-updates on all updated branches")),
> OPT_PASSTHRU(0, "set-upstream", &set_upstream, NULL,
It's a shame we don't have a nice way to combine the `OPT_ALIAS` and
`OPT_PASSTHRU_ARGV` functionality, but it's only a small duplication
cost of the repeated definition.
> diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
> index 5dcb4b51a4..dc3ce56d84 100755
> --- a/t/t5510-fetch.sh
> +++ b/t/t5510-fetch.sh
> @@ -1460,6 +1460,31 @@ EOF
> test_cmp fatal-expect fatal-actual
> '
>
> +test_expect_success '--negotiation-restrict limits "have" lines sent' '
> + setup_negotiation_tip server server 0 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 --negotiation-restrict=beta_1 \
> + origin alpha_s beta_s &&
> + check_negotiation_tip
> +'
> +
> +test_expect_success '--negotiation-restrict understands globs' '
> + setup_negotiation_tip server server 0 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=*_1 \
> + origin alpha_s beta_s &&
> + check_negotiation_tip
> +'
> +
> +test_expect_success '--negotiation-restrict and --negotiation-tip can be mixed' '
> + setup_negotiation_tip server server 0 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 \
> + --negotiation-tip=beta_1 \
> + origin alpha_s beta_s &&
> + check_negotiation_tip
> +'
> +
> test_expect_success SYMLINKS 'clone does not get confused by a D/F conflict' '
> git init df-conflict &&
> (
> diff --git a/t/t5702-protocol-v2.sh b/t/t5702-protocol-v2.sh
> index f826ac46a5..9f6cf4142d 100755
> --- a/t/t5702-protocol-v2.sh
> +++ b/t/t5702-protocol-v2.sh
> @@ -869,14 +869,14 @@ setup_negotiate_only () {
> test_commit -C client three
> }
>
> -test_expect_success 'usage: --negotiate-only without --negotiation-tip' '
> +test_expect_success 'usage: --negotiate-only without --negotiation-restrict' '
> SERVER="server" &&
> URI="file://$(pwd)/server" &&
>
> setup_negotiate_only "$SERVER" "$URI" &&
>
> cat >err.expect <<-\EOF &&
> - fatal: --negotiate-only needs one or more --negotiation-tip=*
> + fatal: --negotiate-only needs one or more --negotiation-restrict=*
> EOF
>
> test_must_fail git -c protocol.version=2 -C client fetch \
Looks like this test is the only place asserting the '--negotiate-tip'
string literal in the tree - good, no others to update.
Except the two doc cross-references above (nits) this looks good to me.
Thanks,
Matthew
^ permalink raw reply
* Re: [PATCH v3 3/7] transport: rename negotiation_tips
From: Matthew John Cheetham @ 2026-05-12 11:30 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <4332cbf26631516030da32fd6de6ae134f8a6645.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee <stolee@gmail.com>
>
> The previous change added the --negotiation-restrict synonym for the
> --negotiation-tips option for 'git fetch'. In anticipation of adding a
> new option that behaves similarly but with distinct changes to its
> behavior, rename the internal representation of this data from
> 'negotiation_tips' to 'negotiation_restrict_tips'.
Nitpick: s/tips/tip/ .. no trailing s for either the option name, nor
the (old) variable name. The function names do use the plural however.
> The 'tips' part is kept because this is an oid_array in the transport
> layer. This requires the builtin to handle parsing refs into collections
> of oids so the transport layer can handle this cleaner form of the data.
>
> Also update the string_list used to store the inputs from command-line
> options.
>
> Signed-off-by: Derrick Stolee <stolee@gmail.com>
> ---
> builtin/fetch.c | 18 +++++++++---------
> fetch-pack.c | 18 +++++++++---------
> fetch-pack.h | 4 ++--
> transport-helper.c | 2 +-
> transport.c | 10 +++++-----
> transport.h | 4 ++--
> 6 files changed, 28 insertions(+), 28 deletions(-)
>
> diff --git a/builtin/fetch.c b/builtin/fetch.c
> index fc950fe35b..2ba0051d52 100644
> --- a/builtin/fetch.c
> +++ b/builtin/fetch.c
> @@ -98,7 +98,7 @@ static struct transport *gtransport;
> static struct transport *gsecondary;
> static struct refspec refmap = REFSPEC_INIT_FETCH;
> static struct string_list server_options = STRING_LIST_INIT_DUP;
> -static struct string_list negotiation_tip = STRING_LIST_INIT_NODUP;
> +static struct string_list negotiation_restrict = STRING_LIST_INIT_NODUP;
Good - now mirrors the new, preferred, option name.
> struct fetch_config {
> enum display_format display_format;
> @@ -1534,13 +1534,13 @@ static int add_oid(const struct reference *ref, void *cb_data)
> return 0;
> }
>
> -static void add_negotiation_tips(struct git_transport_options *smart_options)
> +static void add_negotiation_restrict_tips(struct git_transport_options *smart_options)
> {
> struct oid_array *oids = xcalloc(1, sizeof(*oids));
> int i;
>
> - for (i = 0; i < negotiation_tip.nr; i++) {
> - const char *s = negotiation_tip.items[i].string;
> + for (i = 0; i < negotiation_restrict.nr; i++) {
> + const char *s = negotiation_restrict.items[i].string;
> struct refs_for_each_ref_options opts = {
> .pattern = s,
> };
All callers and references are renamed to match consistency. Good.
> @@ -1561,7 +1561,7 @@ static void add_negotiation_tips(struct git_transport_options *smart_options)
> warning(_("ignoring %s=%s because it does not match any refs"),
> "--negotiation-restrict", s);
> }
> - smart_options->negotiation_tips = oids;
> + smart_options->negotiation_restrict_tips = oids;
> }
>
> static struct transport *prepare_transport(struct remote *remote, int deepen,
> @@ -1595,9 +1595,9 @@ static struct transport *prepare_transport(struct remote *remote, int deepen,
> set_option(transport, TRANS_OPT_LIST_OBJECTS_FILTER, spec);
> set_option(transport, TRANS_OPT_FROM_PROMISOR, "1");
> }
> - if (negotiation_tip.nr) {
> + if (negotiation_restrict.nr) {
> if (transport->smart_options)
> - add_negotiation_tips(transport->smart_options);
> + add_negotiation_restrict_tips(transport->smart_options);
> else
> warning(_("ignoring %s because the protocol does not support it"),
> "--negotiation-restrict");
> @@ -2566,7 +2566,7 @@ int cmd_fetch(int argc,
> N_("specify fetch refmap"), PARSE_OPT_NONEG, parse_refmap_arg),
> OPT_STRING_LIST('o', "server-option", &server_options, N_("server-specific"), N_("option to transmit")),
> OPT_IPVERSION(&family),
> - OPT_STRING_LIST(0, "negotiation-restrict", &negotiation_tip, N_("revision"),
> + OPT_STRING_LIST(0, "negotiation-restrict", &negotiation_restrict, N_("revision"),
> N_("report that we have only objects reachable from this object")),
> OPT_ALIAS(0, "negotiation-tip", "negotiation-restrict"),
> OPT_BOOL(0, "negotiate-only", &negotiate_only,
> @@ -2658,7 +2658,7 @@ int cmd_fetch(int argc,
> config.display_format = DISPLAY_FORMAT_PORCELAIN;
> }
>
> - if (negotiate_only && !negotiation_tip.nr)
> + if (negotiate_only && !negotiation_restrict.nr)
> die(_("%s needs one or more %s"), "--negotiate-only",
> "--negotiation-restrict=*");
>
> diff --git a/fetch-pack.c b/fetch-pack.c
> index 6ecd468ef7..baf239adf9 100644
> --- a/fetch-pack.c
> +++ b/fetch-pack.c
> @@ -291,21 +291,21 @@ static int next_flush(int stateless_rpc, int count)
> }
>
> static void mark_tips(struct fetch_negotiator *negotiator,
> - const struct oid_array *negotiation_tips)
> + const struct oid_array *negotiation_restrict_tips)
> {
> struct refs_for_each_ref_options opts = {
> .flags = REFS_FOR_EACH_INCLUDE_BROKEN,
> };
> int i;
>
> - if (!negotiation_tips) {
> + if (!negotiation_restrict_tips) {
> refs_for_each_ref_ext(get_main_ref_store(the_repository),
> rev_list_insert_ref_oid, negotiator, &opts);
> return;
> }
>
> - for (i = 0; i < negotiation_tips->nr; i++)
> - rev_list_insert_ref(negotiator, &negotiation_tips->oid[i]);
> + for (i = 0; i < negotiation_restrict_tips->nr; i++)
> + rev_list_insert_ref(negotiator, &negotiation_restrict_tips->oid[i]);
> return;
> }
>
> @@ -355,7 +355,7 @@ static int find_common(struct fetch_negotiator *negotiator,
> PACKET_READ_CHOMP_NEWLINE |
> PACKET_READ_DIE_ON_ERR_PACKET);
>
> - mark_tips(negotiator, args->negotiation_tips);
> + mark_tips(negotiator, args->negotiation_restrict_tips);
> for_each_cached_alternate(negotiator, insert_one_alternate_object);
>
> fetching = 0;
> @@ -1728,7 +1728,7 @@ static struct ref *do_fetch_pack_v2(struct fetch_pack_args *args,
> else
> state = FETCH_SEND_REQUEST;
>
> - mark_tips(negotiator, args->negotiation_tips);
> + mark_tips(negotiator, args->negotiation_restrict_tips);
> for_each_cached_alternate(negotiator,
> insert_one_alternate_object);
> break;
> @@ -2177,7 +2177,7 @@ static void clear_common_flag(struct oidset *s)
> }
> }
>
> -void negotiate_using_fetch(const struct oid_array *negotiation_tips,
> +void negotiate_using_fetch(const struct oid_array *negotiation_restrict_tips,
> const struct string_list *server_options,
> int stateless_rpc,
> int fd[],
> @@ -2195,13 +2195,13 @@ void negotiate_using_fetch(const struct oid_array *negotiation_tips,
> timestamp_t min_generation = GENERATION_NUMBER_INFINITY;
>
> fetch_negotiator_init(the_repository, &negotiator);
> - mark_tips(&negotiator, negotiation_tips);
> + mark_tips(&negotiator, negotiation_restrict_tips);
>
> packet_reader_init(&reader, fd[0], NULL, 0,
> PACKET_READ_CHOMP_NEWLINE |
> PACKET_READ_DIE_ON_ERR_PACKET);
>
> - oid_array_for_each((struct oid_array *) negotiation_tips,
> + oid_array_for_each((struct oid_array *) negotiation_restrict_tips,
> add_to_object_array,
> &nt_object_array);
>
> diff --git a/fetch-pack.h b/fetch-pack.h
> index 9d3470366f..6c70c942c2 100644
> --- a/fetch-pack.h
> +++ b/fetch-pack.h
> @@ -21,7 +21,7 @@ struct fetch_pack_args {
> * If not NULL, during packfile negotiation, fetch-pack will send "have"
> * lines only with these tips and their ancestors.
> */
> - const struct oid_array *negotiation_tips;
> + const struct oid_array *negotiation_restrict_tips;
>
> unsigned deepen_relative:1;
> unsigned quiet:1;
> @@ -89,7 +89,7 @@ struct ref *fetch_pack(struct fetch_pack_args *args,
> * In the capability advertisement that has happened prior to invoking this
> * function, the "wait-for-done" capability must be present.
> */
> -void negotiate_using_fetch(const struct oid_array *negotiation_tips,
> +void negotiate_using_fetch(const struct oid_array *negotiation_restrict_tips,
> const struct string_list *server_options,
> int stateless_rpc,
> int fd[],
LGTM up to here.
> diff --git a/transport-helper.c b/transport-helper.c
> index 4d95d84f9e..0e5b3b7202 100644
> --- a/transport-helper.c
> +++ b/transport-helper.c
> @@ -754,7 +754,7 @@ static int fetch_refs(struct transport *transport,
> set_helper_option(transport, "filter", spec);
> }
>
> - if (data->transport_options.negotiation_tips)
> + if (data->transport_options.negotiation_restrict_tips)
> warning("Ignoring --negotiation-tip because the protocol does not support it.");
>
> if (data->fetch)
Oh! Looks like a place was missed when renaming the preferred option
name in strings. It probably makes sense to do this rename in this patch
(rather than in patch 1) since we're already updating the struct field
name here anyway, but up to you.
Also do we also want to make it translatable like the others?
> diff --git a/transport.c b/transport.c
> index 107f4fa5dc..a3051f6733 100644
> --- a/transport.c
> +++ b/transport.c
> @@ -463,7 +463,7 @@ static int fetch_refs_via_pack(struct transport *transport,
> args.refetch = data->options.refetch;
> args.stateless_rpc = transport->stateless_rpc;
> args.server_options = transport->server_options;
> - args.negotiation_tips = data->options.negotiation_tips;
> + args.negotiation_restrict_tips = data->options.negotiation_restrict_tips;
> args.reject_shallow_remote = transport->smart_options->reject_shallow;
>
> if (!data->finished_handshake) {
> @@ -491,7 +491,7 @@ static int fetch_refs_via_pack(struct transport *transport,
> warning(_("server does not support wait-for-done"));
> ret = -1;
> } else {
> - negotiate_using_fetch(data->options.negotiation_tips,
> + negotiate_using_fetch(data->options.negotiation_restrict_tips,
> transport->server_options,
> transport->stateless_rpc,
> data->fd,
> @@ -979,9 +979,9 @@ static int disconnect_git(struct transport *transport)
> finish_connect(data->conn);
> }
>
> - if (data->options.negotiation_tips) {
> - oid_array_clear(data->options.negotiation_tips);
> - free(data->options.negotiation_tips);
> + if (data->options.negotiation_restrict_tips) {
> + oid_array_clear(data->options.negotiation_restrict_tips);
> + free(data->options.negotiation_restrict_tips);
> }
> list_objects_filter_release(&data->options.filter_options);
> oid_array_clear(&data->extra_have);
> diff --git a/transport.h b/transport.h
> index 892f19454a..cdeb33c16f 100644
> --- a/transport.h
> +++ b/transport.h
> @@ -40,13 +40,13 @@ struct git_transport_options {
>
> /*
> * This is only used during fetch. See the documentation of
> - * negotiation_tips in struct fetch_pack_args.
> + * negotiation_restrict_tips in struct fetch_pack_args.
> *
> * This field is only supported by transports that support connect or
> * stateless_connect. Set this field directly instead of using
> * transport_set_option().
> */
> - struct oid_array *negotiation_tips;
> + struct oid_array *negotiation_restrict_tips;
>
> /*
> * If allocated, whenever transport_fetch_refs() is called, add known
Just a missing string rename, and a nitpick typo in the commit message,
but otherwise this patch looks functionally correct.
Aside: I just noticed another '--negotiation-tip' instance in the
`get_commons_through_negotiation` function in send-pack.c. It still uses
the 'tip' option name when forming the shell cmdline.
Thanks,
Matthew
^ permalink raw reply
* [PATCH 2/4] cat-file: use strbuf_add_uint()
From: René Scharfe @ 2026-05-12 11:56 UTC (permalink / raw)
To: git
In-Reply-To: <20260512115603.80780-1-l.s.r@web.de>
Speed up printing of objectsize atoms by using the specialized function
strbuf_add_uint() instead of the general-purpose function strbuf_addf():
Benchmark 1: ./git_main cat-file --batch-all-objects --batch-check='%(objectsize)'
Time (mean ± σ): 751.7 ms ± 1.5 ms [User: 733.5 ms, System: 17.1 ms]
Range (min … max): 750.5 ms … 755.0 ms 10 runs
Benchmark 2: ./git cat-file --batch-all-objects --batch-check='%(objectsize)'
Time (mean ± σ): 720.4 ms ± 0.4 ms [User: 701.9 ms, System: 16.7 ms]
Range (min … max): 719.7 ms … 721.2 ms 10 runs
Summary
./git cat-file --batch-all-objects --batch-check='%(objectsize)' ran
1.04 ± 0.00 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectsize)'
Benchmark 1: ./git_main cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Time (mean ± σ): 404.6 ms ± 0.9 ms [User: 397.8 ms, System: 5.7 ms]
Range (min … max): 403.3 ms … 405.9 ms 10 runs
Benchmark 2: ./git cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Time (mean ± σ): 378.3 ms ± 0.9 ms [User: 371.2 ms, System: 5.9 ms]
Range (min … max): 376.8 ms … 380.2 ms 10 runs
Summary
./git cat-file --batch-all-objects --batch-check='%(objectsize:disk)' ran
1.07 ± 0.00 times faster than ./git_main cat-file --batch-all-objects --batch-check='%(objectsize:disk)'
Signed-off-by: René Scharfe <l.s.r@web.de>
---
builtin/cat-file.c | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/builtin/cat-file.c b/builtin/cat-file.c
index d9fbad5358..62160ca9d4 100644
--- a/builtin/cat-file.c
+++ b/builtin/cat-file.c
@@ -330,12 +330,12 @@ static int expand_atom(struct strbuf *sb, const char *atom, int len,
if (data->mark_query)
data->info.sizep = &data->size;
else
- strbuf_addf(sb, "%"PRIuMAX , (uintmax_t)data->size);
+ strbuf_add_uint(sb, data->size);
} else if (is_atom("objectsize:disk", atom, len)) {
if (data->mark_query)
data->info.disk_sizep = &data->disk_size;
else
- strbuf_addf(sb, "%"PRIuMAX, (uintmax_t)data->disk_size);
+ strbuf_add_uint(sb, data->disk_size);
} else if (is_atom("rest", atom, len)) {
if (data->mark_query)
data->split_on_whitespace = 1;
--
2.54.0
^ permalink raw reply related
* [PATCH 1/4] strbuf: add strbuf_add_uint()
From: René Scharfe @ 2026-05-12 11:56 UTC (permalink / raw)
To: git
In-Reply-To: <20260512115603.80780-1-l.s.r@web.de>
strbuf_addf() calls vsnprintf(3) underneath, which supports a plethora
of formatting options. We can avoid its overhead in basic cases by
providing specialized functions like strbuf_addstr() for strings. Add
another one, strbuf_add_uint(), for unsigned integers.
Prepare the number string in a temporary buffer. Make it big enough for
any unsigned integer value: A decimal digit can represent ln(10)/ln(2) ≈
3.32 bits; dividing the number of bits of uintmax_t by 3.3 and rounding
up gives a sufficiently close conservative size estimate.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
strbuf.c | 12 ++++++++++++
strbuf.h | 6 ++++++
2 files changed, 18 insertions(+)
diff --git a/strbuf.c b/strbuf.c
index 3e04addc22..9731ecdc1f 100644
--- a/strbuf.c
+++ b/strbuf.c
@@ -361,6 +361,18 @@ void strbuf_addf(struct strbuf *sb, const char *fmt, ...)
va_end(ap);
}
+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;
+
+ do
+ *--p = "0123456789"[value % 10];
+ while (value /= 10);
+ strbuf_add(sb, p, end - p);
+}
+
static void add_lines(struct strbuf *out,
const char *prefix,
const char *buf, size_t size,
diff --git a/strbuf.h b/strbuf.h
index 06e284f9cc..1089ae687b 100644
--- a/strbuf.h
+++ b/strbuf.h
@@ -410,6 +410,12 @@ void strbuf_humanise_rate(struct strbuf *buf, off_t bytes);
__attribute__((format (printf,2,3)))
void strbuf_addf(struct strbuf *sb, const char *fmt, ...);
+
+/**
+ * Add an unsigned decimal number.
+ */
+void strbuf_add_uint(struct strbuf *sb, uintmax_t value);
+
/**
* Add a formatted string prepended by a comment character and a
* blank to the buffer.
--
2.54.0
^ permalink raw reply related
* [PATCH 4/4] ls-tree: use strbuf_add_uint()
From: René Scharfe @ 2026-05-12 11:56 UTC (permalink / raw)
To: git
In-Reply-To: <20260512115603.80780-1-l.s.r@web.de>
Speed up printing of objectsize values by using the specialized function
strbuf_add_uint() as well as strbuf_insert() for padding instead of the
general-purpose function strbuf_addf(). Here are the numbers I get when
listing objects in the Linux kernel repo:
Benchmark 1: ./git_main -C ../linux ls-tree -r --format='%(objectsize)' HEAD
Time (mean ± σ): 294.4 ms ± 0.4 ms [User: 231.5 ms, System: 59.4 ms]
Range (min … max): 293.9 ms … 295.0 ms 10 runs
Benchmark 2: ./git -C ../linux ls-tree -r --format='%(objectsize)' HEAD
Time (mean ± σ): 291.2 ms ± 0.4 ms [User: 227.9 ms, System: 62.1 ms]
Range (min … max): 290.6 ms … 292.0 ms 10 runs
Benchmark 3: ./git_main -C ../linux ls-tree -r --format='%(objectsize:padded)' HEAD
Time (mean ± σ): 295.3 ms ± 0.6 ms [User: 232.0 ms, System: 59.6 ms]
Range (min … max): 294.3 ms … 296.3 ms 10 runs
Benchmark 4: ./git -C ../linux ls-tree -r --format='%(objectsize:padded)' HEAD
Time (mean ± σ): 291.9 ms ± 0.4 ms [User: 228.5 ms, System: 61.5 ms]
Range (min … max): 291.2 ms … 292.3 ms 10 runs
Summary
./git -C ../linux ls-tree -r --format='%(objectsize)' HEAD ran
1.00 ± 0.00 times faster than ./git -C ../linux ls-tree -r --format='%(objectsize:padded)' HEAD
1.01 ± 0.00 times faster than ./git_main -C ../linux ls-tree -r --format='%(objectsize)' HEAD
1.01 ± 0.00 times faster than ./git_main -C ../linux ls-tree -r --format='%(objectsize:padded)' HEAD
Signed-off-by: René Scharfe <l.s.r@web.de>
---
builtin/ls-tree.c | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/builtin/ls-tree.c b/builtin/ls-tree.c
index 113e4a960d..57846911ce 100644
--- a/builtin/ls-tree.c
+++ b/builtin/ls-tree.c
@@ -26,20 +26,23 @@ static const char * const ls_tree_usage[] = {
static void expand_objectsize(struct strbuf *line, const struct object_id *oid,
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;
+
if (type == OBJ_BLOB) {
unsigned long size;
if (odb_read_object_info(the_repository->objects, oid, &size) < 0)
die(_("could not get object info about '%s'"),
oid_to_hex(oid));
- if (padded)
- strbuf_addf(line, "%7"PRIuMAX, (uintmax_t)size);
- else
- strbuf_addf(line, "%"PRIuMAX, (uintmax_t)size);
- } else if (padded) {
- strbuf_addf(line, "%7s", "-");
+ strbuf_add_uint(line, size);
} else {
strbuf_addstr(line, "-");
}
+ len = line->len - orig_len;
+ if (len < min_len)
+ strbuf_insert(line, orig_len, padding, min_len - len);
}
struct ls_tree_options {
--
2.54.0
^ permalink raw reply related
* [PATCH 3/4] ls-files: use strbuf_add_uint()
From: René Scharfe @ 2026-05-12 11:56 UTC (permalink / raw)
To: git
In-Reply-To: <20260512115603.80780-1-l.s.r@web.de>
Speed up printing of objectsize values by using the specialized function
strbuf_add_uint() as well as strbuf_insert() for padding instead of the
general-purpose function strbuf_addf(). Here are the numbers I get when
listing files in the Linux kernel repo:
Benchmark 1: ./git_main -C ../linux ls-files --format='%(objectsize)'
Time (mean ± σ): 257.3 ms ± 0.4 ms [User: 197.4 ms, System: 56.7 ms]
Range (min … max): 256.7 ms … 258.1 ms 11 runs
Benchmark 2: ./git -C ../linux ls-files --format='%(objectsize)'
Time (mean ± σ): 253.4 ms ± 0.3 ms [User: 193.6 ms, System: 56.6 ms]
Range (min … max): 253.0 ms … 253.8 ms 11 runs
Benchmark 3: ./git_main -C ../linux ls-files --format='%(objectsize:padded)'
Time (mean ± σ): 257.9 ms ± 0.3 ms [User: 198.0 ms, System: 56.6 ms]
Range (min … max): 257.3 ms … 258.5 ms 11 runs
Benchmark 4: ./git -C ../linux ls-files --format='%(objectsize:padded)'
Time (mean ± σ): 254.6 ms ± 1.0 ms [User: 194.6 ms, System: 56.7 ms]
Range (min … max): 253.7 ms … 256.8 ms 11 runs
Summary
./git -C ../linux ls-files --format='%(objectsize)' ran
1.00 ± 0.00 times faster than ./git -C ../linux ls-files --format='%(objectsize:padded)'
1.02 ± 0.00 times faster than ./git_main -C ../linux ls-files --format='%(objectsize)'
1.02 ± 0.00 times faster than ./git_main -C ../linux ls-files --format='%(objectsize:padded)'
Signed-off-by: René Scharfe <l.s.r@web.de>
---
builtin/ls-files.c | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/builtin/ls-files.c b/builtin/ls-files.c
index b148607f7a..c142ad4156 100644
--- a/builtin/ls-files.c
+++ b/builtin/ls-files.c
@@ -250,20 +250,23 @@ static void expand_objectsize(struct repository *repo, struct strbuf *line,
const struct object_id *oid,
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;
+
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)
- strbuf_addf(line, "%7"PRIuMAX, (uintmax_t)size);
- else
- strbuf_addf(line, "%"PRIuMAX, (uintmax_t)size);
- } else if (padded) {
- strbuf_addf(line, "%7s", "-");
+ strbuf_add_uint(line, size);
} else {
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,
--
2.54.0
^ permalink raw reply related
* [PATCH 0/4] strbuf: add and use strbuf_add_uint()
From: René Scharfe @ 2026-05-12 11:55 UTC (permalink / raw)
To: git
Add a function that does the same as strbuf_addf(sb, "%"PRIuMAX, value),
only faster, and use it to speed up bulk reporting of size values a bit.
strbuf: add strbuf_add_uint()
cat-file: use strbuf_add_uint()
ls-files: use strbuf_add_uint()
ls-tree: use strbuf_add_uint()
builtin/cat-file.c | 4 ++--
builtin/ls-files.c | 15 +++++++++------
builtin/ls-tree.c | 15 +++++++++------
strbuf.c | 12 ++++++++++++
strbuf.h | 6 ++++++
5 files changed, 38 insertions(+), 14 deletions(-)
--
2.54.0
^ permalink raw reply
* Re: [PATCH v3 4/7] remote: add remote.*.negotiationRestrict config
From: Matthew John Cheetham @ 2026-05-12 12:29 UTC (permalink / raw)
To: Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps, Derrick Stolee
In-Reply-To: <d2f48b78b5b4c63269b1129865d94fdab9dffd92.1776871546.git.gitgitgadget@gmail.com>
On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
> From: Derrick Stolee<stolee@gmail.com>
>
> In a previous change, the --negotiation-restrict command-line option of
> 'git fetch' was added as a synonym of --negotiation-tips. Both of these
> options restrict the set of 'haves' the client can send as part of
> negotiation.
s/tips/tip/ as per the previous patch comments. Not important either
way.
> This was previously not available via a configuration option. Add a new
> 'remote.<name>.negotiationRestrict' multi-valued config option that
> updates 'git fetch <name>' to use these restrictions by default.
>
> If the user provides even one --negotiation-restrict argument, then the
> config is ignored.
>
> An empty value resets the value list to allow ignoring earlier config
> values, such as those that might be set in system or global config.
>
> Signed-off-by: Derrick Stolee<stolee@gmail.com>
> ---
> Documentation/config/remote.adoc | 19 +++++++++++++++++++
> builtin/fetch.c | 21 +++++++++++++++++----
> remote.c | 8 ++++++++
> remote.h | 1 +
> t/t5510-fetch.sh | 26 ++++++++++++++++++++++++++
> 5 files changed, 71 insertions(+), 4 deletions(-)
>
> diff --git a/Documentation/config/remote.adoc b/Documentation/config/remote.adoc
> index 91e46f66f5..f1d889d03e 100644
> --- a/Documentation/config/remote.adoc
> +++ b/Documentation/config/remote.adoc
> @@ -107,6 +107,25 @@ priority configuration file (e.g. `.git/config` in a repository) to clear
> the values inherited from a lower priority configuration files (e.g.
> `$HOME/.gitconfig`).
>
> +remote.<name>.negotiationRestrict::
> + When negotiating with this remote during `git fetch` and `git push`,
> + restrict the commits advertised as "have" lines to only those
> + reachable from refs matching the given patterns. This multi-valued
> + config option behaves like `--negotiation-restrict` on the command
> + line.
> ++
> +Each value is either an exact ref name (e.g. `refs/heads/release`) or a
> +glob pattern (e.g. `refs/heads/release/*`). The pattern syntax is the
> +same as for `--negotiation-restrict`.
> ++
> +These config values are used as defaults for the `--negotiation-restrict`
> +command-line option. If `--negotiation-restrict` (or its synonym
> +`--negotiation-tip`) is specified on the command line, then the config
> +values are not used.
> ++
> +Blank values signal to ignore all previous values, allowing a reset of
> +the list from broader config scenarios.
> +
> remote.<name>.followRemoteHEAD::
> How linkgit:git-fetch[1] should handle updates to `remotes/<name>/HEAD`
> when fetching using the configured refspecs of a remote.
You say "during `git fetch` and `git push`", but does `push` actually
honour the new config?
When the `push.negotiate` config is on then
`get_commons_through_negotiation()` from send-pack.c shells out to
`git fetch --negotiate-only` with one `--negotiation-tip=<oid>` arg per
ref being pushed, then the URL. This means the CLI restrict list is
always non-empty in the subprocess so in `prepare_transport()` (in the
below hunk) the `if (negotiation_restrict.nr)` arm is always taken and
the new `else if (remote->negotiation_restrict.nr)` arm is never taken.
BUT.. reading ahead I see that patch 7 actually wires up negotiation
config for push - so my commentary here will be moot! Do we want to drop
the "and `git push`" part from this until patch 7, when it is wired up
appropriately?
One other suggestion: perhaps we should clarify that `push.negotiate`
needs to be set for `remote.<name>.negotiationRestrict` to be honoured
during pushes?
> diff --git a/builtin/fetch.c b/builtin/fetch.c
> index 2ba0051d52..a1960e3e0c 100644
> --- a/builtin/fetch.c
> +++ b/builtin/fetch.c
> @@ -1601,6 +1601,19 @@ static struct transport *prepare_transport(struct remote *remote, int deepen,
> else
> warning(_("ignoring %s because the protocol does not support it"),
> "--negotiation-restrict");
> + } else if (remote->negotiation_restrict.nr) {
> + struct string_list_item *item;
> + for_each_string_list_item(item, &remote->negotiation_restrict)
> + string_list_append(&negotiation_restrict, item->string);
> + if (transport->smart_options)
> + add_negotiation_restrict_tips(transport->smart_options);
> + else {
> + struct strbuf config_name = STRBUF_INIT;
> + strbuf_addf(&config_name, "remote.%s.negotiationRestrict", remote->name);
> + warning(_("ignoring %s because the protocol does not support it"),
> + config_name.buf);
> + strbuf_release(&config_name);
> + }
> }
> return transport;
> }
See above - this new arm is not reachable on the push.negotiate=true
path until patch 7 wires send-pack up.
> @@ -2658,10 +2671,6 @@ int cmd_fetch(int argc,
> config.display_format = DISPLAY_FORMAT_PORCELAIN;
> }
>
> - if (negotiate_only && !negotiation_restrict.nr)
> - die(_("%s needs one or more %s"), "--negotiate-only",
> - "--negotiation-restrict=*");
> -
> if (deepen_relative) {
> if (deepen_relative < 0)
> die(_("negative depth in --deepen is not supported"));
> @@ -2749,6 +2758,10 @@ int cmd_fetch(int argc,
> if (!remote)
> die(_("must supply remote when using --negotiate-only"));
> gtransport = prepare_transport(remote, 1, &filter_options);
> + if (!gtransport->smart_options ||
> + !gtransport->smart_options->negotiation_restrict_tips)
> + die(_("%s needs one or more %s"), "--negotiate-only",
> + "--negotiation-restrict=*");
> if (gtransport->smart_options) {
> gtransport->smart_options->acked_commits = &acked_commits;
> } else {
This new condition fires whenever `gtransport->smart_options` is NULL,
i.e. the transport doesn't support smart options. Before this case was
handled three lines after this hunk by:
} else {
warning(_("protocol does not support --negotiate-only, exiting"));
result = 1;
trace2_region_leave("fetch", "negotiate-only", the_repository);
goto cleanup;
}
What happens now if a user runs --negotiate-only against a non-smart
transport is they see an odd message:
fatal: --negotiate-only needs one or more --negotiation-restrict=*
..but they may have specified --negotiation-restrict options.
Do we instead want &&?
if (gtransport->smart_options &&
!gtransport->smart_options->negotiation_restrict_tips)
die(_("%s needs one or more %s"), "--negotiate-only",
"--negotiation-restrict=*");
> diff --git a/remote.c b/remote.c
> index 7ca2a6501b..166a56408a 100644
> --- a/remote.c
> +++ b/remote.c
> @@ -152,6 +152,7 @@ static struct remote *make_remote(struct remote_state *remote_state,
> refspec_init_push(&ret->push);
> refspec_init_fetch(&ret->fetch);
> string_list_init_dup(&ret->server_options);
> + string_list_init_dup(&ret->negotiation_restrict);
>
> ALLOC_GROW(remote_state->remotes, remote_state->remotes_nr + 1,
> remote_state->remotes_alloc);
> @@ -179,6 +180,7 @@ static void remote_clear(struct remote *remote)
> FREE_AND_NULL(remote->http_proxy);
> FREE_AND_NULL(remote->http_proxy_authmethod);
> string_list_clear(&remote->server_options, 0);
> + string_list_clear(&remote->negotiation_restrict, 0);
> }
>
> static void add_merge(struct branch *branch, const char *name)
> @@ -562,6 +564,12 @@ static int handle_config(const char *key, const char *value,
> } else if (!strcmp(subkey, "serveroption")) {
> return parse_transport_option(key, value,
> &remote->server_options);
> + } else if (!strcmp(subkey, "negotiationrestrict")) {
> + /* reset list on empty value. */
> + if (!value || !*value)
> + string_list_clear(&remote->negotiation_restrict, 0);
> + else
> + string_list_append(&remote->negotiation_restrict, value);
> } else if (!strcmp(subkey, "followremotehead")) {
> const char *no_warn_branch;
> if (!strcmp(value, "never"))
Here we use the 'empty value means reset the list' pattern, but I notice
that the `parse_transport_option()` function already supports this reset
pattern (and used by serveroption above), with a small difference:
if (!value)
return config_error_nonbool(var);
if (!*value)
string_list_clear(transport_options, 0);
So NULL is an error, but empty string is 'reset'. Is it worth being
consistent with other options that use `parse_transport_options`?
> diff --git a/remote.h b/remote.h
> index fc052945ee..e6ec37c393 100644
> --- a/remote.h
> +++ b/remote.h
> @@ -117,6 +117,7 @@ struct remote {
> char *http_proxy_authmethod;
>
> struct string_list server_options;
> + struct string_list negotiation_restrict;
>
> enum follow_remote_head_settings follow_remote_head;
> const char *no_warn_branch;
> diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
> index dc3ce56d84..eff3ce8e2d 100755
> --- a/t/t5510-fetch.sh
> +++ b/t/t5510-fetch.sh
> @@ -1485,6 +1485,32 @@ test_expect_success '--negotiation-restrict and --negotiation-tip can be mixed'
> check_negotiation_tip
> '
>
> +test_expect_success 'remote.<name>.negotiationRestrict used as default' '
> + setup_negotiation_tip server server 0 &&
> +
> + # test the reset of the list on an empty value
> + git -C client config --add remote.origin.negotiationRestrict alpha_2 &&
> + git -C client config --add remote.origin.negotiationRestrict "" &&
> + git -C client config --add remote.origin.negotiationRestrict alpha_1 &&
> + git -C client config --add remote.origin.negotiationRestrict beta_1 &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + origin alpha_s beta_s &&
> + check_negotiation_tip
> +'
> +
> +test_expect_success 'CLI --negotiation-restrict overrides remote config' '
> + setup_negotiation_tip server server 0 &&
> + git -C client config --add remote.origin.negotiationRestrict alpha_1 &&
> + git -C client config --add remote.origin.negotiationRestrict beta_1 &&
> + ALPHA_1=$(git -C client rev-parse alpha_1) &&
> + GIT_TRACE_PACKET="$(pwd)/trace" git -C client fetch \
> + --negotiation-restrict=alpha_1 \
> + origin alpha_s beta_s &&
> + test_grep "fetch> have $ALPHA_1" trace &&
> + BETA_1=$(git -C client rev-parse beta_1) &&
> + test_grep ! "fetch> have $BETA_1" trace
> +'
> +
> test_expect_success SYMLINKS 'clone does not get confused by a D/F conflict' '
> git init df-conflict &&
> (
> -- gitgitgadget
>
General shape of this patch is good. The main thing that tripped me up
when reading this patch is the doc claim about push, which only becomes
true after patch 7 lands.
Thanks,
Matthew
^ permalink raw reply
* Re: [PATCH v7 3/5] branch: add --prune-merged <remote>
From: Junio C Hamano @ 2026-05-12 13:53 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget
Cc: git, Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <a2450098932a734f47ebe5f2c800bd63f760b64c.1778574229.git.gitgitgadget@gmail.com>
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> +`--prune-merged`::
> + Delete the local branches that `--forked` would list for
> + the same _<remote>_ arguments, but only when the branch's
> + push destination remote-tracking branch (the branch `git push`
> + would update; see `branch_get_push` semantics) no longer
> + resolves locally.
I thought the thing you were aiming for is this scenario:
git fetch origin
git checkout -t -b hn/topic origin/next
... work work work ...
git push origin
... the above pushes the current hn/topic to update their next
git checkout master ;# or anywhere other than hn/topic
git fetch origin
git branch --prune-merged origin
The last step notices that our local hn/topic has been merged at the
remote to the target branch 'next', and the second-to-last fetch
makes us notice that origin/next now has our hn/topic merged, so we
no longer have a reason to keep hn/topic around.
But the above description uses quite different condition. You want
to notice that _they_ removed 'next' (for that, the second-to-last
fetch may need to be run with --prune) and then remove our local
hn/topic, but to me, that sounds nonsense for two reasons.
(1) Their 'next' is something contributors may fork from to work
on. You exactly did that with hn/topic branch of your own.
Why would we even expect that to go away?
(2) When disappearance of their 'next' is fetched to our
remote-tracking namespace, we would not even know if hn/topic
that used to fork from has been already integrated and stashed
safely on some other branch on the remote. It sounds very
unsafe to remove it based on disappearance of origin/next
remote-tracking branch.
> In other words: the branch was pushed
> + under some name on _<remote>_, and that name has since
> + been pruned upstream.
> ++
> +As a safety check, branches with commits not yet integrated into
> +their upstream remote-tracking branch are refused; if the upstream
> +itself is gone, the remote's default branch is consulted instead.
Again, this is as nonsense as our example in an earlier iteration of
having a topic forked from my 'todo' branch while the HEAD is
pointing at the default branch that is 'master'. If the upstream
itself is gone, removing anything based on some other criteria
cannot by definition a "safety check". I'd suggest rethinking the
logic.
> @@ -171,7 +197,7 @@ static int branch_merged(int kind, const char *name,
> * any of the following code, but during the transition period,
> * a gentle reminder is in order.
> */
> - if (head_rev != reference_rev) {
> + if (!no_head_fallback && head_rev != reference_rev) {
I somehow thought that the necessary check at the lowest level can
reuse most of the "branch -d" protection logic, except that it needs
to pass NULL for head_rev from check_branch_commit() down to
branch_merged() when doing "branch --prune-merged". Do we really
need an extra no_head_fallback parameter?
^ permalink raw reply
* Re: What's cooking in git.git (May 2026, #03)
From: Pushkar Singh @ 2026-05-12 14:20 UTC (permalink / raw)
To: Junio C Hamano; +Cc: git
In-Reply-To: <xmqqik8tm16n.fsf@gitster.g>
Hi Junio,
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
Thanks,
Pushkar
^ permalink raw reply
* Re: [PATCH v3 2/7] fetch: add --negotiation-restrict option
From: Derrick Stolee @ 2026-05-12 14:23 UTC (permalink / raw)
To: Matthew John Cheetham, Derrick Stolee via GitGitGadget, git; +Cc: gitster, ps
In-Reply-To: <VI0PR03MB116340D42554FA1D08E51910BC0392@VI0PR03MB11634.eurprd03.prod.outlook.com>
On 5/12/26 7:11 AM, Matthew John Cheetham wrote:
> On 2026-04-22 16:25, Derrick Stolee via GitGitGadget wrote:
>> From: Derrick Stolee <stolee@gmail.com>
>> --- a/Documentation/fetch-options.adoc
>> +++ b/Documentation/fetch-options.adoc
>> @@ -49,6 +49,7 @@ the current repository has the same history as the source
>> repository.
>> `.git/shallow`. This option updates `.git/shallow` and accepts such
>> refs.
>> +`--negotiation-restrict=(<commit>|<glob>)`::
>> `--negotiation-tip=(<commit>|<glob>)`::
>> By default, Git will report, to the server, commits reachable
>> from all local refs to find common commits in an attempt to
>> @@ -58,6 +59,9 @@ the current repository has the same history as the source
>> repository.
>> local ref is likely to have commits in common with the
>> upstream ref being fetched.
>> +
>> +`--negotiation-restrict` is the preferred name for this option;
>> +`--negotiation-tip` is accepted as a synonym.
>> ++
>> This option may be specified more than once; if so, Git will report
>> commits reachable from any of the given commits.
>> +
>
> By my eyes it looks like two other references to the old name remain and
> could also be updated for consistency (since --negotiation-restrict is
> now the preferred name):
>
> 1. Documentation/fetch-options.adoc, under `--negotiate-only`:
> "ancestors of the provided `--negotiation-tip=` arguments"
>
> 2. Documentation/config/fetch.adoc:
> "See also the `--negotiate-only` and `--negotiation-tip` options"
>
> Of course the old name will still work, so this is more a nit-pick :-)
Thanks for catching these! I will make the correct updates in the
next version.
>> diff --git a/builtin/pull.c b/builtin/pull.c
>> index 7e67fdce97..821cc6699a 100644
>> --- a/builtin/pull.c
>> +++ b/builtin/pull.c
>> @@ -999,6 +999,9 @@ int cmd_pull(int argc,
>> OPT_PASSTHRU_ARGV(0, "negotiation-tip", &opt_fetch, N_("revision"),
>> N_("report that we have only objects reachable from this object"),
>> 0),
>> + OPT_PASSTHRU_ARGV(0, "negotiation-restrict", &opt_fetch, N_("revision"),
>> + N_("report that we have only objects reachable from this object"),
>> + 0),
>> OPT_BOOL(0, "show-forced-updates", &opt_show_forced_updates,
>> N_("check for forced-updates on all updated branches")),
>> OPT_PASSTHRU(0, "set-upstream", &set_upstream, NULL,
>
> It's a shame we don't have a nice way to combine the `OPT_ALIAS` and
> `OPT_PASSTHRU_ARGV` functionality, but it's only a small duplication
> cost of the repeated definition.
Actually, I just missed that I should use OPT_ALIAS in 'pull' as well as
how it's used in 'fetch'. Will fix.
Thanks,
-Stolee
^ permalink raw reply
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