* Re: [PATCH v3 3/5] name-rev: factor code for sharing with a new command
From: Kristoffer Haugsbakk @ 2026-05-05 19:21 UTC (permalink / raw)
To: Phillip Wood; +Cc: D. Ben Knoble, git, Phillip Wood
In-Reply-To: <65e013cd-5bca-4340-8018-bcbb44371e4f@gmail.com>
On Sat, May 2, 2026, at 12:00, Phillip Wood wrote:
>>>[snip]
>>
>> Yeah, I didn’t want to repeat that bookkeeping but in some iteration it
>> looked necessary. But it’s good that it isn’t.
>
> It looks like the printing code is shared between the two case blocks in
> patch 5 as well so we should move that outside them as well and just set
> "name" inside the switch statement.
They’re not shared. Both commands work the same: either the object name
is consumed and replaced or it is written out as-is, in other words when
commit lookup fails. But somehow the name-rev path prints that *failure
to look up* case before continuing here (I don’t know how):
if (!name)
continue;
Because the printf(3) only prints when a symbolic name was found. Either
name-only:
<symbolic name>
Or not name-only:
<object name> (<symbolic name>)
On the other hand format-rev uses those two print statements to output
either the name lookup case or the lookup failure case.
>>>[snip]
>>
>> They looked the same to me. So I will need to think about this some
>> more. Just a lack of C experience on my part.
>>
>> Replacing the `continue` with a goto at the start of the loop was also
>> unnecessary. Of course the `continue` breaks out of the loop and not the
>> switch-block (unlike `break`).
>>
>> But I didn’t break `t6120-describe.sh`. So I’ll also take a look to see
>> if there are any holes.
>
> I think the difference only matters in pathological cases as "goto
> start" means we end up looking at the same character twice but the loop
> carries on as normal after that. We should just keep using "continue",
> I'm not sure we need a new test case.
Yeah, I have gone back to the sensible `continue`. Thanks.
(To be honest I tried to provoke a parsing bug here but I was unable
to. Somewhat annoying.)
^ permalink raw reply
* [PATCH v4 0/6] fetch: add fetch.pruneBranches config
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>
I ran this on one of my repos and wiped out the local master branch! That
was a bad surprise, so I made an update now to treat the default branch in a
special way (never prune it).
* Resolve each remote's HEAD and collect the targets into a
protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a
protected default ref and the local branch name matches the default
branch's leaf name (so a local main tracking origin/main is spared, but a
renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default
ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name
(not by upstream alone); spare a branch whose push ref is the remote
default.
Harald Nordgren (6):
branch: add --forked <remote>
branch: let delete_branches warn instead of error on bulk refusal
branch: add --prune-merged <remote>
fetch: add --prune-merged
branch: add branch.<name>.pruneMerged opt-out
branch: add --all-remotes flag
Documentation/config/branch.adoc | 7 +
Documentation/fetch-options.adoc | 8 +
Documentation/git-branch.adoc | 32 ++++
builtin/branch.c | 289 +++++++++++++++++++++++++++++--
builtin/fetch.c | 20 +++
t/t3200-branch.sh | 247 ++++++++++++++++++++++++++
t/t5510-fetch.sh | 31 ++++
7 files changed, 623 insertions(+), 11 deletions(-)
base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v4
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v4
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v3:
1: 77e67d4b8b = 1: 77e67d4b8b branch: add --forked <remote>
2: 807c9f981f = 2: 807c9f981f branch: let delete_branches warn instead of error on bulk refusal
3: 49dc853403 ! 3: 77beb620d7 branch: add --prune-merged <remote>
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
}
-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;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
- 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);
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ if (!argc)
+ die(_("--forked requires at least one <remote>"));
+
-+ collect_forked_set(argc, argv, &out);
++ collect_forked_set(argc, argv, NULL, &out);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ 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;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ if (!argc)
+ die(_("--prune-merged requires at least one <remote>"));
+
-+ collect_forked_set(argc, argv, &candidates);
++ 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)) {
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ 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);
+ }
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
++ string_list_clear(&protected_default_refs, 0);
+ return ret;
+}
+
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
+ 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/main &&
++ 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
4: 938bf7c794 = 4: 98cfdb87d2 fetch: add --prune-merged
5: b2e7c97298 ! 5: c645526bb5 branch: add branch.<name>.pruneMerged opt-out
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ 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);
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
continue;
}
strbuf_release(&full);
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
+ 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;
++ }
+ }
- branch = branch_get(short_name);
push_ref = branch ? branch_get_push(branch, NULL) : NULL;
- if (!push_ref)
+ if (!push_ref) {
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ 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) &&
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
}
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged deletes when push ref differs from upstream'
- test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul
+ git -C pm-pushdefault rev-parse --verify refs/heads/topic
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
6: 6462642cd0 ! 6: 690242d89b branch: add --all-remotes flag
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
static void parse_forked_args(int argc, const char **argv,
struct string_list *remote_names,
struct string_list *tracking_refs)
-@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
- return 0;
+@@ builtin/branch.c: 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)
{
- struct string_list remote_names = STRING_LIST_INIT_NODUP;
@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
};
@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
+ if (!argc && !all_remotes)
+ die(_("--forked requires at least one <remote> or --all-remotes"));
-- collect_forked_set(argc, argv, &out);
-+ collect_forked_set(argc, argv, all_remotes, &out);
+- 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);
@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
+ int all_remotes, int force, int quiet)
{
struct string_list candidates = STRING_LIST_INIT_DUP;
- struct strvec deletable = STRVEC_INIT;
+ struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
int n_not_merged = 0;
int ret = 0;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ if (!argc && !all_remotes)
+ die(_("--prune-merged requires at least one <remote> or --all-remotes"));
-- collect_forked_set(argc, argv, &candidates);
-+ collect_forked_set(argc, argv, all_remotes, &candidates);
+- 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;
--
gitgitgadget
^ permalink raw reply
* [PATCH v4 1/6] branch: add --forked <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.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 v4 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.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 v4 3/6] branch: add --prune-merged <remote>
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.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 its upstream
remote-tracking 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 | 134 +++++++++++++++++++++++++++++++---
t/t3200-branch.sh | 113 ++++++++++++++++++++++++++++
3 files changed, 251 insertions(+), 12 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5773104cd3..80b20a55eb 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.
++
+By default, the local tip must also be reachable from the
+upstream remote-tracking branch (see `--no-merged`); branches with
+unpushed commits are refused. With `--force` (or `-f`), delete
+them 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..f2ca7b64d3 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"
@@ -753,36 +754,138 @@ 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, 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 +928,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 +984,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 +1030,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 +1040,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);
@@ -977,6 +1084,9 @@ int cmd_branch(int argc,
} 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..b41f8343b3 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1771,4 +1771,117 @@ 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 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 v4 4/6] fetch: add --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
After a successful fetch from a configured remote, run
'git branch --prune-merged <remote>' to delete local branches
whose push destination ref has just been pruned.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/fetch-options.adoc | 8 ++++++++
builtin/fetch.c | 20 ++++++++++++++++++++
t/t5510-fetch.sh | 31 +++++++++++++++++++++++++++++++
3 files changed, 59 insertions(+)
diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
index 81a9d7f9bb..afbd1f60b8 100644
--- a/Documentation/fetch-options.adoc
+++ b/Documentation/fetch-options.adoc
@@ -185,6 +185,14 @@ See the PRUNING section below for more details.
+
See the PRUNING section below for more details.
+`--prune-merged`::
+ After a successful fetch, run `git branch --prune-merged
+ <remote>` for the fetched remote, deleting local branches
+ that fork from this remote and whose tip is reachable from
+ their upstream remote-tracking branch. See linkgit:git-branch[1]
+ for the exact selection rules. The currently checked-out
+ branch is always preserved.
+
endif::git-pull[]
ifndef::git-pull[]
diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..5451bf3b5b 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -82,6 +82,8 @@ static int prune = -1; /* unspecified */
static int prune_tags = -1; /* unspecified */
#define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */
+static int prune_merged;
+
static int append, dry_run, force, keep, update_head_ok;
static int write_fetch_head = 1;
static int verbosity, deepen_relative, set_upstream, refetch;
@@ -2189,6 +2191,8 @@ static void add_options_to_argv(struct strvec *argv,
strvec_push(argv, prune ? "--prune" : "--no-prune");
if (prune_tags != -1)
strvec_push(argv, prune_tags ? "--prune-tags" : "--no-prune-tags");
+ if (prune_merged)
+ strvec_push(argv, "--prune-merged");
if (update_head_ok)
strvec_push(argv, "--update-head-ok");
if (force)
@@ -2382,6 +2386,15 @@ static inline void fetch_one_setup_partial(struct remote *remote,
return;
}
+static int prune_merged_for_remote(const struct remote *remote)
+{
+ struct child_process cmd = CHILD_PROCESS_INIT;
+
+ cmd.git_cmd = 1;
+ strvec_pushl(&cmd.args, "branch", "--prune-merged", remote->name, NULL);
+ return run_command(&cmd);
+}
+
static int fetch_one(struct remote *remote, int argc, const char **argv,
int prune_tags_ok, int use_stdin_refspecs,
const struct fetch_config *config,
@@ -2457,6 +2470,11 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,
refspec_clear(&rs);
transport_disconnect(gtransport);
gtransport = NULL;
+
+ if (!exit_code && prune_merged && remote_via_config &&
+ prune_merged_for_remote(remote))
+ exit_code = 1;
+
return exit_code;
}
@@ -2520,6 +2538,8 @@ int cmd_fetch(int argc,
N_("prune remote-tracking branches no longer on remote")),
OPT_BOOL('P', "prune-tags", &prune_tags,
N_("prune local tags no longer on remote and clobber changed tags")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream")),
OPT_CALLBACK_F(0, "recurse-submodules", &recurse_submodules_cli, N_("on-demand"),
N_("control recursive fetching of submodules"),
PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 6fe21e2b3a..b94fd2bda0 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -386,6 +386,37 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '
)
'
+test_expect_success 'fetch --prune-merged: setup' '
+ git init -b main fetch-pm-parent &&
+ test_commit -C fetch-pm-parent base
+'
+
+test_expect_success 'fetch --prune-merged deletes merged local branches' '
+ test_when_finished "rm -rf fetch-pm-clone" &&
+ git -C fetch-pm-parent branch one base &&
+ git clone fetch-pm-parent fetch-pm-clone &&
+ git -C fetch-pm-clone branch one --track origin/one &&
+ git -C fetch-pm-parent branch -D one &&
+
+ git -C fetch-pm-clone fetch --prune --prune-merged origin &&
+
+ test_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one
+'
+
+test_expect_success 'fetch --prune-merged skips unmerged local branches' '
+ test_when_finished "rm -rf fetch-pm-unmerged" &&
+ git -C fetch-pm-parent branch two base &&
+ git clone fetch-pm-parent fetch-pm-unmerged &&
+ git -C fetch-pm-unmerged checkout -b two --track origin/two &&
+ test_commit -C fetch-pm-unmerged unpushed &&
+ git -C fetch-pm-unmerged checkout - &&
+ git -C fetch-pm-parent branch -D two &&
+
+ git -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&
+ test_grep "not fully merged" err &&
+ git -C fetch-pm-unmerged rev-parse --verify refs/heads/two
+'
+
test_expect_success 'fetch --atomic works with a single branch' '
test_when_finished "rm -rf atomic" &&
--
gitgitgadget
^ permalink raw reply related
* [PATCH v4 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.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 | 17 +++++++-------
builtin/branch.c | 31 +++++++++++++++++++++----
t/t3200-branch.sh | 40 ++++++++++++++++++++++++++++++++
4 files changed, 82 insertions(+), 13 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..60dba38e27 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` (and `git fetch --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 80b20a55eb..9d4944d17e 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -216,16 +216,15 @@ 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.
+
-By default, the local tip must also be reachable from the
-upstream remote-tracking branch (see `--no-merged`); branches with
-unpushed commits are refused. With `--force` (or `-f`), delete
-them regardless. The currently checked-out branch in any worktree
-is always preserved.
+The local tip must also be reachable from the upstream
+remote-tracking branch; branches with unpushed commits are refused.
+With `--force` (or `-f`), 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`.
`-v`::
`-vv`::
diff --git a/builtin/branch.c b/builtin/branch.c
index f2ca7b64d3..07d867373f 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -834,13 +834,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);
@@ -850,18 +853,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 b41f8343b3..f9aca90f4d 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1884,4 +1884,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 v4 6/6] branch: add --all-remotes flag
From: Harald Nordgren via GitGitGadget @ 2026-05-05 19:23 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren,
Harald Nordgren
In-Reply-To: <pull.2285.v4.git.git.1778009038.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 9d4944d17e..5c5b91d9b6 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
-----------
@@ -226,6 +226,11 @@ With `--force` (or `-f`), 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`.
+`--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 07d867373f..37ea75ecc3 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -685,6 +685,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)
@@ -774,7 +781,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)
{
@@ -787,6 +794,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);
@@ -800,15 +809,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);
@@ -816,8 +825,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;
@@ -826,10 +835,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;
@@ -952,6 +962,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 */
@@ -1009,6 +1020,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")),
@@ -1052,6 +1066,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)
@@ -1105,10 +1122,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 f9aca90f4d..3809bfe0ad 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 &&
@@ -1924,4 +1945,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
* Re: [PATCH v2 03/11] odb, packfile: use size_t for streaming object sizes
From: Torsten Bögershausen @ 2026-05-05 19:27 UTC (permalink / raw)
To: Johannes Schindelin via GitGitGadget
Cc: git, Derrick Stolee, Jeff King, Johannes Schindelin
In-Reply-To: <3a539061c5f62c65d46bd0eb774bb1b1239463ff.1777914508.git.gitgitgadget@gmail.com>
On Mon, May 04, 2026 at 05:08:20PM +0000, Johannes Schindelin via GitGitGadget wrote:
> From: Johannes Schindelin <johannes.schindelin@gmx.de>
>
> The odb_read_stream structure uses unsigned long for the size field,
> which is 32-bit on Windows even in 64-bit builds. When streaming
> objects larger than 4GB, the size would be truncated to zero or an
> incorrect value, resulting in empty files being written to disk.
>
> Change the size field in odb_read_stream to size_t and introduce
> unpack_object_header_sz() to return sizes via size_t pointer. Since
> object_info.sizep remains unsigned long for API compatibility, use
> temporary variables where the types differ, with comments noting the
> truncation limitation for code paths that still use unsigned long.
>
> This was originally authored by LordKiRon <https://github.com/LordKiRon>,
> who preferred not to reveal their real name and therefore agreed that I
> take over authorship.
>
> Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
> ---
> builtin/pack-objects.c | 23 ++++++++++++++++-------
> object-file.c | 10 +++++++++-
> odb/streaming.c | 13 ++++++++++++-
> odb/streaming.h | 2 +-
> oss-fuzz/fuzz-pack-headers.c | 2 +-
> pack-bitmap.c | 2 +-
> pack-check.c | 6 ++++--
> packfile.c | 24 +++++++++++++++---------
> packfile.h | 4 ++--
> 9 files changed, 61 insertions(+), 25 deletions(-)
>
> diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c
> index dd2480a73d..aa4b1cb9b8 100644
> --- a/builtin/pack-objects.c
> +++ b/builtin/pack-objects.c
I haven't been able to follow all changes, so this may be false alarm.
Do we need a cast_size_t_to_ulong() somewhere ?
> @@ -629,14 +629,21 @@ static off_t write_reuse_object(struct hashfile *f, struct object_entry *entry,
> struct packed_git *p = IN_PACK(entry);
> struct pack_window *w_curs = NULL;
> uint32_t pos;
> - off_t offset;
> + off_t offset, cur;
> enum object_type type = oe_type(entry);
> + enum object_type in_pack_type;
> off_t datalen;
> unsigned char header[MAX_PACK_OBJECT_HEADER],
> dheader[MAX_PACK_OBJECT_HEADER];
> unsigned hdrlen;
> const unsigned hashsz = the_hash_algo->rawsz;
> - unsigned long entry_size = SIZE(entry);
> + size_t entry_size;
> +
> + cur = entry->in_pack_offset;
> + in_pack_type = unpack_object_header(p, &w_curs, &cur, &entry_size);
> + if (in_pack_type < 0)
> + die(_("write_reuse_object: unable to parse object header of %s"),
> + oid_to_hex(&entry->idx.oid));
>
> if (DELTA(entry))
> type = (allow_ofs_delta && DELTA(entry)->idx.offset) ?
> @@ -1087,7 +1094,7 @@ static void write_reused_pack_one(struct packed_git *reuse_packfile,
> {
> off_t offset, next, cur;
> enum object_type type;
> - unsigned long size;
> + size_t size;
>
> offset = pack_pos_to_offset(reuse_packfile, pos);
> next = pack_pos_to_offset(reuse_packfile, pos + 1);
> @@ -2243,7 +2250,7 @@ static void check_object(struct object_entry *entry, uint32_t object_index)
> off_t ofs;
> unsigned char *buf, c;
> enum object_type type;
> - unsigned long in_pack_size;
> + size_t in_pack_size;
>
> buf = use_pack(p, &w_curs, entry->in_pack_offset, &avail);
>
> @@ -2734,16 +2741,18 @@ unsigned long oe_get_size_slow(struct packing_data *pack,
> struct pack_window *w_curs;
> unsigned char *buf;
> enum object_type type;
> - unsigned long used, avail, size;
> + unsigned long used, avail;
> + size_t size;
>
> if (e->type_ != OBJ_OFS_DELTA && e->type_ != OBJ_REF_DELTA) {
> + unsigned long sz;
> packing_data_lock(&to_pack);
> if (odb_read_object_info(the_repository->objects,
> - &e->idx.oid, &size) < 0)
> + &e->idx.oid, &sz) < 0)
> die(_("unable to get size of %s"),
> oid_to_hex(&e->idx.oid));
> packing_data_unlock(&to_pack);
> - return size;
> + return sz;
> }
>
> p = oe_in_pack(pack, e);
> diff --git a/object-file.c b/object-file.c
> index 086b2b65ff..0be2981c7a 100644
> --- a/object-file.c
> +++ b/object-file.c
> @@ -2326,6 +2326,7 @@ int odb_source_loose_read_object_stream(struct odb_read_stream **out,
> struct object_info oi = OBJECT_INFO_INIT;
> struct odb_loose_read_stream *st;
> unsigned long mapsize;
> + unsigned long size_ul;
> void *mapped;
>
> mapped = odb_source_loose_map_object(source, oid, &mapsize);
> @@ -2349,11 +2350,18 @@ int odb_source_loose_read_object_stream(struct odb_read_stream **out,
> goto error;
> }
>
> - oi.sizep = &st->base.size;
> + /*
> + * object_info.sizep is unsigned long* (32-bit on Windows), but
> + * st->base.size is size_t (64-bit). Use temporary variable.
> + * Note: loose objects >4GB would still truncate here, but such
> + * large loose objects are uncommon (they'd normally be packed).
> + */
> + oi.sizep = &size_ul;
> oi.typep = &st->base.type;
>
> if (parse_loose_header(st->hdr, &oi) < 0 || st->base.type < 0)
> goto error;
> + st->base.size = size_ul;
>
> st->mapped = mapped;
> st->mapsize = mapsize;
> diff --git a/odb/streaming.c b/odb/streaming.c
> index 5927a12954..af2adf5ce7 100644
> --- a/odb/streaming.c
> +++ b/odb/streaming.c
> @@ -157,15 +157,26 @@ static int open_istream_incore(struct odb_read_stream **out,
> .base.read = read_istream_incore,
> };
> struct odb_incore_read_stream *st;
> + unsigned long size_ul;
> int ret;
>
> oi.typep = &stream.base.type;
> - oi.sizep = &stream.base.size;
> + /*
> + * object_info.sizep is unsigned long* (32-bit on Windows), but
> + * stream.base.size is size_t (64-bit). We use a temporary variable
> + * because the types are incompatible. Note: this path still truncates
> + * for >4GB objects, but large objects should use pack streaming
> + * (packfile_store_read_object_stream) which handles size_t properly.
> + * This incore fallback is only used for small objects or when pack
> + * streaming is unavailable.
> + */
> + oi.sizep = &size_ul;
> oi.contentp = (void **)&stream.buf;
> ret = odb_read_object_info_extended(odb, oid, &oi,
> OBJECT_INFO_DIE_IF_CORRUPT);
> if (ret)
> return ret;
> + stream.base.size = size_ul;
>
> CALLOC_ARRAY(st, 1);
> *st = stream;
> diff --git a/odb/streaming.h b/odb/streaming.h
> index c7861f7e13..517e2ea2d3 100644
> --- a/odb/streaming.h
> +++ b/odb/streaming.h
> @@ -21,7 +21,7 @@ struct odb_read_stream {
> odb_read_stream_close_fn close;
> odb_read_stream_read_fn read;
> enum object_type type;
> - unsigned long size; /* inflated size of full object */
> + size_t size; /* inflated size of full object */
> };
>
> /*
> diff --git a/oss-fuzz/fuzz-pack-headers.c b/oss-fuzz/fuzz-pack-headers.c
> index 150c0f5fa2..ef61ab577c 100644
> --- a/oss-fuzz/fuzz-pack-headers.c
> +++ b/oss-fuzz/fuzz-pack-headers.c
> @@ -6,7 +6,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
> int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
> {
> enum object_type type;
> - unsigned long len;
> + size_t len;
>
> unpack_object_header_buffer((const unsigned char *)data,
> (unsigned long)size, &type, &len);
> diff --git a/pack-bitmap.c b/pack-bitmap.c
> index f6ec18d83a..f9af8a96bd 100644
> --- a/pack-bitmap.c
> +++ b/pack-bitmap.c
> @@ -2270,7 +2270,7 @@ static int try_partial_reuse(struct bitmap_index *bitmap_git,
> {
> off_t delta_obj_offset;
> enum object_type type;
> - unsigned long size;
> + size_t size;
>
> if (pack_pos >= pack->p->num_objects)
> return -1; /* not actually in the pack */
> diff --git a/pack-check.c b/pack-check.c
> index 79992bb509..2792f34d25 100644
> --- a/pack-check.c
> +++ b/pack-check.c
> @@ -110,7 +110,7 @@ static int verify_packfile(struct repository *r,
> void *data;
> struct object_id oid;
> enum object_type type;
> - unsigned long size;
> + size_t size;
> off_t curpos;
> int data_valid;
>
> @@ -143,7 +143,9 @@ static int verify_packfile(struct repository *r,
> data = NULL;
> data_valid = 0;
> } else {
> - data = unpack_entry(r, p, entries[i].offset, &type, &size);
> + unsigned long sz;
> + data = unpack_entry(r, p, entries[i].offset, &type, &sz);
> + size = sz;
> data_valid = 1;
> }
>
> diff --git a/packfile.c b/packfile.c
> index b012d648ad..fdae91dd11 100644
> --- a/packfile.c
> +++ b/packfile.c
> @@ -1133,7 +1133,7 @@ out:
> }
>
> unsigned long unpack_object_header_buffer(const unsigned char *buf,
> - unsigned long len, enum object_type *type, unsigned long *sizep)
> + unsigned long len, enum object_type *type, size_t *sizep)
> {
> unsigned shift;
> size_t size, c;
> @@ -1144,7 +1144,11 @@ unsigned long unpack_object_header_buffer(const unsigned char *buf,
> size = c & 15;
> shift = 4;
> while (c & 0x80) {
> - if (len <= used || (bitsizeof(long) - 7) < shift) {
> + /*
> + * Each continuation byte adds 7 bits. Ensure shift won't
> + * overflow size_t (use size_t not long for 64-bit on Windows).
> + */
> + if (len <= used || (bitsizeof(size_t) - 7) < shift) {
> error("bad object header");
> size = used = 0;
> break;
> @@ -1153,7 +1157,7 @@ unsigned long unpack_object_header_buffer(const unsigned char *buf,
> size = st_add(size, st_left_shift(c & 0x7f, shift));
> shift += 7;
> }
> - *sizep = cast_size_t_to_ulong(size);
> + *sizep = size;
> return used;
> }
>
> @@ -1215,7 +1219,7 @@ unsigned long get_size_from_delta(struct packed_git *p,
> int unpack_object_header(struct packed_git *p,
> struct pack_window **w_curs,
> off_t *curpos,
> - unsigned long *sizep)
> + size_t *sizep)
> {
> unsigned char *base;
> unsigned long left;
> @@ -1367,7 +1371,7 @@ static enum object_type packed_to_object_type(struct repository *r,
>
> while (type == OBJ_OFS_DELTA || type == OBJ_REF_DELTA) {
> off_t base_offset;
> - unsigned long size;
> + size_t size;
> /* Push the object we're going to leave behind */
> if (poi_stack_nr >= poi_stack_alloc && poi_stack == small_poi_stack) {
> poi_stack_alloc = alloc_nr(poi_stack_nr);
> @@ -1586,7 +1590,7 @@ static int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_off
> uint32_t *maybe_index_pos, struct object_info *oi)
> {
> struct pack_window *w_curs = NULL;
> - unsigned long size;
> + size_t size;
> off_t curpos = obj_offset;
> enum object_type type = OBJ_NONE;
> uint32_t pack_pos;
> @@ -1778,7 +1782,7 @@ void *unpack_entry(struct repository *r, struct packed_git *p, off_t obj_offset,
> struct pack_window *w_curs = NULL;
> off_t curpos = obj_offset;
> void *data = NULL;
> - unsigned long size;
> + size_t size;
> enum object_type type;
> struct unpack_entry_stack_ent small_delta_stack[UNPACK_ENTRY_STACK_PREALLOC];
> struct unpack_entry_stack_ent *delta_stack = small_delta_stack;
> @@ -1943,8 +1947,10 @@ void *unpack_entry(struct repository *r, struct packed_git *p, off_t obj_offset,
> (uintmax_t)curpos, p->pack_name);
> data = NULL;
> } else {
> + unsigned long sz;
> data = patch_delta(base, base_size, delta_data,
> - delta_size, &size);
> + delta_size, &sz);
> + size = sz;
>
> /*
> * We could not apply the delta; warn the user, but
> @@ -2929,7 +2935,7 @@ int packfile_read_object_stream(struct odb_read_stream **out,
> struct odb_packed_read_stream *stream;
> struct pack_window *window = NULL;
> enum object_type in_pack_type;
> - unsigned long size;
> + size_t size;
>
> in_pack_type = unpack_object_header(pack, &window, &offset, &size);
> unuse_pack(&window);
> diff --git a/packfile.h b/packfile.h
> index 9b647da7dd..49d6bdecf6 100644
> --- a/packfile.h
> +++ b/packfile.h
> @@ -456,9 +456,9 @@ off_t find_pack_entry_one(const struct object_id *oid, struct packed_git *);
>
> int is_pack_valid(struct packed_git *);
> void *unpack_entry(struct repository *r, struct packed_git *, off_t, enum object_type *, unsigned long *);
> -unsigned long unpack_object_header_buffer(const unsigned char *buf, unsigned long len, enum object_type *type, unsigned long *sizep);
> +unsigned long unpack_object_header_buffer(const unsigned char *buf, unsigned long len, enum object_type *type, size_t *sizep);
> unsigned long get_size_from_delta(struct packed_git *, struct pack_window **, off_t);
> -int unpack_object_header(struct packed_git *, struct pack_window **, off_t *, unsigned long *);
> +int unpack_object_header(struct packed_git *, struct pack_window **, off_t *, size_t *);
> off_t get_delta_base(struct packed_git *p, struct pack_window **w_curs,
> off_t *curpos, enum object_type type,
> off_t delta_obj_offset);
> --
> gitgitgadget
>
>
^ permalink raw reply
* Re: [PATCH v3 5/5] format-rev: introduce builtin for on-demand pretty formatting
From: Kristoffer Haugsbakk @ 2026-05-05 19:27 UTC (permalink / raw)
To: Phillip Wood; +Cc: D. Ben Knoble, git
In-Reply-To: <0f57e309-62a0-438e-a1d8-7c367379ef01@gmail.com>
On Sat, May 2, 2026, at 12:00, Phillip Wood wrote:
>>>[snip]
>>> We'll also need to be
>>> careful about flushing the output at the end of a processed message.
>>
>> I don’t get why this takes special care. I’ll think about it.
>
> Because the output from printf() is buffered, unless you explicitly
> flush it you can get into a state where git thinks it has printed the
> output and is waiting for the caller to write more input, but the caller
> is still waiting to read git's output and so they are deadlocked.
> Calling maybe_flush_or_die() is the usual way to handle this I think -
> see 344a107b55 (merge-tree --stdin: flush stdout to avoid deadlock,
> 2025-02-18)
Ah, I understand now. Very well explained. Thanks :)
>>> For "--stdin-mode=revs" the caller cannot know how many lines the output
>>> will span because formats like %(trailers) will produce a variable
>>> number of lines depending on which trailers are present. It is also
>>> possible for a rev name to span more than one line. The following
>>> example finds the most recent commit that mentions 'cherry-pick' in the
>>> subject line
>>>
>>> :/^[^
>>> ]cherry-pick
>>>
>>> so we need a way to delimit the input and output records there as well.
>>
>> Okay, so a null-terminator mode for input as well?
>
> Yes I think "-z" should mean NUL terminated input and output.
I will use `-z` (and `--null`) to mean NUL terminated input and output
based on your recommendation and because I see that it is the approach
used in other commands that I have found that have NUL termination for
both stdin and stdout.
I also want to supply `--null-input` and `--null-output` since I think
`--null-output` will be more generally useful.
***
That these long options ended up being called `--null` instead of
`--nul` by convention is maybe just a historical accident? Considering
one writes NUL byte/character.
>[snip]
^ permalink raw reply
* Re: [Bug] fetch --deepen truncates history in v2.54.0
From: Samo Pogačnik @ 2026-05-05 19:27 UTC (permalink / raw)
To: René Scharfe, Owen Stephens, git
In-Reply-To: <e39f6770-fcc4-49a2-b3ba-5ac2ec9e047b@web.de>
[-- Attachment #1: Type: text/plain, Size: 3906 bytes --]
On Sat, 2026-05-02 at 22:26 +0200, René Scharfe wrote:
> On 5/2/26 11:22 AM, René Scharfe wrote:
> > On 4/29/26 1:27 PM, Owen Stephens wrote:
> > > > What did you do before the bug happened? (Steps to reproduce your issue)
> > >
> > > Repeatedy called `git fetch --deepen 2` inside a shallow repo that was a
> > > file:// clone of another repo. Once all commits had been fetched, a
> > > subsequent
> > > `fetch --deepen` appears to "reset" the repo back to being shallow with a
> > > depth
> > > of 2. A reproduction script is included below. This issue appears to have
> > > been
> > > introduced in v2.54.0.
> > >
> > > > What did you expect to happen? (Expected behavior)
> > >
> > > I expected `git fetch --deepen` in a non-shallow repo with no upstream
> > > commits
> > > to be a no-op.
> > >
> > > > What happened instead? (Actual behavior)
> > >
> > > `git log` history is truncated to two commits, and repo is considered
> > > shallow
> > > by `git rev-parse --is-shallow-repository`.
> > >
> > > > What's different between what you expected and what actually happened?
> > >
> > > The previously-present commits in `git log` are missing, and the repo is
> > > again
> > > considered shallow.
> > >
> > > > Anything else you want to add:
> > >
> > > Commit 3ef68ff seems relevant.
> >
> > Indeed, bisect identifies 3ef68ff40e (shallow: handling fetch relative-
> > deepen,
> > 2026-02-15) and reverting it fixes the issue. Copying its author.
>
> Here's a simple fix, but it feels like cheating. A proper one should
> live in shallow.c, no?
>
>
> diff --git a/builtin/fetch.c b/builtin/fetch.c
> index a22c319467..310099b96d 100644
> --- a/builtin/fetch.c
> +++ b/builtin/fetch.c
> @@ -2664,7 +2664,8 @@ int cmd_fetch(int argc,
> die(_("negative depth in --deepen is not
> supported"));
> if (depth)
> die(_("options '%s' and '%s' cannot be used
> together"), "--deepen", "--depth");
> - depth = xstrfmt("%d", deepen_relative);
> + if (is_repository_shallow(the_repository))
> + depth = xstrfmt("%d", deepen_relative);
> }
> if (unshallow) {
> if (depth)
>
Hi, thanks for pointing out this edge case. Would you care to check the
following change (the provided test is also a bit modified):
diff --git a/shallow.c b/shallow.c
index a156006d88..ec95653132 100644
--- a/shallow.c
+++ b/shallow.c
@@ -245,7 +245,11 @@ struct commit_list *get_shallow_commits(struct object_array
*heads,
int depth, int shallow_flag, int
not_shallow_flag)
{
if (shallows && deepen_relative) {
- depth += get_shallows_depth(heads, shallows);
+ int cur_shallow_depth = get_shallows_depth(heads, shallows);
+ if (cur_shallow_depth)
+ depth += cur_shallow_depth;
+ else
+ return NULL;
}
return get_shallows_or_depth(heads, NULL, NULL,
depth, shallow_flag, not_shallow_flag);
diff --git a/t/t5537-fetch-shallow.sh b/t/t5537-fetch-shallow.sh
index 6588ce6226..9982dd2aa6 100755
--- a/t/t5537-fetch-shallow.sh
+++ b/t/t5537-fetch-shallow.sh
@@ -251,6 +251,16 @@ test_expect_success '.git/shallow is edited by repack' '
origin "+refs/heads/*:refs/remotes/origin/*"
'
+test_expect_success 'fetch --deepen does not truncate' '
+ git clone --no-local .git full-clone &&
+ git -C full-clone rev-parse --is-shallow-repository >expect &&
+ git -C full-clone log --oneline >>expect &&
+ git -C full-clone fetch --deepen=1 &&
+ git -C full-clone rev-parse --is-shallow-repository >actual &&
+ git -C full-clone log --oneline >>actual &&
+ test_cmp expect actual
+'
+
. "$TEST_DIRECTORY"/lib-httpd.sh
start_httpd
[-- Attachment #2: This is a digitally signed message part --]
[-- Type: application/pgp-signature, Size: 833 bytes --]
^ permalink raw reply related
* Re: [PATCH v2 00/10] pack-objects: integrate --path-walk and some --filter options
From: Derrick Stolee @ 2026-05-05 19:44 UTC (permalink / raw)
To: Taylor Blau
Cc: Derrick Stolee via GitGitGadget, git, christian.couder, gitster,
johannes.schindelin, johncai86, karthik.188, kristofferhaugsbakk,
newren, peff, ps
In-Reply-To: <afo+mEITFBSLevqV@nand.local>
On 5/5/2026 3:01 PM, Taylor Blau wrote:
> On Tue, May 05, 2026 at 12:18:28PM -0400, Derrick Stolee wrote:
>> One thing I discovered when testing Taylor's series is that this series
>> introduces new test failures when run with GIT_TEST_PACK_PATH_WALK=1.
>> It's probably due to new cases that are fragile to the difference
>> between delta compression algorithms, but are now exposed after the
>> filters are no longer disabling --path-walk even with that test var.
>>
>> I'll make sure these are fixed in the next version.
>
> Thanks for looking into it.
>
> It looks like this bisects (at least in t5310) to "path-walk: support
> blobless filter", which is 03/10 in this series. I suspect that there
> are other failures that are indeed due to delta selection sensitivity as
> you note, but in this case it looks like we are actually not sending the
> right set of objects:
>
> + git clone --no-local --bare --filter=blob:none . partial-clone.git
> Cloning into bare repository 'partial-clone.git'...
> [...]
> fatal: bad object 782f60206c837dcd3d441e106549ad6f58de55b5
> fatal: remote did not send all necessary objects
> error: last command exited with $?=128
> not ok 26 - partial clone from bitmapped repository
>
> I think this is a consequence of us not sending directly-referenced
> blobs with `--filter=blob:none` when running the filters through
> `--path-walk`. Something like:
>
> --- 8< ---
> diff --git a/path-walk.c b/path-walk.c
> index a4dd197c37e..dbad01287e2 100644
> --- a/path-walk.c
> +++ b/path-walk.c
> @@ -159,8 +159,8 @@ static int add_tree_entries(struct path_walk_context *ctx,
> if (S_ISGITLINK(entry.mode))
> continue;
>
> - /* If the caller doesn't want blobs, then don't bother. */
> - if (!ctx->info->blobs && type == OBJ_BLOB)
> + if ((!ctx->info->blobs || ctx->info->prune_tree_blobs) &&
> + type == OBJ_BLOB)
> continue;
>
> if (type == OBJ_TREE) {
> @@ -495,7 +495,7 @@ static int prepare_filters(struct path_walk_info *info,
>
> case LOFC_BLOB_NONE:
> if (info) {
> - info->blobs = 0;
> + info->prune_tree_blobs = 1;
> list_objects_filter_release(options);
> }
> return 1;
> --- >8 ---
Thanks for this suggestion. I got pulled away from my investigation, so
wasn't to this point yet.
> fixes t5310 for me. I haven't looked into any of the other failures yet
> since you mentioned that you're looking into them, but let me know if
> you want to tag-team any of these.
>
> (As a related side-note, I noticed that GIT_TEST_PACK_PATH_WALK=1 is not
> currently in the TEST-vars CI build. I'm not sure if there are
> historical reasons for leaving it out, but if not I think it would be
> worthwhile to add it.)
I think the initial idea was that the feature was too niche to add it to
the CI builds right away. Your series is going to make it a lot more
important, so adding this to CI builds may be valuable.
Thanks,
-Stolee
^ permalink raw reply
* Re: Git trims the last character of content from remotes
From: René Scharfe @ 2026-05-05 19:41 UTC (permalink / raw)
To: Chris Torek, Hugo Osvaldo Barrera; +Cc: git
In-Reply-To: <CAPx1Gvf5Vts3oS2BdFQ4PpCR-UY=5cYW7fgOkRuQpi8ug2JXDg@mail.gmail.com>
On 5/5/26 2:34 AM, Chris Torek wrote:
> On Mon, May 4, 2026 at 10:02 AM Hugo Osvaldo Barrera <hugo@whynothugo.nl> wrote:
> [snippage]
>> When the width of a whole line is the same as my terminal width ...
> [snippage]
>> ... sideband.c prints ANSI_SUFFIX = "\033[K", this escape
>> sequence being "clear the line from the current position until the end of the
>> line", and this is the root cause of the issue.
> If you have a non-empty prefix
> string before this "clear to end of line" suffix, the solution is more
> obvious: print the ESC [ K as a *prefix* rather than a suffix, but
> that fails with the empty prefix.
We do have a non-empty prefix, but why would it be necessary? What's
wrong with clearing the full line starting from column 1?
Anyway, do you mean something like this?
diff --git a/sideband.c b/sideband.c
index ea7c25211e..5bfdd1d372 100644
--- a/sideband.c
+++ b/sideband.c
@@ -120,7 +120,7 @@ static void maybe_colorize_sideband(struct strbuf *dest, const char *src, int n)
#define DISPLAY_PREFIX "remote: "
-#define ANSI_SUFFIX "\033[K"
+#define ANSI_PREFIX "\033[K"
#define DUMB_SUFFIX " "
int demultiplex_sideband(const char *me, int status,
@@ -129,15 +129,19 @@ int demultiplex_sideband(const char *me, int status,
struct strbuf *scratch,
enum sideband_type *sideband_type)
{
+ static const char *prefix;
static const char *suffix;
const char *b, *brk;
int band;
if (!suffix) {
- if (isatty(2) && !is_terminal_dumb())
- suffix = ANSI_SUFFIX;
- else
+ if (isatty(2) && !is_terminal_dumb()) {
+ prefix = DISPLAY_PREFIX ANSI_PREFIX;
+ suffix = "";
+ } else {
+ prefix = DISPLAY_PREFIX;
suffix = DUMB_SUFFIX;
+ }
}
if (status == PACKET_READ_EOF) {
@@ -172,7 +176,7 @@ int demultiplex_sideband(const char *me, int status,
if (die_on_error)
die(_("remote error: %s"), buf + 1);
strbuf_addf(scratch, "%s%s", scratch->len ? "\n" : "",
- DISPLAY_PREFIX);
+ prefix);
maybe_colorize_sideband(scratch, buf + 1, len);
*sideband_type = SIDEBAND_REMOTE_ERROR;
@@ -203,7 +207,7 @@ int demultiplex_sideband(const char *me, int status,
strbuf_addstr(scratch, suffix);
if (!scratch->len)
- strbuf_addstr(scratch, DISPLAY_PREFIX);
+ strbuf_addstr(scratch, prefix);
/*
* A use case that we should not add clear-to-eol suffix
@@ -230,7 +234,7 @@ int demultiplex_sideband(const char *me, int status,
if (*b) {
strbuf_addstr(scratch, scratch->len ?
- "" : DISPLAY_PREFIX);
+ "" : prefix);
maybe_colorize_sideband(scratch, b, strlen(b));
}
return 0;
^ permalink raw reply related
* [PATCH] doc: restore: remove double underscore
From: kristofferhaugsbakk @ 2026-05-05 19:46 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk
From: Kristoffer Haugsbakk <code@khaugsbakk.name>
69666e67 (doc: convert git-restore to new style format, 2025-01-10)
converted `A` to _<rev-A>__; the extra underscore was a mistake.
Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
Notes (series):
Topic name: kh/doc-restore-double-underscore
Documentation/git-restore.adoc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Documentation/git-restore.adoc b/Documentation/git-restore.adoc
index 961eef01373..7d194231651 100644
--- a/Documentation/git-restore.adoc
+++ b/Documentation/git-restore.adoc
@@ -41,7 +41,7 @@ given, otherwise from the index.
+
As a special case, you may use `"<rev-A>...<rev-B>"` as a shortcut for the
merge base of _<rev-A>_ and _<rev-B>_ if there is exactly one merge base. You can
-leave out at most one of _<rev-A>__ and _<rev-B>_, in which case it defaults to `HEAD`.
+leave out at most one of _<rev-A>_ and _<rev-B>_, in which case it defaults to `HEAD`.
`-p`::
`--patch`::
base-commit: 67ad42147a7acc2af6074753ebd03d904476118f
--
2.54.0.13.g9c7419e39f8
^ permalink raw reply related
* Re: [Bug] fetch --deepen truncates history in v2.54.0
From: René Scharfe @ 2026-05-05 20:34 UTC (permalink / raw)
To: Samo Pogačnik, Owen Stephens, git
In-Reply-To: <2afd4a28a9a542f8baeab488cb0801d6b98adb0a.camel@t-2.net>
On 5/5/26 9:27 PM, Samo Pogačnik wrote:
>
> Hi, thanks for pointing out this edge case. Would you care to check the
> following change (the provided test is also a bit modified):
There's spurious wrapping in the patch, but the changes look good to me.
Care to send them with a commit message and sign-off?
René
> diff --git a/shallow.c b/shallow.c
> index a156006d88..ec95653132 100644
> --- a/shallow.c
> +++ b/shallow.c
> @@ -245,7 +245,11 @@ struct commit_list *get_shallow_commits(struct object_array
> *heads,
> int depth, int shallow_flag, int
> not_shallow_flag)
> {
> if (shallows && deepen_relative) {
> - depth += get_shallows_depth(heads, shallows);
> + int cur_shallow_depth = get_shallows_depth(heads, shallows);
> + if (cur_shallow_depth)
> + depth += cur_shallow_depth;
> + else
> + return NULL;
Nice. get_shallows_depth() returns 0 on full clones; translating it to
an empty list of shallow commits makes sense.
> }
> return get_shallows_or_depth(heads, NULL, NULL,
> depth, shallow_flag, not_shallow_flag);
> diff --git a/t/t5537-fetch-shallow.sh b/t/t5537-fetch-shallow.sh
> index 6588ce6226..9982dd2aa6 100755
> --- a/t/t5537-fetch-shallow.sh
> +++ b/t/t5537-fetch-shallow.sh
> @@ -251,6 +251,16 @@ test_expect_success '.git/shallow is edited by repack' '
> origin "+refs/heads/*:refs/remotes/origin/*"
> '
>
> +test_expect_success 'fetch --deepen does not truncate' '
> + git clone --no-local .git full-clone &&
> + git -C full-clone rev-parse --is-shallow-repository >expect &&
> + git -C full-clone log --oneline >>expect &&
> + git -C full-clone fetch --deepen=1 &&
> + git -C full-clone rev-parse --is-shallow-repository >actual &&
> + git -C full-clone log --oneline >>actual &&
Using the exact same commands to prepare expect and actual creates a
pleasant symmetry.
> + test_cmp expect actual
> +'
> +
> . "$TEST_DIRECTORY"/lib-httpd.sh
> start_httpd
>
^ permalink raw reply
* Re: [PATCH v2 00/10] pack-objects: integrate --path-walk and some --filter options
From: Taylor Blau @ 2026-05-05 20:42 UTC (permalink / raw)
To: Derrick Stolee
Cc: Derrick Stolee via GitGitGadget, git, christian.couder, gitster,
johannes.schindelin, johncai86, karthik.188, kristofferhaugsbakk,
newren, peff, ps
In-Reply-To: <07b36bd8-376b-4a98-a735-0c0f75452c24@gmail.com>
On Tue, May 05, 2026 at 03:44:56PM -0400, Derrick Stolee wrote:
> Thanks for this suggestion. I got pulled away from my investigation, so
> wasn't to this point yet.
No problem. One of those things that in the course of responding to your
email, I had written enough to fix at least one of the tests. I hope I
didn't step on any toes as a consequence.
> I think the initial idea was that the feature was too niche to add it to
> the CI builds right away. Your series is going to make it a lot more
> important, so adding this to CI builds may be valuable.
Sounds good to me. I imagine that this makes more sense to place as a
preparatory patch in your series, but LMK if you would rather I place it
in mine.
Thanks,
Taylor
^ permalink raw reply
* [PATCH] doc: add caveat about turning off commit-graph
From: kristofferhaugsbakk @ 2026-05-05 20:45 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Derrick Stolee
From: Kristoffer Haugsbakk <code@khaugsbakk.name>
The doc `technical/commit-graph.adoc` says that replace objects and
commit grafts turn off commit-graph:
Commit grafts and replace objects can change the shape of the commit
history. The latter can also be enabled/disabled on the fly using
`--no-replace-objects`. This leads to difficulty storing both possible
interpretations of a commit id, especially when computing generation
numbers. The commit-graph will not be read or written when
replace-objects or grafts are present.
But this isn’t mentioned in the user-facing doc. Let’s mention it on
git-replace(1) and git-commit-graph(1).
Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---
Documentation/git-commit-graph.adoc | 6 ++++++
Documentation/git-replace.adoc | 6 ++++++
2 files changed, 12 insertions(+)
diff --git a/Documentation/git-commit-graph.adoc b/Documentation/git-commit-graph.adoc
index 6d19026035f..f2a37e91634 100644
--- a/Documentation/git-commit-graph.adoc
+++ b/Documentation/git-commit-graph.adoc
@@ -146,6 +146,12 @@ $ git show-ref -s | git commit-graph write --stdin-commits
$ git rev-parse HEAD | git commit-graph write --stdin-commits --append
------------------------------------------------
+CAVEATS
+-------
+
+The existence of replace objects or commit grafts turns off reading or
+writing to the commit-graph. See linkgit:git-replace[1].
+
CONFIGURATION
-------------
diff --git a/Documentation/git-replace.adoc b/Documentation/git-replace.adoc
index 0a65460adbd..2c0ea07724d 100644
--- a/Documentation/git-replace.adoc
+++ b/Documentation/git-replace.adoc
@@ -145,6 +145,12 @@ commit instead of the replaced commit.
There may be other problems when using 'git rev-list' related to
pending objects.
+CAVEATS
+-------
+
+The existence of replace objects or commit grafts turns off reading or
+writing to the commit-graph. See linkgit:git-commit-graph[1].
+
SEE ALSO
--------
linkgit:git-hash-object[1]
base-commit: 67ad42147a7acc2af6074753ebd03d904476118f
--
2.54.0.13.g9c7419e39f8
^ permalink raw reply related
* Re: [PATCH v4 4/6] fetch: add --prune-merged
From: Johannes Sixt @ 2026-05-05 20:48 UTC (permalink / raw)
To: Harald Nordgren
Cc: Kristoffer Haugsbakk, git, Harald Nordgren via GitGitGadget
In-Reply-To: <98cfdb87d26cec5f91ea4d8ce949512d60958e56.1778009038.git.gitgitgadget@gmail.com>
Am 05.05.26 um 21:23 schrieb Harald Nordgren via GitGitGadget:
> After a successful fetch from a configured remote, run
> 'git branch --prune-merged <remote>' to delete local branches
> whose push destination ref has just been pruned.
I have some sympathy for the desire to clean up unnecessary local
branches, but I don't like the concept that `git fetch` modifies local
branches, not even as an opt-in. Deleting local branches should be `git
branch`'s task exclusively (at the porcelain level).
-- Hannes
^ permalink raw reply
* Re: [PATCH v2 08/11] test-tool synthesize: precompute pack for 4 GiB + 1
From: Johannes Schindelin @ 2026-05-05 20:54 UTC (permalink / raw)
To: Derrick Stolee
Cc: Johannes Schindelin via GitGitGadget, git,
Torsten Bögershausen, Jeff King
In-Reply-To: <a382fcdf-a9c9-4caa-8be4-163c7bcbd64b@gmail.com>
Hi Stolee,
On Tue, 5 May 2026, Derrick Stolee wrote:
> On 5/4/2026 1:08 PM, Johannes Schindelin via GitGitGadget wrote:
> > From: Johannes Schindelin <johannes.schindelin@gmx.de>
>
> > Benchmarks generating a 4 GiB + 1 pack (3 runs each, SHA1DC on
> > x86_64):
> >
> > generic path: 88s / 81s / 140s
> > fast path: 14s / 13s / 15s
> >
> > On CI, where t5608 currently takes 200-850 seconds depending on the
> > job, the fast path cuts the pack-generation phase from minutes to
> > seconds, leaving only the clone operations themselves.
>
> Are these numbers accurate for the patch position in the series?
Unfortunately, yes.
> The previous change replaced SHA1DC with the unsafe version, which
> gained similar performance improvements. I'd be interested to see
> the numbers for both enabled at the same time.
The problem is that in many (most?) cases, the "unsafe" version is the
_same_ as the safe version, i.e. SHA1DC. Only the `linux-TEST-vars` job on
CI (and most notably, _not_ in the `win-*` jobs) has a fast "unsafe"
version by default. So if I build the revision as per the previous patch
on Windows, I get no performance benefit whatsoever. That's what my lament
about not being able to link OpenSSL was all about.
Ciao,
Johannes
^ permalink raw reply
* Re: [Bug] fetch --deepen truncates history in v2.54.0
From: Samo Pogačnik @ 2026-05-05 21:26 UTC (permalink / raw)
To: René Scharfe, Owen Stephens, git
In-Reply-To: <e8257951-4ea7-40ba-8043-f4f2a080b70b@web.de>
[-- Attachment #1: Type: text/plain, Size: 445 bytes --]
On Tue, 2026-05-05 at 22:34 +0200, René Scharfe wrote:
> On 5/5/26 9:27 PM, Samo Pogačnik wrote:
> >
> > Hi, thanks for pointing out this edge case. Would you care to check the
> > following change (the provided test is also a bit modified):
>
> There's spurious wrapping in the patch, but the changes look good to me.
>
> Care to send them with a commit message and sign-off?
>
I will, but it may take a while.
thanks, Samo
[-- Attachment #2: This is a digitally signed message part --]
[-- Type: application/pgp-signature, Size: 833 bytes --]
^ permalink raw reply
* [PATCH v6] revision.c: implement --max-count-oldest
From: Mirko Faina @ 2026-05-05 21:54 UTC (permalink / raw)
To: git
Cc: Mirko Faina, Junio C Hamano, Jeff King, Jean-Noël Avila,
Patrick Steinhardt, Tian Yuchen, Ben Knoble, Johannes Sixt,
Chris Torek
In-Reply-To: <2f71a00b035e25b971641b77a6fa7626f1e2459c.1777578676.git.mroik@delayed.space>
--max-count is a commit limiting option sets a maximum amount of commits
to be shown. If a user wants to see only the first N commits of the
history (the oldest commits) they'd have to do something like
git log $(git rev-list HEAD | tail -n N | head -n 1)
This is not very user-friendly.
Teach get_revision() the --max-count-oldest option.
Signed-off-by: Mirko Faina <mroik@delayed.space>
---
Since v5 I've reworded the commit message and rewrote the docs for
--max-count-oldest to be clearer on its functionality.
Documentation/rev-list-options.adoc | 5 ++
revision.c | 77 +++++++++++++++++++++++++++--
revision.h | 2 +
t/t4202-log.sh | 14 ++++++
4 files changed, 95 insertions(+), 3 deletions(-)
diff --git a/Documentation/rev-list-options.adoc b/Documentation/rev-list-options.adoc
index 2d195a1474..9f857cabcc 100644
--- a/Documentation/rev-list-options.adoc
+++ b/Documentation/rev-list-options.adoc
@@ -18,6 +18,11 @@ ordering and formatting options, such as `--reverse`.
`--max-count=<number>`::
Limit the output to _<number>_ commits.
+`--max-count-oldest=<number>`::
+ Just like `--max-count=<number>`, it limits the output to _<number>_
+ commits. But instead of limiting to the first _<number>_ commits it
+ limits to the last _<number>_ commits.
+
`--skip=<number>`::
Skip _<number>_ commits before starting to show the commit output.
diff --git a/revision.c b/revision.c
index 599b3a66c3..3aaa77ced5 100644
--- a/revision.c
+++ b/revision.c
@@ -2339,10 +2339,24 @@ static int handle_revision_opt(struct rev_info *revs, int argc, const char **arg
}
if ((argcount = parse_long_opt("max-count", argv, &optarg))) {
+ if (revs->max_count_type == 1)
+ die(_("can't use --max-count with --max-count-oldest"));
revs->max_count = parse_count(optarg);
revs->no_walk = 0;
+ revs->max_count_type = 0;
return argcount;
+ } else if ((argcount = parse_long_opt("max-count-oldest", argv, &optarg))) {
+ if (revs->max_count_type == 0 && revs->max_count != -1)
+ die(_("can't use --max-count with --max-count-oldest"));
+ if (revs->skip_count > 0)
+ die(_("con't use --max-count-oldest with --skip"));
+ revs->max_count = parse_count(optarg);
+ revs->no_walk = 0;
+ revs->max_count_type = 1;
+ revs->max_count_stage = 0;
} else if ((argcount = parse_long_opt("skip", argv, &optarg))) {
+ if (revs->max_count_type == 1)
+ die(_("con't use --max-count-oldest with --skip"));
revs->skip_count = parse_count(optarg);
return argcount;
} else if ((*arg == '-') && isdigit(arg[1])) {
@@ -4521,15 +4535,68 @@ static struct commit *get_revision_internal(struct rev_info *revs)
return c;
}
+static void retrieve_oldest_commits(struct rev_info *revs,
+ struct commit_list **queue)
+{
+ struct commit *c;
+ int max_count = revs->max_count;
+ int queuei_count = 0;
+ int queueo_count = 0;
+ struct commit_list *queueo = NULL;
+ struct commit_list *queuei = NULL;
+ struct commit_list *reversed_queue = NULL;
+
+ revs->max_count = -1;
+ while ((c = get_revision_internal(revs))) {
+ c->object.flags &= ~SHOWN;
+ commit_list_insert(c, &queuei);
+ queuei_count++;
+ while (queuei_count + queueo_count > max_count) {
+ if (!queueo_count) {
+ while (queuei_count > 0) {
+ c = pop_commit(&queuei);
+ queuei_count--;
+ commit_list_insert(c, &queueo);
+ queueo_count++;
+ }
+ }
+ pop_commit(&queueo);
+ queueo_count--;
+ }
+ }
+
+ while ((c = pop_commit(&queueo)))
+ commit_list_insert(c, &reversed_queue);
+ while ((c = pop_commit(&queuei)))
+ commit_list_insert(c, &queueo);
+ while ((c = pop_commit(&queueo)))
+ commit_list_insert(c, &reversed_queue);
+
+ while ((c = pop_commit(&reversed_queue)))
+ commit_list_insert(c, queue);
+}
+
struct commit *get_revision(struct rev_info *revs)
{
struct commit *c;
struct commit_list *reversed;
+ struct commit_list *queue = NULL;
+
+ if (revs->max_count_type == 1 && !revs->max_count_stage) {
+ retrieve_oldest_commits(revs, &queue);
+ commit_list_free(revs->commits);
+ revs->commits = queue;
+ revs->max_count_stage = 1;
+ }
if (revs->reverse) {
reversed = NULL;
- while ((c = get_revision_internal(revs)))
- commit_list_insert(c, &reversed);
+ if (revs->max_count_type == 1)
+ while ((c = pop_commit(&revs->commits)))
+ commit_list_insert(c, &reversed);
+ else
+ while ((c = get_revision_internal(revs)))
+ commit_list_insert(c, &reversed);
commit_list_free(revs->commits);
revs->commits = reversed;
revs->reverse = 0;
@@ -4543,7 +4610,11 @@ struct commit *get_revision(struct rev_info *revs)
return c;
}
- c = get_revision_internal(revs);
+ if (revs->max_count_stage)
+ c = pop_commit(&revs->commits);
+ else
+ c = get_revision_internal(revs);
+
if (c && revs->graph)
graph_update(revs->graph, c);
if (!c) {
diff --git a/revision.h b/revision.h
index 584f1338b5..e157463cb1 100644
--- a/revision.h
+++ b/revision.h
@@ -309,6 +309,8 @@ struct rev_info {
/* special limits */
int skip_count;
int max_count;
+ unsigned int max_count_type:1;
+ unsigned int max_count_stage:1;
timestamp_t max_age;
timestamp_t max_age_as_filter;
timestamp_t min_age;
diff --git a/t/t4202-log.sh b/t/t4202-log.sh
index 05cee9e41b..668c231cf1 100755
--- a/t/t4202-log.sh
+++ b/t/t4202-log.sh
@@ -1882,6 +1882,20 @@ test_expect_success 'log --graph with --name-status' '
test_cmp_graph --name-status tangle..reach
'
+test_expect_success 'log --max-count-oldest=3 --oneline' '
+ test_when_finished rm expect &&
+ git log --oneline | tail -n3 >expect &&
+ git log --oneline --max-count-oldest=3 >actual &&
+ test_cmp expect actual
+'
+
+test_expect_success 'log --max-count-oldest=3 --reverse --oneline' '
+ test_when_finished rm expect &&
+ git log --oneline | tail -n3 | tac >expect &&
+ git log --oneline --max-count-oldest=3 --reverse >actual &&
+ test_cmp expect actual
+'
+
cat >expect <<-\EOF
* reach
|
--
2.54.0
^ permalink raw reply related
* [PATCH] fetch: add fetch.pruneLocalBranches config
From: Harald Nordgren @ 2026-05-05 22:07 UTC (permalink / raw)
To: j6t; +Cc: git, gitgitgadget, haraldnordgren, kristofferhaugsbakk
In-Reply-To: <1e38fb35-f75d-4067-856e-b5c15f507007@kdbg.org>
> I have some sympathy for the desire to clean up unnecessary local
> branches, but I don't like the concept that `git fetch` modifies local
> branches, not even as an opt-in. Deleting local branches should be `git
> branch`'s task exclusively (at the porcelain level).
Yeah, maybe that's a good point.
Harald
^ permalink raw reply
* [PATCH 0/4] diff: reject negative context values
From: Michael Montalbo via GitGitGadget @ 2026-05-05 23:02 UTC (permalink / raw)
To: git; +Cc: Michael Montalbo
Negative values for -U and --inter-hunk-context are silently accepted
and produce structurally invalid diff output.
Malformed hunk headers:
$ wc -l GIT-VERSION-GEN
106
$ git log -1 -p -U-500 -- GIT-VERSION-GEN | grep '^@@'
@@ -503,999- +503,999- @@
Line 503 of a 106-line file, count "999-" is not a valid integer.
Overlapping hunks that cannot be applied:
$ git log -1 -p -U3 --inter-hunk-context=100 791aeddfa2 \
-- git-compat-util.h | git apply --check --reverse
(success)
$ git log -1 -p -U3 --inter-hunk-context=-100 791aeddfa2 \
-- git-compat-util.h | git apply --check --reverse
error: patch failed: git-compat-util.h:118
error: git-compat-util.h: patch does not apply
Both options were originally parsed via opt_arg() which gated on
isdigit(), making negative values impossible. When they were converted
to OPT_INTEGER_F / OPT_CALLBACK in d473e2e0e8 (diff.c: convert
-U|--unified, 2019-01-27) and 16ed6c97cc (diff-parseopt: convert
--inter-hunk-context, 2019-03-24), the implicit rejection was lost.
PARSE_OPT_NONEG was added but only prevents the --no-* boolean form,
not negative numeric arguments.
This series restores the original invariant with stronger guarantees:
1/4 diff: reject negative values for --inter-hunk-context
Change type to unsigned int, switch to OPT_UNSIGNED.
2/4 diff: reject negative values for -U/--unified
Change type to unsigned int, add range check in callback.
3/4 xdiff: guard against negative context lengths
BUG() in xdl_get_hunk() as defense in depth.
4/4 parse-options: clarify PARSE_OPT_NONEG does not reject
negative numbers
Documentation fix.
The config variables diff.context and diff.interHunkContext have
always rejected negative values. This series brings the CLI options in line.
Michael Montalbo (4):
diff: reject negative values for --inter-hunk-context
diff: reject negative values for -U/--unified
xdiff: guard against negative context lengths
parse-options: clarify PARSE_OPT_NONEG does not reject negative
numbers
diff.c | 25 ++++++++++++++-----------
diff.h | 4 ++--
parse-options.h | 5 ++++-
t/t4032-diff-inter-hunk-context.sh | 6 ++++++
t/t4055-diff-context.sh | 5 +++++
xdiff/xemit.c | 16 ++++++++++++----
6 files changed, 43 insertions(+), 18 deletions(-)
base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2105%2Fmmontalbo%2Fmm%2Freject-negative-interhunk-context-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2105/mmontalbo/mm/reject-negative-interhunk-context-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/2105
--
gitgitgadget
^ permalink raw reply
* [PATCH 1/4] diff: reject negative values for --inter-hunk-context
From: Michael Montalbo via GitGitGadget @ 2026-05-05 23:02 UTC (permalink / raw)
To: git; +Cc: Michael Montalbo, Michael Montalbo
In-Reply-To: <pull.2105.git.1778022144.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
Negative values for --inter-hunk-context produce structurally invalid
diff output with overlapping hunks:
$ git log -1 -p -U3 --inter-hunk-context=-100 791aeddfa2 \
-- git-compat-util.h | grep '^@@'
@@ -110,6 +110,9 @@
@@ -115,6 +118,9 @@
@@ -116,6 +122,7 @@
Hunk 1 covers lines 110-115, hunk 2 starts at 115 (overlap), hunk 3
starts at 116 (overlaps both). The resulting patch cannot be applied.
The config variable diff.interHunkContext already rejects negative
values, but the command line option does not. The option currently
uses OPT_INTEGER_F with PARSE_OPT_NONEG, but PARSE_OPT_NONEG only
prevents the "--no-inter-hunk-context" boolean negation form. It does
not reject negative numeric arguments like "--inter-hunk-context=-1".
Change the type of diff_options.interhunkcontext and its static
default from int to unsigned int, and switch the option parser from
OPT_INTEGER_F to OPT_UNSIGNED. This rejects negative values at parse
time via git_parse_unsigned() and enforces the correct type at compile
time via BARF_UNLESS_UNSIGNED.
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
diff.c | 13 ++++++-------
diff.h | 2 +-
t/t4032-diff-inter-hunk-context.sh | 6 ++++++
3 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/diff.c b/diff.c
index 397e38b41c..5df28e49c5 100644
--- a/diff.c
+++ b/diff.c
@@ -61,7 +61,7 @@ static enum git_colorbool diff_use_color_default = GIT_COLOR_UNKNOWN;
static int diff_color_moved_default;
static int diff_color_moved_ws_default;
static int diff_context_default = 3;
-static int diff_interhunk_context_default;
+static unsigned int diff_interhunk_context_default;
static char *diff_word_regex_cfg;
static struct external_diff external_diff_cfg;
static char *diff_order_file_cfg;
@@ -388,10 +388,10 @@ int git_diff_ui_config(const char *var, const char *value,
return 0;
}
if (!strcmp(var, "diff.interhunkcontext")) {
- diff_interhunk_context_default = git_config_int(var, value,
- ctx->kvi);
- if (diff_interhunk_context_default < 0)
+ int val = git_config_int(var, value, ctx->kvi);
+ if (val < 0)
return -1;
+ diff_interhunk_context_default = val;
return 0;
}
if (!strcmp(var, "diff.renames")) {
@@ -6111,9 +6111,8 @@ struct option *add_diff_options(const struct option *opts,
OPT_CALLBACK_F(0, "default-prefix", options, NULL,
N_("use default prefixes a/ and b/"),
PARSE_OPT_NONEG | PARSE_OPT_NOARG, diff_opt_default_prefix),
- OPT_INTEGER_F(0, "inter-hunk-context", &options->interhunkcontext,
- N_("show context between diff hunks up to the specified number of lines"),
- PARSE_OPT_NONEG),
+ OPT_UNSIGNED(0, "inter-hunk-context", &options->interhunkcontext,
+ N_("show context between diff hunks up to the specified number of lines")),
OPT_CALLBACK_F(0, "output-indicator-new",
&options->output_indicators[OUTPUT_INDICATOR_NEW],
N_("<char>"),
diff --git a/diff.h b/diff.h
index 7eb84aadf4..033d633db4 100644
--- a/diff.h
+++ b/diff.h
@@ -296,7 +296,7 @@ struct diff_options {
/* Number of context lines to generate in patch output. */
int context;
- int interhunkcontext;
+ unsigned int interhunkcontext;
/* Affects the way detection logic for complete rewrites, renames and
* copies.
diff --git a/t/t4032-diff-inter-hunk-context.sh b/t/t4032-diff-inter-hunk-context.sh
index bada0cbd32..bec1676f8d 100755
--- a/t/t4032-diff-inter-hunk-context.sh
+++ b/t/t4032-diff-inter-hunk-context.sh
@@ -114,4 +114,10 @@ test_expect_success 'diff.interHunkContext invalid' '
test_must_fail git diff
'
+test_expect_success '--inter-hunk-context rejects negative value' '
+ test_unconfig diff.interHunkContext &&
+ test_must_fail git diff --inter-hunk-context=-1 2>err &&
+ test_grep "expects a non-negative integer" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH 2/4] diff: reject negative values for -U/--unified
From: Michael Montalbo via GitGitGadget @ 2026-05-05 23:02 UTC (permalink / raw)
To: git; +Cc: Michael Montalbo, Michael Montalbo
In-Reply-To: <pull.2105.git.1778022144.gitgitgadget@gmail.com>
From: Michael Montalbo <mmontalbo@gmail.com>
Passing a negative value to -U is silently accepted and produces
corrupt unified diff output with malformed hunk headers:
$ git log -1 -p -U-500 -- GIT-VERSION-GEN | grep '^@@'
@@ -503,999- +503,999- @@
Line 503 of a 106-line file, count "999-" is not a valid integer.
The config variable diff.context already rejects negative values, but
the command line callback diff_opt_unified() uses strtol() with no
range check.
Change the type of diff_options.context and its static default from
int to unsigned int, matching the change to interhunkcontext in the
previous commit. The type change requires reworking the callback and
config parsing to validate in a local variable before assigning to
the now-unsigned field.
Unlike --inter-hunk-context which could be converted to OPT_UNSIGNED,
-U needs OPT_CALLBACK_F for PARSE_OPT_OPTARG (bare -U with no value
enables patch output). Add a range check in the callback instead.
Signed-off-by: Michael Montalbo <mmontalbo@gmail.com>
---
diff.c | 12 ++++++++----
diff.h | 2 +-
t/t4055-diff-context.sh | 5 +++++
3 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/diff.c b/diff.c
index 5df28e49c5..1771b2c444 100644
--- a/diff.c
+++ b/diff.c
@@ -60,7 +60,7 @@ static int diff_suppress_blank_empty;
static enum git_colorbool diff_use_color_default = GIT_COLOR_UNKNOWN;
static int diff_color_moved_default;
static int diff_color_moved_ws_default;
-static int diff_context_default = 3;
+static unsigned int diff_context_default = 3;
static unsigned int diff_interhunk_context_default;
static char *diff_word_regex_cfg;
static struct external_diff external_diff_cfg;
@@ -382,9 +382,10 @@ int git_diff_ui_config(const char *var, const char *value,
return 0;
}
if (!strcmp(var, "diff.context")) {
- diff_context_default = git_config_int(var, value, ctx->kvi);
- if (diff_context_default < 0)
+ int val = git_config_int(var, value, ctx->kvi);
+ if (val < 0)
return -1;
+ diff_context_default = val;
return 0;
}
if (!strcmp(var, "diff.interhunkcontext")) {
@@ -5924,9 +5925,12 @@ static int diff_opt_unified(const struct option *opt,
BUG_ON_OPT_NEG(unset);
if (arg) {
- options->context = strtol(arg, &s, 10);
+ long val = strtol(arg, &s, 10);
if (*s)
return error(_("%s expects a numerical value"), "--unified");
+ if (val < 0)
+ return error(_("%s expects a non-negative integer"), "--unified");
+ options->context = val;
}
enable_patch_output(&options->output_format);
diff --git a/diff.h b/diff.h
index 033d633db4..bb5cddaf34 100644
--- a/diff.h
+++ b/diff.h
@@ -294,7 +294,7 @@ struct diff_options {
enum git_colorbool use_color;
/* Number of context lines to generate in patch output. */
- int context;
+ unsigned int context;
unsigned int interhunkcontext;
diff --git a/t/t4055-diff-context.sh b/t/t4055-diff-context.sh
index 1384a81957..b26f6eea7c 100755
--- a/t/t4055-diff-context.sh
+++ b/t/t4055-diff-context.sh
@@ -82,6 +82,11 @@ test_expect_success 'negative integer config parsing' '
test_grep "bad config variable" output
'
+test_expect_success '-U-1 is rejected' '
+ test_must_fail git diff -U-1 2>err &&
+ test_grep "expects a non-negative integer" err
+'
+
test_expect_success '-U0 is valid, so is diff.context=0' '
test_config diff.context 0 &&
git diff >output &&
--
gitgitgadget
^ permalink raw reply related
page: next (older) | prev (newer) | latest
- recent:[subjects (threaded)|topics (new)|topics (active)]
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox