* [PATCH v4 1/2] submodule: fix premature failure in recursive submodule fetch
From: Paulius Zaleckas @ 2026-07-14 13:29 UTC (permalink / raw)
To: git
Cc: Junio C Hamano, Paulius Zaleckas, Elijah Newren,
Patrick Steinhardt, Glen Choo, Jonathan Tan
In-Reply-To: <20260714132959.3368867-1-paulius.zaleckas@gmail.com>
When git fetch --recurse-submodules encounters a failure fetching a
submodule's refs (phase 1), it immediately marks the overall operation
as failed, even though a subsequent OID-based fetch (phase 2) is about
to be attempted for any missing commits. If phase 2 succeeds, the
overall result should be success, but the prematurely set failure flag
makes it look like an error.
Restructure fetch_finish() so that a phase-1 failure does not record an
error immediately. Instead, the decision is deferred:
- If missing commits trigger a phase-2 (OID-based) retry and that
retry succeeds, no error is recorded.
- If the phase-2 retry also fails, the error is recorded then.
- If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON)
and is not in the changed list, a phase-1 failure is recorded right
away since there is no OID retry to fall back on.
- If phase 1 fails but all required commits are already present
locally, there is no retry to defer to; the failure is still
recorded, since the fetch itself went wrong (e.g. a transport
error) even though the wanted commits happen to be available.
This resolves the NEEDSWORK comment added by bd5e567dc7 (submodule:
explain first attempt failure clearly, 2019-03-13).
Extract the common error-recording logic into a helper
record_fetch_error() and use it in fetch_start_failure() and for the
"Could not access submodule" error in get_fetch_task_from_index() as
well; the latter now also lists the submodule in the final error
summary.
Add a test ensuring a failed submodule fetch is still reported when
the gitlinked commits happen to be present locally.
Signed-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>
---
submodule.c | 52 +++++++++++++++++++--------
t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 14 deletions(-)
diff --git a/submodule.c b/submodule.c
index fd91201a92..8bcef68a42 100644
--- a/submodule.c
+++ b/submodule.c
@@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf
return NULL;
}
+static void record_fetch_error(struct submodule_parallel_fetch *spf,
+ const char *name)
+{
+ spf->result = 1;
+ strbuf_addf(&spf->submodules_with_errors, "\t%s\n", name);
+}
+
static struct fetch_task *
get_fetch_task_from_index(struct submodule_parallel_fetch *spf,
struct strbuf *err)
@@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf,
ce->name);
if (S_ISGITLINK(ce->ce_mode) &&
!is_empty_dir(empty_submodule_path.buf)) {
- spf->result = 1;
+ record_fetch_error(spf, ce->name);
strbuf_addf(err,
_("Could not access submodule '%s'\n"),
ce->name);
@@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED,
struct submodule_parallel_fetch *spf = cb;
struct fetch_task *task = task_cb;
- spf->result = 1;
+ record_fetch_error(spf, task->sub->name);
fetch_task_free(task);
return 0;
@@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,
if (!task || !task->sub)
BUG("callback cookie bogus");
- if (retvalue) {
+ if (retvalue && task->commits) {
/*
- * NEEDSWORK: This indicates that the overall fetch
- * failed, even though there may be a subsequent fetch
- * by commit hash that might work. It may be a good
- * idea to not indicate failure in this case, and only
- * indicate failure if the subsequent fetch fails.
+ * This is the second pass (OID-based fetch) and it failed.
+ * The commits are genuinely unavailable from the remote.
*/
- spf->result = 1;
-
- strbuf_addf(&spf->submodules_with_errors, "\t%s\n",
- task->sub->name);
+ record_fetch_error(spf, task->sub->name);
}
/* Is this the second time we process this submodule? */
@@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,
goto out;
it = string_list_lookup(&spf->changed_submodule_names, task->sub->name);
- if (!it)
- /* Could be an unchanged submodule, not contained in the list */
+ if (!it) {
+ /*
+ * This submodule is not in the changed list (e.g. it was
+ * fetched because RECURSE_SUBMODULES_ON fetches all populated
+ * submodules). A phase 1 failure here has no OID-based retry
+ * to fall back on, so it is a genuine error.
+ */
+ if (retvalue)
+ record_fetch_error(spf, task->sub->name);
goto out;
+ }
cs_data = it->util;
oid_array_filter(&cs_data->new_commits,
@@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,
/* Are there commits we want, but do not exist? */
if (cs_data->new_commits.nr) {
+ /*
+ * Schedule an OID-based phase 2 fetch to retrieve the missing
+ * commits directly. Defer any error from phase 1: if phase 2
+ * succeeds, the overall operation should still succeed.
+ */
task->commits = &cs_data->new_commits;
ALLOC_GROW(spf->oid_fetch_tasks,
spf->oid_fetch_tasks_nr + 1,
@@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,
return 0;
}
+ /*
+ * All required commits are already present locally (they were either
+ * fetched by phase 1 or existed beforehand), so there is no phase 2
+ * retry to defer to. If phase 1 failed, the fetch itself went wrong
+ * (e.g. a transport error) and must still be reported, even though
+ * the gitlinked commits are available.
+ */
+ if (retvalue)
+ record_fetch_error(spf, task->sub->name);
+
out:
fetch_task_free(task);
return 0;
diff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh
index 1242ee9185..188c674c89 100755
--- a/t/t5526-fetch-submodules.sh
+++ b/t/t5526-fetch-submodules.sh
@@ -1262,4 +1262,76 @@ test_expect_success "fetch --all with --no-recurse-submodules only fetches super
! grep "Fetching submodule" fetch-log
'
+# Create an isolated environment for submodule fetch error tests.
+#
+# Sets up sub_bare (the submodule upstream), super_bare (the superproject
+# upstream), super_work (a working clone of super_bare with an initialized
+# submodule), and clone (a clone of super_bare with an initialized submodule
+# at a reachable commit). The caller can then create an unreachable commit
+# and push the superproject to put the clone one commit behind a state it
+# cannot fully fetch.
+#
+# Usage: create_err_env <envdir>
+create_err_env () {
+ local envdir="$1" &&
+ mkdir "$envdir" &&
+
+ git init --bare "$envdir/sub_bare" &&
+ git clone "$envdir/sub_bare" "$envdir/sub_work" &&
+ test_commit -C "$envdir/sub_work" "${envdir}_base" &&
+ git -C "$envdir/sub_work" push &&
+
+ git init --bare "$envdir/super_bare" &&
+ git clone "$envdir/super_bare" "$envdir/super_work" &&
+ git -C "$envdir/super_work" submodule add \
+ "$pwd/$envdir/sub_bare" sub &&
+ git -C "$envdir/super_work" commit -m "add submodule" &&
+ git -C "$envdir/super_work" push &&
+
+ git clone "$envdir/super_bare" "$envdir/clone" &&
+ git -C "$envdir/clone" submodule update --init
+}
+
+# Push a commit to <envdir>/super_bare that records a submodule SHA that is
+# present locally in super_work/sub but NOT pushed to sub_bare, making the
+# submodule commit unreachable from clone's sub remote.
+push_unreachable_commit () {
+ local envdir="$1" &&
+ git -C "$envdir/super_work/sub" commit --allow-empty -m "unreachable" &&
+ git -C "$envdir/super_work" add sub &&
+ git -C "$envdir/super_work" commit -m "point sub to unreachable commit" &&
+ git -C "$envdir/super_work" push
+}
+
+test_expect_success 'setup for submodule fetch error tests' '
+ git config --global protocol.file.allow always
+'
+
+test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '
+ # Create the same commit (unreferenced, via commit-tree with fixed
+ # dates) in both super_work/sub and clone/sub, point the gitlink at
+ # it, and break clone/sub'\''s remote. The commit exists in clone/sub
+ # but is unreachable, so the submodule stays in the changed list; the
+ # fetch failure must still be reported even though there is nothing
+ # left to fetch by commit hash.
+ test_when_finished "rm -fr env_phase1" &&
+ create_err_env env_phase1 &&
+ commit=$(GIT_AUTHOR_DATE="1234567890 +0000" \
+ GIT_COMMITTER_DATE="1234567890 +0000" \
+ git -C env_phase1/super_work/sub commit-tree \
+ "HEAD^{tree}" -p HEAD -m present) &&
+ present=$(GIT_AUTHOR_DATE="1234567890 +0000" \
+ GIT_COMMITTER_DATE="1234567890 +0000" \
+ git -C env_phase1/clone/sub commit-tree \
+ "HEAD^{tree}" -p HEAD -m present) &&
+ test "$commit" = "$present" &&
+ git -C env_phase1/super_work/sub checkout "$commit" &&
+ git -C env_phase1/super_work add sub &&
+ git -C env_phase1/super_work commit -m "gitlink to locally-present commit" &&
+ git -C env_phase1/super_work push &&
+ git -C env_phase1/clone/sub remote set-url origin "$pwd/env_phase1/missing" &&
+ test_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
test_done
--
2.54.0
^ permalink raw reply related
* [PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal
From: Paulius Zaleckas @ 2026-07-14 13:29 UTC (permalink / raw)
To: git
Cc: Junio C Hamano, Paulius Zaleckas,
Ævar Arnfjörð Bjarmason, Glen Choo,
Patrick Steinhardt
In-Reply-To: <20260714132959.3368867-1-paulius.zaleckas@gmail.com>
When fetching with --recurse-submodules, a submodule commit that is not
yet reachable from any of the submodule's remote refs causes the entire
fetch to fail. This is overly strict when the missing commit belongs to
an upstream branch that is still being prepared (e.g. an in-progress
merge topic): the local branch does not need that commit, so there is no
reason to treat its absence as fatal.
Add a new config key fetch.submoduleErrors (values: fail/warn) and a
corresponding --submodule-errors=(fail|warn) command-line option that
control this behaviour. The default remains fail (existing behaviour);
setting the value to warn causes submodule fetch failures to be reported
on stderr without affecting the overall exit status of git fetch / git
pull.
Forward the option to child fetches in add_options_to_argv() so that it
also takes effect for `git fetch --all` / `--multiple` (where per-remote
child processes handle the submodule recursion themselves) and for
nested submodule recursion. The resolved value is forwarded whenever it
was set explicitly, in either direction: the per-remote children re-read
the repository configuration, so a command-line --submodule-errors=fail
must be passed down to them to override fetch.submoduleErrors=warn from
the configuration. When neither the configuration nor the command line
sets a value, nothing is forwarded and the child processes fall back to
their own configuration.
Helped-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>
---
Documentation/config/fetch.adoc | 14 +++++
Documentation/fetch-options.adoc | 8 +++
builtin/fetch.c | 46 ++++++++++++++++-
submodule.c | 8 ++-
submodule.h | 7 ++-
t/t5526-fetch-submodules.sh | 89 ++++++++++++++++++++++++++++++++
6 files changed, 168 insertions(+), 4 deletions(-)
diff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc
index 04ac90912d..5c9c942a70 100644
--- a/Documentation/config/fetch.adoc
+++ b/Documentation/config/fetch.adoc
@@ -10,6 +10,20 @@
reference.
Defaults to `on-demand`, or to the value of `submodule.recurse` if set.
+`fetch.submoduleErrors`::
+ Controls how errors from submodule fetches are handled when
+ `--recurse-submodules` is in effect. When set to `fail` (the default),
+ any submodule fetch error causes the overall `git fetch` or `git pull`
+ to exit with a non-zero status. When set to `warn`, submodule fetch
+ errors are reported to standard error but do not affect the exit
+ status of the command. This is useful when working in repositories
+ where some branches reference submodule commits that are not yet
+ available on the submodule remote, but those commits are not needed
+ for the currently checked-out branch.
++
+The value of this option can be overridden by the `--submodule-errors`
+option of linkgit:git-fetch[1].
+
`fetch.fsckObjects`::
If it is set to true, git-fetch-pack will check all fetched
objects. See `transfer.fsckObjects` for what's
diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
index 035f780e58..78525f6848 100644
--- a/Documentation/fetch-options.adoc
+++ b/Documentation/fetch-options.adoc
@@ -294,6 +294,14 @@ ifndef::git-pull[]
`--no-recurse-submodules`::
Disable recursive fetching of submodules (this has the same effect as
using the `--recurse-submodules=no` option).
+
+`--submodule-errors=(fail|warn)`::
+ Control how errors from submodule fetches are handled when
+ `--recurse-submodules` is in effect. When set to `fail` (the default),
+ any submodule fetch error causes the overall `git fetch` to exit with a
+ non-zero status. When set to `warn`, submodule fetch errors are reported
+ to standard error but do not affect the exit status of the command. Can
+ also be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].
endif::git-pull[]
`--set-upstream`::
diff --git a/builtin/fetch.c b/builtin/fetch.c
index c1d7c672f4..41122e17b3 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -110,6 +110,7 @@ struct fetch_config {
int recurse_submodules;
int parallel;
int submodule_fetch_jobs;
+ int submodule_errors;
};
static int git_fetch_config(const char *k, const char *v,
@@ -152,6 +153,19 @@ static int git_fetch_config(const char *k, const char *v,
return 0;
}
+ if (!strcmp(k, "fetch.submoduleerrors")) {
+ if (!v)
+ return config_error_nonbool(k);
+ else if (!strcasecmp(v, "fail"))
+ fetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;
+ else if (!strcasecmp(v, "warn"))
+ fetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;
+ else
+ die(_("invalid value for '%s': '%s'"),
+ "fetch.submoduleErrors", v);
+ return 0;
+ }
+
if (!strcmp(k, "fetch.parallel")) {
fetch_config->parallel = git_config_int(k, v, ctx->kvi);
if (fetch_config->parallel < 0)
@@ -2205,6 +2219,10 @@ static void add_options_to_argv(struct strvec *argv,
strvec_push(argv, "--no-recurse-submodules");
else if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
strvec_push(argv, "--recurse-submodules=on-demand");
+ if (config->submodule_errors == SUBMODULE_ERRORS_FAIL)
+ strvec_push(argv, "--submodule-errors=fail");
+ else if (config->submodule_errors == SUBMODULE_ERRORS_WARN)
+ strvec_push(argv, "--submodule-errors=warn");
if (tags == TAGS_SET)
strvec_push(argv, "--tags");
else if (tags == TAGS_UNSET)
@@ -2464,6 +2482,19 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,
return exit_code;
}
+static int option_parse_submodule_errors(const struct option *opt,
+ const char *arg, int unset)
+{
+ int *v = opt->value;
+ if (unset || !strcasecmp(arg, "fail"))
+ *v = SUBMODULE_ERRORS_FAIL;
+ else if (!strcasecmp(arg, "warn"))
+ *v = SUBMODULE_ERRORS_WARN;
+ else
+ die(_("invalid value for '%s': '%s'"), "--submodule-errors", arg);
+ return 0;
+}
+
int cmd_fetch(int argc,
const char **argv,
const char *prefix,
@@ -2477,6 +2508,7 @@ int cmd_fetch(int argc,
.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,
.parallel = 1,
.submodule_fetch_jobs = -1,
+ .submodule_errors = -1, /* unset */
};
const char *submodule_prefix = "";
const char *bundle_uri;
@@ -2491,6 +2523,7 @@ int cmd_fetch(int argc,
int max_jobs = -1;
int recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;
int recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;
+ int submodule_errors_cli = -1; /* -1: not set on command line */
int fetch_write_commit_graph = -1;
int stdin_refspecs = 0;
int negotiate_only = 0;
@@ -2527,6 +2560,10 @@ int cmd_fetch(int argc,
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),
+ OPT_CALLBACK_F(0, "submodule-errors", &submodule_errors_cli,
+ N_("(fail|warn)"),
+ N_("control how submodule fetch errors are handled"),
+ 0, option_parse_submodule_errors),
OPT_BOOL(0, "dry-run", &dry_run,
N_("dry run")),
OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
@@ -2616,6 +2653,9 @@ int cmd_fetch(int argc,
if (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)
config.recurse_submodules = recurse_submodules_cli;
+ if (submodule_errors_cli != -1)
+ config.submodule_errors = submodule_errors_cli;
+
if (negotiate_only) {
switch (recurse_submodules_cli) {
case RECURSE_SUBMODULES_OFF:
@@ -2819,11 +2859,14 @@ int cmd_fetch(int argc,
if (!result && remote && (config.recurse_submodules != RECURSE_SUBMODULES_OFF)) {
struct strvec options = STRVEC_INIT;
int max_children = max_jobs;
+ int submodule_errors = config.submodule_errors;
if (max_children < 0)
max_children = config.submodule_fetch_jobs;
if (max_children < 0)
max_children = config.parallel;
+ if (submodule_errors < 0)
+ submodule_errors = SUBMODULE_ERRORS_FAIL;
add_options_to_argv(&options, &config);
trace2_region_enter_printf("fetch", "recurse-submodule", the_repository, "%s", submodule_prefix);
@@ -2833,7 +2876,8 @@ int cmd_fetch(int argc,
config.recurse_submodules,
recurse_submodules_default,
verbosity < 0,
- max_children);
+ max_children,
+ submodule_errors);
trace2_region_leave_printf("fetch", "recurse-submodule", the_repository, "%s", submodule_prefix);
strvec_clear(&options);
}
diff --git a/submodule.c b/submodule.c
index 8bcef68a42..da4ace751f 100644
--- a/submodule.c
+++ b/submodule.c
@@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch {
int oid_fetch_tasks_nr, oid_fetch_tasks_alloc;
struct strbuf submodules_with_errors;
+ int submodule_errors;
};
#define SPF_INIT { \
.args = STRVEC_INIT, \
@@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf
static void record_fetch_error(struct submodule_parallel_fetch *spf,
const char *name)
{
- spf->result = 1;
+ if (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)
+ spf->result = 1;
strbuf_addf(&spf->submodules_with_errors, "\t%s\n", name);
}
@@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r,
const struct strvec *options,
const char *prefix, int command_line_option,
int default_option,
- int quiet, int max_parallel_jobs)
+ int quiet, int max_parallel_jobs,
+ int submodule_errors)
{
struct submodule_parallel_fetch spf = SPF_INIT;
const struct run_process_parallel_opts opts = {
@@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r,
spf.default_option = default_option;
spf.quiet = quiet;
spf.prefix = prefix;
+ spf.submodule_errors = submodule_errors;
if (!r->worktree)
goto out;
diff --git a/submodule.h b/submodule.h
index b10e16e6c0..c80b687d2a 100644
--- a/submodule.h
+++ b/submodule.h
@@ -90,12 +90,17 @@ int should_update_submodules(void);
*/
const struct submodule *submodule_from_ce(const struct cache_entry *ce);
void check_for_new_submodule_commits(struct object_id *oid);
+/* Values for the submodule_errors parameter of fetch_submodules(). */
+#define SUBMODULE_ERRORS_FAIL 0 /* submodule fetch errors are fatal (default) */
+#define SUBMODULE_ERRORS_WARN 1 /* submodule fetch errors are non-fatal warnings */
+
int fetch_submodules(struct repository *r,
const struct strvec *options,
const char *prefix,
int command_line_option,
int default_option,
- int quiet, int max_parallel_jobs);
+ int quiet, int max_parallel_jobs,
+ int submodule_errors);
unsigned is_submodule_modified(const char *path, int ignore_untracked);
int submodule_uses_gitfile(const char *path);
diff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh
index 188c674c89..504ab200ef 100755
--- a/t/t5526-fetch-submodules.sh
+++ b/t/t5526-fetch-submodules.sh
@@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' '
git config --global protocol.file.allow always
'
+test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' '
+ test_when_finished "rm -fr env_default" &&
+ create_err_env env_default &&
+ push_unreachable_commit env_default &&
+ test_must_fail git -C env_default/clone fetch --recurse-submodules 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' '
+ test_when_finished "rm -fr env_warn_cfg" &&
+ create_err_env env_warn_cfg &&
+ push_unreachable_commit env_warn_cfg &&
+ git -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \
+ fetch --recurse-submodules 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' '
+ test_when_finished "rm -fr env_warn_cli" &&
+ create_err_env env_warn_cli &&
+ push_unreachable_commit env_warn_cli &&
+ git -C env_warn_cli/clone fetch --recurse-submodules \
+ --submodule-errors=warn 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' '
+ test_when_finished "rm -fr env_fail_cli" &&
+ create_err_env env_fail_cli &&
+ push_unreachable_commit env_fail_cli &&
+ test_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \
+ --submodule-errors=fail 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' '
+ # A new reachable submodule commit (pushed to sub_bare) should be
+ # fetched without any error summary.
+ test_when_finished "rm -fr env_ok" &&
+ create_err_env env_ok &&
+ test_commit -C env_ok/sub_work reachable_ok &&
+ git -C env_ok/sub_work push &&
+ git -C env_ok/super_work submodule update --remote &&
+ git -C env_ok/super_work add sub &&
+ git -C env_ok/super_work commit -m "point sub to reachable commit" &&
+ git -C env_ok/super_work push &&
+ git -C env_ok/clone -c fetch.submoduleErrors=warn \
+ fetch --recurse-submodules 2>err &&
+ ! grep "Errors during submodule fetch" err
+'
+
test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '
# Create the same commit (unreferenced, via commit-tree with fixed
# dates) in both super_work/sub and clone/sub, point the gitlink at
@@ -1334,4 +1385,42 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p
grep "Errors during submodule fetch" err
'
+test_expect_success '--submodule-errors=warn is honored by fetch --all' '
+ # A second remote forces fetch_multiple(), which hands the submodule
+ # recursion off to per-remote child processes; the option must be
+ # forwarded to them.
+ test_when_finished "rm -fr env_all" &&
+ create_err_env env_all &&
+ push_unreachable_commit env_all &&
+ git -C env_all/clone remote add second "$pwd/env_all/super_bare" &&
+ git -C env_all/clone fetch --all --recurse-submodules \
+ --submodule-errors=warn 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success '--submodule-errors=fail overrides warn config for fetch --all' '
+ # The per-remote child processes re-read the repository config, so
+ # the command-line override must be forwarded to them explicitly.
+ test_when_finished "rm -fr env_override" &&
+ create_err_env env_override &&
+ push_unreachable_commit env_override &&
+ git -C env_override/clone remote add second "$pwd/env_override/super_bare" &&
+ git -C env_override/clone config fetch.submoduleErrors warn &&
+ test_must_fail git -C env_override/clone fetch --all --recurse-submodules \
+ --submodule-errors=fail 2>err &&
+ grep "Errors during submodule fetch" err
+'
+
+test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '
+ test_when_finished "rm -fr env_access" &&
+ create_err_env env_access &&
+ rm env_access/clone/sub/.git &&
+ rm -r env_access/clone/.git/modules/sub &&
+ git -C env_access/clone -c fetch.submoduleErrors=warn \
+ fetch --recurse-submodules 2>err &&
+ grep "Could not access submodule" err &&
+ test_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&
+ grep "Could not access submodule" err
+'
+
test_done
--
2.54.0
^ permalink raw reply related
* Re: [PATCH v3 0/2] t1100: modernize test script
From: Patrick Steinhardt @ 2026-07-14 13:56 UTC (permalink / raw)
To: Shlok Kulshreshtha; +Cc: git, Junio C Hamano
In-Reply-To: <20260714122033.61947-1-diy2903@gmail.com>
On Tue, Jul 14, 2026 at 05:50:31PM +0530, Shlok Kulshreshtha wrote:
> This is v3 of the microproject cleaning up
> t/t1100-commit-tree-options.sh ("Modernize a test script").
>
> Apologies, v2 crossed with Patrick's review of v1. This v3 folds in his
> feedback as well.
>
> Changes since v2:
> - Patch 1/2: also drop the extraneous blank line before the "flags
> and then non flags" test, as Patrick suggested.
>
> Changes since v1 (carried over from v2):
> - Patch 2/2: reword the commit message to use the present tense, as
> Junio suggested.
Thanks, this version looks good to me.
Patrick
^ permalink raw reply
* Re: [PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal
From: Junio C Hamano @ 2026-07-14 15:34 UTC (permalink / raw)
To: Paulius Zaleckas
Cc: git, Ævar Arnfjörð Bjarmason, Glen Choo,
Patrick Steinhardt
In-Reply-To: <20260714132959.3368867-3-paulius.zaleckas@gmail.com>
Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:
> t/t5526-fetch-submodules.sh | 89 ++++++++++++++++++++++++++++++++
> 6 files changed, 168 insertions(+), 4 deletions(-)
In addition to what was pointed out by Ramsay in his squashable
patch <387a34d5-fdf5-4513-9aaf-4e73d9304c1d@ramsayjones.plus.com>
this round adds another use of raw grep that is caught by the test
framework.
commit 8f7761ee72b3669c1aee98142852437d016c785c
Author: Junio C Hamano <gitster@pobox.com>
Date: Tue Jul 14 08:31:57 2026 -0700
fixup! fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal
diff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh
index 614d45ab71..19d17440cf 100755
--- a/t/t5526-fetch-submodules.sh
+++ b/t/t5526-fetch-submodules.sh
@@ -1408,7 +1408,7 @@ test_expect_success '--submodule-errors=fail overrides warn config for fetch --a
git -C env_override/clone config fetch.submoduleErrors warn &&
test_must_fail git -C env_override/clone fetch --all --recurse-submodules \
--submodule-errors=fail 2>err &&
- grep "Errors during submodule fetch" err
+ test_grep "Errors during submodule fetch" err
'
test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '
^ permalink raw reply related
* Re: [PATCH v3 0/2] t1100: modernize test script
From: Junio C Hamano @ 2026-07-14 15:34 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: Shlok Kulshreshtha, git
In-Reply-To: <alZADk3gB5GRxUiC@pks.im>
Patrick Steinhardt <ps@pks.im> writes:
> On Tue, Jul 14, 2026 at 05:50:31PM +0530, Shlok Kulshreshtha wrote:
>> This is v3 of the microproject cleaning up
>> t/t1100-commit-tree-options.sh ("Modernize a test script").
>>
>> Apologies, v2 crossed with Patrick's review of v1. This v3 folds in his
>> feedback as well.
>>
>> Changes since v2:
>> - Patch 1/2: also drop the extraneous blank line before the "flags
>> and then non flags" test, as Patrick suggested.
>>
>> Changes since v1 (carried over from v2):
>> - Patch 2/2: reword the commit message to use the present tense, as
>> Junio suggested.
>
> Thanks, this version looks good to me.
Thanks.
^ permalink raw reply
* Re: [PATCH v11 06/10] environment: migrate apply_default_whitespace and apply_default_ignorewhitespace
From: Junio C Hamano @ 2026-07-14 16:24 UTC (permalink / raw)
To: Tian Yuchen
Cc: git, pabloosabaterr, cirnovskyv, szeder.dev, Christian Couder,
Ayush Chandekar, Olamide Caleb Bello
In-Reply-To: <61ef1b0a-72e8-45b3-b6e8-46eb5b34ea91@malon.dev>
Tian Yuchen <cat@malon.dev> writes:
> On 7/14/26 00:39, Junio C Hamano wrote:
>> Tian Yuchen <cat@malon.dev> writes:
>>
>>> Subject: Re: [PATCH v11 06/10] environment: migrate apply_default_whitespace and apply_default_ignorewhitespace
>>
>> Are there patches 7..10/10 posted somewhere else? I didn't see them
>> in the thread (neither did "b4").
>>
>
> Oh, I didn't notice that:
>
> Died at /usr/lib/git-core/git-send-email line 1665.
>
> Will resend very soon.
Thanks.
^ permalink raw reply
* Re: [PATCH] strbuf: avoid redundant reset in strbuf_getwholeline()
From: Junio C Hamano @ 2026-07-14 16:40 UTC (permalink / raw)
To: René Scharfe; +Cc: Git List
In-Reply-To: <d4ffe7fb-f782-4f06-9e3b-f72729d1e225@web.de>
René Scharfe <l.s.r@web.de> writes:
> The HAVE_GETDELIM variant of strbuf_getwholeline() calls strbuf_reset()
> on the strbuf before handing it over to getdelim(3). This is
> unnecessary:
>
> - getdelim(3) doesn't care whether the old buffer contents is
> NUL-terminated and has no access to ->len,
> - on success getdelim(3) NUL-terminates the buffer and we set ->len,
> - on error we either call strbuf_init() or strbuf_reset().
>
> Remove the superfluous preparatory call.
>
> Signed-off-by: René Scharfe <l.s.r@web.de>
> ---
> strbuf.c | 2 --
> 1 file changed, 2 deletions(-)
>
> diff --git a/strbuf.c b/strbuf.c
> index 764b629927..44955669e8 100644
> --- a/strbuf.c
> +++ b/strbuf.c
> @@ -646,8 +646,6 @@ int strbuf_getwholeline(struct strbuf *sb, FILE *fp, int term)
> if (feof(fp))
> return EOF;
>
> - strbuf_reset(sb);
> -
This is well explained and makes perfect sense.
Thanks. Will apply and mark for 'next'.
> /* Translate slopbuf to NULL, as we cannot call realloc on it */
> if (!sb->alloc)
> sb->buf = NULL;
^ permalink raw reply
* Re: [PATCH v3 0/2] t1100: modernize test script
From: Junio C Hamano @ 2026-07-14 16:43 UTC (permalink / raw)
To: Shlok Kulshreshtha; +Cc: git, Patrick Steinhardt
In-Reply-To: <20260714122033.61947-1-diy2903@gmail.com>
Shlok Kulshreshtha <diy2903@gmail.com> writes:
> This is v3 of the microproject cleaning up
> t/t1100-commit-tree-options.sh ("Modernize a test script").
>
> Apologies, v2 crossed with Patrick's review of v1. This v3 folds in his
> feedback as well.
Looking good. Will replace, and mark the topic for 'next'.
Thanks.
^ permalink raw reply
* Re: [PATCH v18 5/7] branch: add --delete-merged <branch>
From: Harald Nordgren @ 2026-07-14 17:00 UTC (permalink / raw)
To: Phillip Wood
Cc: phillip.wood, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
In-Reply-To: <2fe8c5e0-96d1-46ce-8fda-1b8f521d3c4b@gmail.com>
> Yes, though I've just remembered that when we were discussing protecting
> branches that are the upstreams of another branch Junio was keen for us
> to extend that protection to "git branch -d" as well.
Sure, not a bad idea, I'll put it on my list of things to do after
this topic has landed.
Harald
^ permalink raw reply
* Re: [PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal
From: Junio C Hamano @ 2026-07-14 17:14 UTC (permalink / raw)
To: Paulius Zaleckas
Cc: git, Ævar Arnfjörð Bjarmason, Glen Choo,
Patrick Steinhardt
In-Reply-To: <20260714132959.3368867-3-paulius.zaleckas@gmail.com>
Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:
> + if (!strcmp(k, "fetch.submoduleerrors")) {
> + if (!v)
> + return config_error_nonbool(k);
> + else if (!strcasecmp(v, "fail"))
> + fetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;
> + else if (!strcasecmp(v, "warn"))
> + fetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;
> + else
> + die(_("invalid value for '%s': '%s'"),
> + "fetch.submoduleErrors", v);
> + return 0;
> + }
Two points.
* Do not use strcasecmp() on the value.
While "fetch.submoduleerrors" may be case-insenstive, the value
does not have to be. We do not want to encourage users to write
"[fetch] submoduleErrors = Fail", as some people may want to
write third-party add-on scripts that parse "git config --get
fetch.submoduleerrors" output. For example:
error_handling=$(git config --get fetch.submoduleErrors)
case "$error_handling" in
fail)
... do something ... ;;
warn)
... do something else ... ;;
esac
We should not force them to write extra code to handle the value
case-insensitively.
* Since you need to convert between the enum and the string here,
in option_parse_submodule_errors(), and in add_options_to_argv(),
defining a pair of parse/format functions would be cleaner.
/* really private - use accessors to parse and format */
static const char *submodule_errors_[] = {
[SUBMODULE_ERRORS_FAIL] = "fail",
[SUBMODULE_ERRORS_WARN] = "warn",
};
static const char *submodule_error(int num)
{
assert(0 <= num && num < ARRAY_SIZE(submodule_errors_));
return submodule_errors[num];
}
static int parse_submodule_error(const char *name)
{
for (int num = 0; num < ARRAY_SIZE(submodule_errors_); num++)
if (!strcmp(submodule_errors_[num], name))
return num;
return -1;
}
The configuration parsing block would then become:
if (!strcmp(k, "fetch.submoduleerrors")) {
int num;
if (!v)
return config_error_nonbool(k);
num = parse_submodule_error(v);
if (num < 0)
die(_("invalid value..."), ...);
fetch_config->submodule_errors = num;
return 0;
}
This approach is much more maintainable. You only need to keep the
submodule_errors_[] array up to date with respect to the error-handling
preprocessor macros. Some reviewers might suggest converting these macros
into a proper enum. I would not object to that change, but I would not
bother doing it myself as I do not personally care much about the
distinction between an enum and a preprocessor macro in this context.
> @@ -2205,6 +2219,10 @@ static void add_options_to_argv(struct strvec *argv,
> strvec_push(argv, "--no-recurse-submodules");
> else if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)
> strvec_push(argv, "--recurse-submodules=on-demand");
> + if (config->submodule_errors == SUBMODULE_ERRORS_FAIL)
> + strvec_push(argv, "--submodule-errors=fail");
> + else if (config->submodule_errors == SUBMODULE_ERRORS_WARN)
> + strvec_push(argv, "--submodule-errors=warn");
This part then becomes:
if (config->submodule_errors < 0)
; /* nothing */
else {
const char *name = submodule_error(config->submodule_errors);
strvec_push(argv, "--submodule-errors=%s", name);
}
This is, again, much more miantainable.
> +static int option_parse_submodule_errors(const struct option *opt,
> + const char *arg, int unset)
> +{
> + int *v = opt->value;
> + if (unset || !strcasecmp(arg, "fail"))
> + *v = SUBMODULE_ERRORS_FAIL;
> + else if (!strcasecmp(arg, "warn"))
> + *v = SUBMODULE_ERRORS_WARN;
> + else
> + die(_("invalid value for '%s': '%s'"), "--submodule-errors", arg);
> + return 0;
> +}
Updating this function is left as an exercise ;-)
^ permalink raw reply
* Re: [PATCH GSoC v17 10/13] transport: add client support for object-info
From: Junio C Hamano @ 2026-07-14 17:58 UTC (permalink / raw)
To: Pablo Sabater
Cc: chandrapratap3519, chriscool, eric.peijian, git, jltobler,
karthik.188, peff, toon, Calvin Wan, Jonathan Tan
In-Reply-To: <20260714-ps-eric-work-rebase-v17-10-afabfc83260e@gmail.com>
Pablo Sabater <pabloosabaterr@gmail.com> writes:
> + for (size_t i = 0; packet_reader_read(reader) == PACKET_READ_NORMAL && i < args->oids->nr; i++) {
An overly long line. Format it like this, perhaps?
for (size_t i = 0;
packet_reader_read(reader) == PACKET_READ_NORMAL && i < args->oids->nr;
i++) {
or even:
for (size_t i = 0;
packet_reader_read(reader) == PACKET_READ_NORMAL &&
i < args->oids->nr;
i++) {
> + struct string_list object_info_values = STRING_LIST_INIT_DUP;
> +
> + string_list_split(&object_info_values, reader->line, " ", -1);
> + if (size_index >= 0) {
> + if (!strcmp(object_info_values.items[1 + size_index].string, "")) {
> + FREE_AND_NULL(object_info_data[i].sizep);
> + string_list_clear(&object_info_values, 0);
> + continue;
> + }
> +
> + if (parse_object_size(object_info_values.items[1 + size_index].string,
> + object_info_data[i].sizep))
> + die("object-info: ref %s has invalid size %s",
> + object_info_values.items[0].string,
> + object_info_values.items[1 + size_index].string);
> + }
> +
> + string_list_clear(&object_info_values, 0);
Is this not trusting the other side too much?
If the other end returns fewer values than expected (e.g., if a
buggy or malicious server returns only "<oid>" without a trailing
space for an unrecognized object, or if we request multiple
attributes in the future and the server returns fewer values than
expected), string_list_split may return a list with fewer elements
than size_index + 1. Accessing object_info_values.items[size_index
+ 1] will then result in an out-of-bounds read/crash.
By the way, from a stylistic standpoint, "size_index + 1" reads a
bit more naturally than the "1 + size_index" used in the current
patch.
^ permalink raw reply
* [PATCH 0/5] tempfile: stop using the_repository
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
create_tempfile_mode() and create_tempfile() use the_repository
internally to call adjust_shared_perm(). Expose that dependency and
push it out to their callers.
Patch 5 is a bonus; it converts lockfile users that already work with
other repositories.
tempfile: add repo_create_tempfile{,_mode}()
refs/packed: use repo_create_tempfile()
lockfile: add repo_hold_lock_file_for_update{,_timeout}{,_mode}()
tempfile: stop using the_repository
use repo_hold_lock_file_for_update{,_mode,_timeout}() with custom repos
apply.c | 10 ++++++----
builtin/difftool.c | 2 +-
builtin/gc.c | 2 +-
builtin/history.c | 2 +-
builtin/sparse-checkout.c | 3 ++-
bundle.c | 4 ++--
commit-graph.c | 9 +++++----
config.c | 4 ++--
lockfile.c | 30 ++++++++++++++++++++++--------
lockfile.h | 31 +++++++++++++++++++++++++++++++
loose.c | 6 ++++--
midx-write.c | 7 ++++---
odb/source-files.c | 3 ++-
refs/files-backend.c | 10 ++++++----
refs/packed-backend.c | 9 ++++-----
refs/packed-backend.h | 2 +-
repack-midx.c | 3 ++-
repository.c | 2 +-
rerere.c | 6 +++---
tempfile.c | 7 +++----
tempfile.h | 10 +++++++---
21 files changed, 110 insertions(+), 52 deletions(-)
--
2.55.0
^ permalink raw reply
* [PATCH 1/5] tempfile: add repo_create_tempfile{,_mode}()
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
In-Reply-To: <20260714175956.54601-1-l.s.r@web.de>
Add variants of create_tempfile_mode() that handle arbitrary
repositories.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
tempfile.c | 8 +++++++-
tempfile.h | 11 +++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/tempfile.c b/tempfile.c
index f0fdf58279..3132eb4371 100644
--- a/tempfile.c
+++ b/tempfile.c
@@ -135,6 +135,12 @@ static void deactivate_tempfile(struct tempfile *tempfile)
/* Make sure errno contains a meaningful value on error */
struct tempfile *create_tempfile_mode(const char *path, int mode)
+{
+ return repo_create_tempfile_mode(the_repository, path, mode);
+}
+
+struct tempfile *repo_create_tempfile_mode(struct repository *r,
+ const char *path, int mode)
{
struct tempfile *tempfile = new_tempfile();
@@ -150,7 +156,7 @@ struct tempfile *create_tempfile_mode(const char *path, int mode)
return NULL;
}
activate_tempfile(tempfile);
- if (adjust_shared_perm(the_repository, tempfile->filename.buf)) {
+ if (adjust_shared_perm(r, tempfile->filename.buf)) {
int save_errno = errno;
error("cannot fix permission bits on %s", tempfile->filename.buf);
delete_tempfile(&tempfile);
diff --git a/tempfile.h b/tempfile.h
index 2227a095fd..2d17e4dad3 100644
--- a/tempfile.h
+++ b/tempfile.h
@@ -4,6 +4,8 @@
#include "list.h"
#include "strbuf.h"
+struct repository;
+
/*
* Handle temporary files.
*
@@ -94,11 +96,20 @@ struct tempfile {
*/
struct tempfile *create_tempfile_mode(const char *path, int mode);
+struct tempfile *repo_create_tempfile_mode(struct repository *r,
+ const char *path, int mode);
+
static inline struct tempfile *create_tempfile(const char *path)
{
return create_tempfile_mode(path, 0666);
}
+static inline struct tempfile *repo_create_tempfile(struct repository *r,
+ const char *path)
+{
+ return repo_create_tempfile_mode(r, path, 0666);
+}
+
/*
* Register an existing file as a tempfile, meaning that it will be
* deleted when the program exits. The tempfile is considered closed,
--
2.55.0
^ permalink raw reply related
* [PATCH 2/5] refs/packed: use repo_create_tempfile()
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
In-Reply-To: <20260714175956.54601-1-l.s.r@web.de>
Apply the config setting core.sharedRepository from the ref store base
repository at hand instead of from the_repository.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
refs/packed-backend.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/refs/packed-backend.c b/refs/packed-backend.c
index 499cb55dfa..7e65d9580e 100644
--- a/refs/packed-backend.c
+++ b/refs/packed-backend.c
@@ -1393,7 +1393,7 @@ static enum ref_transaction_error write_with_updates(struct packed_ref_store *re
packed_refs_path = get_locked_file_path(&refs->lock);
strbuf_addf(&sb, "%s.new", packed_refs_path);
free(packed_refs_path);
- refs->tempfile = create_tempfile(sb.buf);
+ refs->tempfile = repo_create_tempfile(refs->base.repo, sb.buf);
if (!refs->tempfile) {
strbuf_addf(err, "unable to create file %s: %s",
sb.buf, strerror(errno));
--
2.55.0
^ permalink raw reply related
* [PATCH 3/5] lockfile: add repo_hold_lock_file_for_update{,_timeout}{,_mode}()
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
In-Reply-To: <20260714175956.54601-1-l.s.r@web.de>
Add variants of hold_lock_file_for_update_timeout_mode() that handle
arbitrary repositories.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
lockfile.c | 30 ++++++++++++++++++++++--------
lockfile.h | 31 +++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 8 deletions(-)
diff --git a/lockfile.c b/lockfile.c
index 7add2f136a..100f603771 100644
--- a/lockfile.c
+++ b/lockfile.c
@@ -2,11 +2,14 @@
* Copyright (c) 2005, Junio C Hamano
*/
+#define USE_THE_REPOSITORY_VARIABLE
+
#include "git-compat-util.h"
#include "abspath.h"
#include "gettext.h"
#include "lockfile.h"
#include "parse.h"
+#include "repository.h"
#include "strbuf.h"
#include "wrapper.h"
@@ -162,8 +165,8 @@ static int read_lock_pid(const char *pid_path, uintmax_t *pid_out)
}
/* Make sure errno contains a meaningful value on error */
-static int lock_file(struct lock_file *lk, const char *path, int flags,
- int mode)
+static int lock_file(struct repository *r, struct lock_file *lk,
+ const char *path, int flags, int mode)
{
struct strbuf base_path = STRBUF_INIT;
struct strbuf lock_path = STRBUF_INIT;
@@ -176,7 +179,7 @@ static int lock_file(struct lock_file *lk, const char *path, int flags,
get_lock_path(&lock_path, base_path.buf);
get_pid_path(&pid_path, base_path.buf);
- lk->tempfile = create_tempfile_mode(lock_path.buf, mode);
+ lk->tempfile = repo_create_tempfile_mode(r, lock_path.buf, mode);
if (lk->tempfile)
lk->pid_tempfile = create_lock_pid_file(pid_path.buf, mode);
@@ -200,8 +203,9 @@ static int lock_file(struct lock_file *lk, const char *path, int flags,
* timeout_ms milliseconds. If timeout_ms is 0, try locking the file
* exactly once. If timeout_ms is -1, try indefinitely.
*/
-static int lock_file_timeout(struct lock_file *lk, const char *path,
- int flags, long timeout_ms, int mode)
+static int lock_file_timeout(struct repository *r, struct lock_file *lk,
+ const char *path, int flags, long timeout_ms,
+ int mode)
{
int n = 1;
int multiplier = 1;
@@ -209,7 +213,7 @@ static int lock_file_timeout(struct lock_file *lk, const char *path,
static int random_initialized = 0;
if (timeout_ms == 0)
- return lock_file(lk, path, flags, mode);
+ return lock_file(r, lk, path, flags, mode);
if (!random_initialized) {
srand((unsigned int)getpid());
@@ -223,7 +227,7 @@ static int lock_file_timeout(struct lock_file *lk, const char *path,
long backoff_ms, wait_ms;
int fd;
- fd = lock_file(lk, path, flags, mode);
+ fd = lock_file(r, lk, path, flags, mode);
if (fd >= 0)
return fd; /* success */
@@ -308,7 +312,17 @@ int hold_lock_file_for_update_timeout_mode(struct lock_file *lk,
const char *path, int flags,
long timeout_ms, int mode)
{
- int fd = lock_file_timeout(lk, path, flags, timeout_ms, mode);
+ return repo_hold_lock_file_for_update_timeout_mode(the_repository,
+ lk, path, flags,
+ timeout_ms, mode);
+}
+
+int repo_hold_lock_file_for_update_timeout_mode(struct repository *r,
+ struct lock_file *lk,
+ const char *path, int flags,
+ long timeout_ms, int mode)
+{
+ int fd = lock_file_timeout(r, lk, path, flags, timeout_ms, mode);
if (fd < 0) {
if (flags & LOCK_DIE_ON_ERROR)
unable_to_lock_die(path, errno);
diff --git a/lockfile.h b/lockfile.h
index e7233f28de..1667612674 100644
--- a/lockfile.h
+++ b/lockfile.h
@@ -189,6 +189,11 @@ int hold_lock_file_for_update_timeout_mode(
struct lock_file *lk, const char *path,
int flags, long timeout_ms, int mode);
+int repo_hold_lock_file_for_update_timeout_mode(struct repository *r,
+ struct lock_file *lk,
+ const char *path, int flags,
+ long timeout_ms, int mode);
+
static inline int hold_lock_file_for_update_timeout(
struct lock_file *lk, const char *path,
int flags, long timeout_ms)
@@ -197,6 +202,16 @@ static inline int hold_lock_file_for_update_timeout(
timeout_ms, 0666);
}
+static inline int repo_hold_lock_file_for_update_timeout(struct repository *r,
+ struct lock_file *lk,
+ const char *path,
+ int flags,
+ long timeout_ms)
+{
+ return repo_hold_lock_file_for_update_timeout_mode(r, lk, path, flags,
+ timeout_ms, 0666);
+}
+
/*
* Attempt to create a lockfile for the file at `path` and return a
* file descriptor for writing to it, or -1 on error. The flags
@@ -208,6 +223,13 @@ static inline int hold_lock_file_for_update(
return hold_lock_file_for_update_timeout(lk, path, flags, 0);
}
+static inline int repo_hold_lock_file_for_update(struct repository *r,
+ struct lock_file *lk,
+ const char *path, int flags)
+{
+ return repo_hold_lock_file_for_update_timeout(r, lk, path, flags, 0);
+}
+
static inline int hold_lock_file_for_update_mode(
struct lock_file *lk, const char *path,
int flags, int mode)
@@ -215,6 +237,15 @@ static inline int hold_lock_file_for_update_mode(
return hold_lock_file_for_update_timeout_mode(lk, path, flags, 0, mode);
}
+static inline int repo_hold_lock_file_for_update_mode(struct repository *r,
+ struct lock_file *lk,
+ const char *path,
+ int flags, int mode)
+{
+ return repo_hold_lock_file_for_update_timeout_mode(r, lk, path, flags,
+ 0, mode);
+}
+
/*
* Return a nonzero value iff `lk` is currently locked.
*/
--
2.55.0
^ permalink raw reply related
* [PATCH 4/5] tempfile: stop using the_repository
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
In-Reply-To: <20260714175956.54601-1-l.s.r@web.de>
Remove the compatibility wrappers create_tempfile_mode() and
create_tempfile() that have become unused.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
tempfile.c | 7 -------
tempfile.h | 7 -------
2 files changed, 14 deletions(-)
diff --git a/tempfile.c b/tempfile.c
index 3132eb4371..dc9ca4e645 100644
--- a/tempfile.c
+++ b/tempfile.c
@@ -42,8 +42,6 @@
* file created by its parent.
*/
-#define USE_THE_REPOSITORY_VARIABLE
-
#include "git-compat-util.h"
#include "abspath.h"
#include "path.h"
@@ -134,11 +132,6 @@ static void deactivate_tempfile(struct tempfile *tempfile)
}
/* Make sure errno contains a meaningful value on error */
-struct tempfile *create_tempfile_mode(const char *path, int mode)
-{
- return repo_create_tempfile_mode(the_repository, path, mode);
-}
-
struct tempfile *repo_create_tempfile_mode(struct repository *r,
const char *path, int mode)
{
diff --git a/tempfile.h b/tempfile.h
index 2d17e4dad3..f571f3c609 100644
--- a/tempfile.h
+++ b/tempfile.h
@@ -94,16 +94,9 @@ struct tempfile {
* `core.sharedRepository`, so it is not guaranteed to have the given
* mode.
*/
-struct tempfile *create_tempfile_mode(const char *path, int mode);
-
struct tempfile *repo_create_tempfile_mode(struct repository *r,
const char *path, int mode);
-static inline struct tempfile *create_tempfile(const char *path)
-{
- return create_tempfile_mode(path, 0666);
-}
-
static inline struct tempfile *repo_create_tempfile(struct repository *r,
const char *path)
{
--
2.55.0
^ permalink raw reply related
* [PATCH 5/5] use repo_hold_lock_file_for_update{,_mode,_timeout}() with custom repos
From: René Scharfe @ 2026-07-14 17:59 UTC (permalink / raw)
To: git
In-Reply-To: <20260714175956.54601-1-l.s.r@web.de>
Apply the config setting core.sharedRepository from the repository at
hand instead of from the_repository.
Signed-off-by: René Scharfe <l.s.r@web.de>
---
apply.c | 10 ++++++----
builtin/difftool.c | 2 +-
builtin/gc.c | 2 +-
builtin/history.c | 2 +-
builtin/sparse-checkout.c | 3 ++-
bundle.c | 4 ++--
commit-graph.c | 9 +++++----
config.c | 4 ++--
loose.c | 6 ++++--
midx-write.c | 7 ++++---
odb/source-files.c | 3 ++-
refs/files-backend.c | 10 ++++++----
refs/packed-backend.c | 7 +++----
refs/packed-backend.h | 2 +-
repack-midx.c | 3 ++-
repository.c | 2 +-
rerere.c | 6 +++---
17 files changed, 46 insertions(+), 36 deletions(-)
diff --git a/apply.c b/apply.c
index 5e54453f79..ac1bfc7f85 100644
--- a/apply.c
+++ b/apply.c
@@ -4287,7 +4287,8 @@ static int build_fake_ancestor(struct apply_state *state, struct patch *list)
}
}
- hold_lock_file_for_update(&lock, state->fake_ancestor, LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(state->repo, &lock, state->fake_ancestor,
+ LOCK_DIE_ON_ERROR);
res = write_locked_index(&result, &lock, COMMIT_LOCK);
discard_index(&result);
@@ -4945,9 +4946,10 @@ static int apply_patch(struct apply_state *state,
state->update_index = (state->check_index || state->ita_only) && state->apply;
if (state->update_index && !is_lock_file_locked(&state->lock_file)) {
if (state->index_file)
- hold_lock_file_for_update(&state->lock_file,
- state->index_file,
- LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(state->repo,
+ &state->lock_file,
+ state->index_file,
+ LOCK_DIE_ON_ERROR);
else
repo_hold_locked_index(state->repo, &state->lock_file,
LOCK_DIE_ON_ERROR);
diff --git a/builtin/difftool.c b/builtin/difftool.c
index 26778f8515..99c01c92ef 100644
--- a/builtin/difftool.c
+++ b/builtin/difftool.c
@@ -636,7 +636,7 @@ static int run_dir_diff(struct repository *repo,
struct lock_file lock = LOCK_INIT;
strbuf_reset(&buf);
strbuf_addf(&buf, "%s/wtindex", tmpdir.buf);
- if (hold_lock_file_for_update(&lock, buf.buf, 0) < 0 ||
+ if (repo_hold_lock_file_for_update(repo, &lock, buf.buf, 0) < 0 ||
write_locked_index(&wtindex, &lock, COMMIT_LOCK)) {
ret = error("could not write %s", buf.buf);
goto finish;
diff --git a/builtin/gc.c b/builtin/gc.c
index d32af422af..7153a49aca 100644
--- a/builtin/gc.c
+++ b/builtin/gc.c
@@ -1790,7 +1790,7 @@ static int maintenance_run_tasks(struct maintenance_run_opts *opts,
struct repository *r = the_repository;
char *lock_path = xstrfmt("%s/maintenance", r->objects->sources->path);
- if (hold_lock_file_for_update(&lk, lock_path, LOCK_NO_DEREF) < 0) {
+ if (repo_hold_lock_file_for_update(r, &lk, lock_path, LOCK_NO_DEREF) < 0) {
/*
* Another maintenance command is running.
*
diff --git a/builtin/history.c b/builtin/history.c
index fd83de8265..7e5177bc0a 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -764,7 +764,7 @@ static int write_ondisk_index(struct repository *repo,
prime_cache_tree(repo, &index, tree);
- if (hold_lock_file_for_update(&lock, path, 0) < 0) {
+ if (repo_hold_lock_file_for_update(repo, &lock, path, 0) < 0) {
ret = error_errno(_("unable to acquire index lock"));
goto out;
}
diff --git a/builtin/sparse-checkout.c b/builtin/sparse-checkout.c
index 0863d0fb46..cb4a037b77 100644
--- a/builtin/sparse-checkout.c
+++ b/builtin/sparse-checkout.c
@@ -341,7 +341,8 @@ static int write_patterns_and_update(struct repository *repo,
if (safe_create_leading_directories(repo, sparse_filename))
die(_("failed to create directory for sparse-checkout file"));
- hold_lock_file_for_update(&lk, sparse_filename, LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(repo, &lk, sparse_filename,
+ LOCK_DIE_ON_ERROR);
result = update_working_directory(repo, pl);
if (result) {
diff --git a/bundle.c b/bundle.c
index fd2db2c837..b64716f252 100644
--- a/bundle.c
+++ b/bundle.c
@@ -519,8 +519,8 @@ int create_bundle(struct repository *r, const char *path,
if (bundle_to_stdout)
bundle_fd = 1;
else
- bundle_fd = hold_lock_file_for_update(&lock, path,
- LOCK_DIE_ON_ERROR);
+ bundle_fd = repo_hold_lock_file_for_update(r, &lock, path,
+ LOCK_DIE_ON_ERROR);
if (version == -1)
version = min_version;
diff --git a/commit-graph.c b/commit-graph.c
index c6d9c5c740..1b073b367a 100644
--- a/commit-graph.c
+++ b/commit-graph.c
@@ -2122,8 +2122,8 @@ static int write_commit_graph_file(struct write_commit_graph_context *ctx)
if (ctx->split) {
char *lock_name = get_commit_graph_chain_filename(ctx->odb_source);
- hold_lock_file_for_update_mode(&lk, lock_name,
- LOCK_DIE_ON_ERROR, 0444);
+ repo_hold_lock_file_for_update_mode(ctx->r, &lk, lock_name,
+ LOCK_DIE_ON_ERROR, 0444);
free(lock_name);
graph_layer = mks_tempfile_m(ctx->graph_name, 0444);
@@ -2141,8 +2141,9 @@ static int write_commit_graph_file(struct write_commit_graph_context *ctx)
f = hashfd(ctx->r->hash_algo,
get_tempfile_fd(graph_layer), get_tempfile_path(graph_layer));
} else {
- hold_lock_file_for_update_mode(&lk, ctx->graph_name,
- LOCK_DIE_ON_ERROR, 0444);
+ repo_hold_lock_file_for_update_mode(ctx->r, &lk,
+ ctx->graph_name,
+ LOCK_DIE_ON_ERROR, 0444);
f = hashfd(ctx->r->hash_algo,
get_lock_file_fd(&lk), get_lock_file_path(&lk));
}
diff --git a/config.c b/config.c
index 6a0de86e3a..d29425ab8e 100644
--- a/config.c
+++ b/config.c
@@ -3034,7 +3034,7 @@ int repo_config_set_multivar_in_file_gently(struct repository *r,
* The lock serves a purpose in addition to locking: the new
* contents of .git/config will be written into it.
*/
- fd = hold_lock_file_for_update(&lock, config_filename, 0);
+ fd = repo_hold_lock_file_for_update(r, &lock, config_filename, 0);
if (fd < 0) {
error_errno(_("could not lock config file %s"), config_filename);
ret = CONFIG_NO_LOCK;
@@ -3379,7 +3379,7 @@ static int repo_config_copy_or_rename_section_in_file(
if (!config_filename)
config_filename = filename_buf = repo_git_path(r, "config");
- out_fd = hold_lock_file_for_update(&lock, config_filename, 0);
+ out_fd = repo_hold_lock_file_for_update(r, &lock, config_filename, 0);
if (out_fd < 0) {
ret = error(_("could not lock config file %s"), config_filename);
goto out;
diff --git a/loose.c b/loose.c
index 0b626c1b85..a79cafd38a 100644
--- a/loose.c
+++ b/loose.c
@@ -138,7 +138,8 @@ int repo_write_loose_object_map(struct repository *repo)
return 0;
repo_common_path_replace(repo, &path, "objects/loose-object-idx");
- fd = hold_lock_file_for_update_timeout(&lock, path.buf, LOCK_DIE_ON_ERROR, -1);
+ fd = repo_hold_lock_file_for_update_timeout(repo, &lock, path.buf,
+ LOCK_DIE_ON_ERROR, -1);
iter = kh_begin(map);
if (write_in_full(fd, loose_object_header, strlen(loose_object_header)) < 0)
goto errout;
@@ -180,7 +181,8 @@ static int write_one_object(struct odb_source_loose *loose,
struct strbuf buf = STRBUF_INIT, path = STRBUF_INIT;
strbuf_addf(&path, "%s/loose-object-idx", loose->base.path);
- hold_lock_file_for_update_timeout(&lock, path.buf, LOCK_DIE_ON_ERROR, -1);
+ repo_hold_lock_file_for_update_timeout(loose->base.odb->repo, &lock,
+ path.buf, LOCK_DIE_ON_ERROR, -1);
fd = open(path.buf, O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd < 0)
diff --git a/midx-write.c b/midx-write.c
index 8c1837f6df..580724d21a 100644
--- a/midx-write.c
+++ b/midx-write.c
@@ -1627,8 +1627,8 @@ static int write_midx_internal(struct write_midx_opts *opts)
struct strbuf lock_name = STRBUF_INIT;
get_midx_chain_filename(opts->source, &lock_name);
- hold_lock_file_for_update(&lk, lock_name.buf,
- LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(r, &lk, lock_name.buf,
+ LOCK_DIE_ON_ERROR);
strbuf_release(&lock_name);
}
@@ -1647,7 +1647,8 @@ static int write_midx_internal(struct write_midx_opts *opts)
f = hashfd(r->hash_algo, get_tempfile_fd(incr),
get_tempfile_path(incr));
} else {
- hold_lock_file_for_update(&lk, midx_name.buf, LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(r, &lk, midx_name.buf,
+ LOCK_DIE_ON_ERROR);
f = hashfd(r->hash_algo, get_lock_file_fd(&lk),
get_lock_file_path(&lk));
}
diff --git a/odb/source-files.c b/odb/source-files.c
index 6c8e935c75..db83a9745c 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -218,7 +218,8 @@ static int odb_source_files_write_alternate(struct odb_source *source,
int found = 0;
int ret;
- hold_lock_file_for_update(&lock, path, LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(source->odb->repo, &lock, path,
+ LOCK_DIE_ON_ERROR);
out = fdopen_lock_file(&lock, "w");
if (!out) {
ret = error_errno(_("unable to fdopen alternates lockfile"));
diff --git a/refs/files-backend.c b/refs/files-backend.c
index 3df56c25c8..1953610c03 100644
--- a/refs/files-backend.c
+++ b/refs/files-backend.c
@@ -842,7 +842,7 @@ static enum ref_transaction_error lock_raw_ref(struct files_ref_store *refs,
goto error_return;
}
- if (hold_lock_file_for_update_timeout(
+ if (repo_hold_lock_file_for_update_timeout(refs->base.repo,
&lock->lk, ref_file.buf, LOCK_NO_DEREF,
get_files_ref_lock_timeout_ms(transaction->ref_store->repo)) < 0) {
int myerr = errno;
@@ -1250,8 +1250,8 @@ struct create_reflock_cb {
static int create_reflock(const char *path, void *cb)
{
struct create_reflock_cb *data = cb;
- return hold_lock_file_for_update_timeout(
- data->lk, path, LOCK_NO_DEREF,
+ return repo_hold_lock_file_for_update_timeout(
+ data->repo, data->lk, path, LOCK_NO_DEREF,
get_files_ref_lock_timeout_ms(data->repo)) < 0 ? -1 : 0;
}
@@ -3581,7 +3581,9 @@ static int files_reflog_expire(struct ref_store *ref_store,
* work we need, including cleaning up if the program
* exits unexpectedly.
*/
- if (hold_lock_file_for_update(&reflog_lock, log_file, 0) < 0) {
+ if (repo_hold_lock_file_for_update(ref_store->repo,
+ &reflog_lock, log_file,
+ 0) < 0) {
struct strbuf err = STRBUF_INIT;
unable_to_lock_message(log_file, errno, &err);
error("%s", err.buf);
diff --git a/refs/packed-backend.c b/refs/packed-backend.c
index 7e65d9580e..0cfef881be 100644
--- a/refs/packed-backend.c
+++ b/refs/packed-backend.c
@@ -1246,10 +1246,9 @@ int packed_refs_lock(struct ref_store *ref_store, int flags, struct strbuf *err)
* don't write new content to it, but rather to a separate
* tempfile.
*/
- if (hold_lock_file_for_update_timeout(
- &refs->lock,
- refs->path,
- flags, timeout_value) < 0) {
+ if (repo_hold_lock_file_for_update_timeout(ref_store->repo, &refs->lock,
+ refs->path, flags,
+ timeout_value) < 0) {
unable_to_lock_message(refs->path, errno, err);
return -1;
}
diff --git a/refs/packed-backend.h b/refs/packed-backend.h
index 1db48e801d..8a7b323825 100644
--- a/refs/packed-backend.h
+++ b/refs/packed-backend.h
@@ -21,7 +21,7 @@ struct ref_store *packed_ref_store_init(struct repository *repo,
/*
* Lock the packed-refs file for writing. Flags is passed to
- * hold_lock_file_for_update(). Return 0 on success. On errors, write
+ * repo_hold_lock_file_for_update(). Return 0 on success. On errors, write
* an error message to `err` and return a nonzero value.
*/
int packed_refs_lock(struct ref_store *ref_store, int flags, struct strbuf *err);
diff --git a/repack-midx.c b/repack-midx.c
index 7c7c3620e5..64c7f8d0f4 100644
--- a/repack-midx.c
+++ b/repack-midx.c
@@ -954,7 +954,8 @@ static int write_midx_incremental(struct repack_write_midx_opts *opts)
lock_name.buf))
die_errno(_("unable to create leading directories of %s"),
lock_name.buf);
- hold_lock_file_for_update(&lf, lock_name.buf, LOCK_DIE_ON_ERROR);
+ repo_hold_lock_file_for_update(opts->existing->repo, &lf, lock_name.buf,
+ LOCK_DIE_ON_ERROR);
if (!fdopen_lock_file(&lf, "w")) {
ret = error_errno(_("unable to open multi-pack-index chain file"));
diff --git a/repository.c b/repository.c
index 73d80bcffd..11fbc69781 100644
--- a/repository.c
+++ b/repository.c
@@ -472,5 +472,5 @@ int repo_hold_locked_index(struct repository *repo,
{
if (!repo->index_file)
BUG("the repo hasn't been setup");
- return hold_lock_file_for_update(lf, repo->index_file, flags);
+ return repo_hold_lock_file_for_update(repo, lf, repo->index_file, flags);
}
diff --git a/rerere.c b/rerere.c
index 8232542585..2d1e99ec11 100644
--- a/rerere.c
+++ b/rerere.c
@@ -911,9 +911,9 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)
if (flags & RERERE_READONLY)
fd = 0;
else
- fd = hold_lock_file_for_update(&write_lock,
- git_path_merge_rr(r),
- LOCK_DIE_ON_ERROR);
+ fd = repo_hold_lock_file_for_update(r, &write_lock,
+ git_path_merge_rr(r),
+ LOCK_DIE_ON_ERROR);
read_rr(r, merge_rr);
return fd;
}
--
2.55.0
^ permalink raw reply related
* Re: [PATCH 1/2] http: use unique tempfiles for packfile URI downloads
From: Junio C Hamano @ 2026-07-14 18:10 UTC (permalink / raw)
To: Jeff King
Cc: Ted Nyman, git, Taylor Blau, Patrick Steinhardt, Karthik Nayak,
brian m. carlson, Ævar Arnfjörð Bjarmason
In-Reply-To: <20260714052833.GA2516582@coredump.intra.peff.net>
Jeff King <peff@peff.net> writes:
> On Mon, Jul 13, 2026 at 06:58:24PM -0700, Ted Nyman wrote:
>
>> > Are there better ways for these processes to coordinate with each
>> > other? Instead of appending to the file, what if the second process
>> > uses a predictable temporary name (which we already use) to open a
>> > new file with O_CREAT | O_EXCL to avoid this redundant work?
>>
>> Using the existing pack-<hash>.pack.temp name with O_CREAT | O_EXCL
>> would prevent concurrent writes, but EEXIST alone would not
>> distinguish an in-progress download from one left by an earlier
>> failed or interrupted invocation. The existing .pack.temp name is not
>> covered by the tmp_* pruning path, so simply waiting for it to
>> disappear could leave a fetch stuck after a crash.
>
> A few thoughts:
>
> - Using O_EXCL makes this essentially a lockfile. So we could apply
> the logic used elsewhere for lockfiles, like auto-removing files
> with ancient mtimes. Or we could even go all-in with a pid check for
> liveness; most of Git's lockfiles don't do that, but at least one
> does (the background auto-gc lock).
>
> - If we're not already using a name which is auto-cleaned during
> maintenance, we probably ought to be. Leaving aside concurrency
> issues, nobody would ever clean up the on-disk cruft.
>
> But of course the original code here is intentionally _not_ using a
> name we'd clean up, because it wants to be able to resume an
> interrupted transfer. And you're explicitly breaking that for the
> packfile URI case.
>
> Is that a cost we're OK with paying? Fixing it opens up that same
> coordination can of worms. You have to tell the difference a
> concurrent writer and a previous dead one (whose work you can
> resume).
>
> It does feel weird that we'd do one thing for dumb-http and another
> for packfile URIs. Wouldn't they suffer from the same concurrency
> and resumption problems?
> ...
>
> If we're OK with killing the ability to resume, then yeah, I think it
> would make sense to start simple and un-break things. And then put a
> coordination layer on top later (or never if nobody cares enough).
I share that sentiment. I am not entirely convinced by Ted's
response, since a major goal of the packfile URI feature, as I
understand it, is to allow the use of resumable protocols for
large transfers. The proposed change deliberately closes the
door on resuming interrupted transfers, whether manually or,
with additional code in the future, automatically.
^ permalink raw reply
* [PATCH v19 0/7] branch: delete-merged
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
In-Reply-To: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>
Delete branches that have already been merged on upstream.
Changes in v19:
* Fix bug where dry-run would still remove config, added test coverage.
* Redesigned --delete-merged as a repeatable upstream selector with
optional positional patterns limiting deletion scope.
* Protect same-name upstream branches independently of push-default
configuration.
* Simplified flags handling where local caching became complicated when
mutating values.
* Clarified assertions in tests.
Changes in v18:
* Instead of keeping the whole chain of upstream branches, keep only the
ones an unmerged branch still needs. When a kept (merged) branch in turn
tracks a branch that is being deleted, clear its now-stale upstream
config.
* Rework spare_stacked_bases() to record the kept bases and, in a second
pass, clear the upstream of any whose own base is going away. Build the
to-delete list with strset_for_each_entry() instead of re-walking the
candidate array.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its
upstream, so --delete-merged no longer deletes a branch out from under
one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into
their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument
instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,
single strbuf_release after) instead of allocating and freeing it each
time
* Rewrite the --delete-merged tests as integration tests: branches that
land commits upstream, with deletion and the checked-out, upstream-gone,
and push-equals-upstream safety cases exercised together in one run and
output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up
by a setup_repo_for_delete_merged helper, and rename helpers off the old
pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of
prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily
final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of
warning.
* Initialized the delete_branches() flag locals where declared. Only force
stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f,
--forked coverage), renamed the misleading trunk fixture, and replaced
the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and
the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other
branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git
branch --prune-merged origin/main 'feature*') instead of repeating the
option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Changes in v11:
* The flags now take a branch, not a remote. --forked and --prune-merged
accept a literal upstream short name like origin/main or a wildmatch
pattern like origin/. The old --all-remotes flag is gone, since origin/
covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is
spared when these are equal. That is the trunk like case, such as local
main tracking and pushing to origin/main, where "fully merged to
upstream" cannot be told apart from "just pulled". Only branches that
push somewhere other than their upstream, typically fork based topics,
are candidates. The earlier /HEAD by name guard that the reviewer
rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its
upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream
first. If the upstream is gone, it checks against the remote's default
branch instead. If neither exists, the branch is refused (use --force to
delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override
that lets a caller substitute a different "what counts as merged"
reference (or skip the check). branch -d callers pass NULL and keep their
existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* 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.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch
--prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config
options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the
given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their
tip is reachable from the upstream tracking ref; --force skips that
safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across
every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running
topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention
--prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 50 ++++-
builtin/branch.c | 284 ++++++++++++++++++++++---
ref-filter.c | 70 ++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 352 +++++++++++++++++++++++++++++++
6 files changed, 742 insertions(+), 31 deletions(-)
base-commit: f60db8d575adb79761d363e026fb49bddf330c73
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v19
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v18:
1: 3e29ff17bd ! 1: 562648132d branch: add --forked filter for --list mode
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ (
+ cd forked &&
+ git remote add -f other ../forked-other &&
-+ git remote set-head origin one &&
+ git branch local-base &&
+ git branch --track local-one origin/one &&
+ git branch --track local-two origin/two &&
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ git checkout local-one &&
+ test_commit --no-tag local-one-work local-one.t &&
+ git checkout local-foreign &&
-+ test_commit --no-tag local-foreign-work local-foreign.t &&
-+ git checkout --detach
++ test_commit --no-tag local-foreign-work local-foreign.t
+ )
+'
+
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked composes with --no-merged' '
-+ test_when_finished "git -C forked checkout --detach" &&
-+ git -C forked checkout local-one &&
-+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked rejects unknown branch/pattern' '
-+ test_must_fail git -C forked branch --forked nope 2>err &&
-+ test_grep "not a valid branch or pattern" err
-+'
-+
-+test_expect_success '--forked requires a value' '
-+ test_must_fail git -C forked branch --forked 2>err &&
-+ test_grep "requires a value" err
-+'
-+
+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
+ git -C forked branch --forked origin --format="%(refname:short)" >actual &&
-+ echo local-one >expect &&
++ echo main >expect &&
+ test_cmp expect actual
+'
+
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ EOF
+ test_cmp expect actual
+'
++
++test_expect_success '--forked rejects unknown branch/pattern' '
++ test_must_fail git -C forked branch --forked nope 2>err &&
++ test_grep "not a valid branch or pattern" err
++'
++
++test_expect_success '--forked requires a value' '
++ test_must_fail git -C forked branch --forked 2>err &&
++ test_grep "requires a value" err
++'
+
test_done
2: cdd4fea4a7 ! 2: c7ebd9344c branch: convert delete_branches() to a flags argument
@@ Metadata
## Commit message ##
branch: convert delete_branches() to a flags argument
- delete_branches() and check_branch_commit() take a pair of int
- booleans (force and quiet) that the next commits would grow further.
- Replace them with a single "unsigned int flags" argument and an
- enum, splitting the bits back into named bool locals so the body
- keeps reading the same named values.
+ delete_branches() takes separate force and quiet parameters, while
+ check_branch_commit() takes force. The next commits would grow this
+ collection further. Replace them with a single unsigned flags argument
+ and an enum.
+
+ Test the FORCE and QUIET bits directly from flags at each use site so
+ that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
-+ bool force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
- if (!force && !rev) {
+- if (!force && !rev) {
++ if (!(flags & DELETE_BRANCH_FORCE) && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
+ return -1;
+ }
+- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
++ if (!(flags & DELETE_BRANCH_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, "
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
{
struct commit *head_rev = NULL;
struct object_id oid;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
- int i;
- int ret = 0;
- int remote_branch = 0;
-+ bool force;
-+ bool quiet = flags & DELETE_BRANCH_QUIET;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
remote_branch = 1;
allowed_interpret = INTERPRET_BRANCH_REMOTE;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
}
branch_name_pos = strcspn(fmt, "%");
-+ force = flags & DELETE_BRANCH_FORCE;
-+
- if (!force)
+- if (!force)
++ if (!(flags & DELETE_BRANCH_FORCE))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
next:
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ char *name = item->string;
+ if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+ char *refname = name + branch_name_pos;
+- if (!quiet)
++ if (!(flags & DELETE_BRANCH_QUIET))
+ printf(remote_branch
+ ? _("Deleted remote-tracking branch %s (was %s).\n")
+ : _("Deleted branch %s (was %s).\n"),
@@ builtin/branch.c: int cmd_branch(int argc,
if (delete) {
if (!argc)
3: a0fd5b4a6c ! 3: 0c4f3358e3 branch: let delete_branches skip unmerged branches on bulk refusal
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
- int kinds, unsigned int flags)
- {
- bool force = flags & DELETE_BRANCH_FORCE;
-+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- struct commit *rev = lookup_commit_reference(the_repository, oid);
- if (!force && !rev) {
- error(_("couldn't look up commit object for '%s'"), refname);
- return -1;
}
- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
+ if (!(flags & DELETE_BRANCH_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 (!skip_unmerged) {
++ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
return -1;
}
return 0;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- int remote_branch = 0;
- bool force;
- bool quiet = flags & DELETE_BRANCH_QUIET;
-+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
flags)) {
- ret = 1;
-+ if (!skip_unmerged)
++ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
+ ret = 1;
goto next;
}
4: a56d8fe93e ! 4: 64a202526a branch: prepare delete_branches for a bulk caller
@@ Metadata
## Commit message ##
branch: prepare delete_branches for a bulk caller
- Teach delete_branches() two new modes for the upcoming
- --delete-merged: one that asks only whether a branch is merged into
- its upstream, without falling back to HEAD when there is no
- upstream, and one that rehearses the deletions without removing any
- ref. Existing callers keep their current behavior.
+ Teach delete_branches() a new mode for the upcoming --delete-merged
+ caller that checks whether a branch is merged into its upstream without
+ falling back to HEAD when there is no upstream. Existing callers keep
+ their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: enum delete_branch_flags {
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- bool force;
- bool quiet = flags & DELETE_BRANCH_QUIET;
- bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
-+ bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
-
- force = flags & DELETE_BRANCH_FORCE;
+ }
+ branch_name_pos = strcspn(fmt, "%");
-- if (!force)
-+ if (!force && !no_head_fallback)
+- if (!(flags & DELETE_BRANCH_FORCE))
++ if (!(flags & DELETE_BRANCH_FORCE) &&
++ !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
5: a84c555d99 ! 5: a6caa5b397 branch: add --delete-merged <branch>
@@ Metadata
## Commit message ##
branch: add --delete-merged <branch>
- git branch --delete-merged <branch>...
+ git branch (--delete-merged <branch>)... [<pattern>...]
- deletes the local branches that "--forked <branch>" would list,
- keeping only those whose tip is reachable from their configured
- upstream. The work has already landed on the upstream they track,
- so the local copy is no longer needed.
+ deletes local branches matching the optional patterns when their
+ configured upstream matches one of the --delete-merged arguments and
+ their tip is reachable from that upstream. The work has already landed
+ on the upstream they track, so the local copy is no longer needed.
+
+ The option can be repeated to widen the upstream match. Keeping the
+ candidate patterns as positional arguments lets users bound the set of
+ local branches that may be deleted independently of the upstream
+ selection.
A branch is not deleted when:
* it is checked out in any worktree
- * its upstream remote-tracking branch no longer exists, since a
- missing upstream is not by itself a sign of integration
- * its push destination equals its upstream (<branch>@{push} is
- the same as <branch>@{upstream}), such as a local "main" that
- tracks and pushes to "origin/main". Right after a pull it just
- looks "fully merged", so it is kept. Only branches that push
- somewhere other than their upstream, typically topics in a fork
- workflow, are candidates.
+ * its configured upstream ref no longer exists, since a missing
+ upstream is not by itself a sign of integration
+ * pushing it by name to the remote configured by
+ branch.<name>.remote would update its upstream, as determined by
+ mapping the branch ref through that remote's fetch refspec. For
+ example, a local "main" that tracks "origin/main" is kept even when
+ remote.pushDefault names a fork. Right after a pull it merely looks
+ fully merged.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-+git branch --delete-merged <branch>...
++git branch (--delete-merged <branch>)... [<pattern>...]
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
-+`--delete-merged <branch>...`::
-+ Delete the local branches that `--forked` would list for the
-+ given _<branch>_ arguments, but only those whose tip is
-+ reachable from their configured upstream. In other words, the
-+ work on the branch has already landed on the upstream it
-+ tracks, so the local copy is no longer needed. Several
-+ _<branch>_ patterns may be given, e.g. `git branch
-+ --delete-merged origin/main 'feature*'`.
++`--delete-merged <branch>`::
++ Delete local branches whose configured upstream matches
++ _<branch>_, but only when their tip is reachable from that
++ upstream. In other words, the work on the branch has already
++ landed on the upstream it tracks, so the local copy is no longer
++ needed. The option can be repeated to widen the upstream match.
++ Optional _<pattern>_ arguments limit which local branches are
++ considered, e.g. `git branch --delete-merged 'origin/*'
++ 'topic-*'`.
++
+A branch is not deleted when:
++
+--
-+* its upstream remote-tracking branch no longer exists,
++* its configured upstream ref no longer exists,
+* it is checked out in any worktree, or
-+* its push destination (`<branch>@{push}`) equals its upstream
-+ (`<branch>@{upstream}`), so it cannot be distinguished from a
-+ branch that just looks "fully merged" right after a pull.
++* pushing it by name to the remote configured by
++ `branch.<name>.remote` would update its upstream, so it cannot be
++ distinguished from a branch that just looks "fully merged" right
++ after a pull.
+--
++
+A branch whose work has not yet been merged into its upstream is
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] --delete-merged <branch>..."),
++ N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),
NULL
};
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ strset_clear(&spared);
+}
+
-+static int delete_merged_branches(int argc, const char **argv,
-+ unsigned int flags)
++static int branch_pushes_to_upstream(struct branch *branch,
++ const char *upstream)
++{
++ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
++ char *tracking = NULL;
++ int ret = 0;
++
++ if (remote)
++ tracking = apply_refspecs(&remote->fetch, branch->refname);
++ if (tracking && !strcmp(tracking, upstream))
++ ret = 1;
++
++ free(tracking);
++ return ret;
++}
++
++static int delete_merged_branches(const struct strvec *upstreams,
++ const char **argv, unsigned int flags)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ struct strvec to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
-+ int i, ret = 0;
++ size_t i;
++ int ret = 0;
+
-+ if (!argc)
-+ die(_("--delete-merged requires at least one <branch>"));
-+
-+ for (i = 0; i < argc; i++)
-+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
-+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
++ for (i = 0; i < upstreams->nr; i++)
++ if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
++ die(_("'%s' is not a valid branch or pattern"),
++ upstreams->v[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
++ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
-+ for (i = 0; i < candidates.nr; i++) {
++ for (i = 0; i < (size_t)candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
-+ const char *upstream, *push;
++ const char *upstream;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ BUG("filter returned non-branch ref '%s'", full_name);
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
-+ push = branch_get_push(branch, NULL);
-+ if (!push || !strcmp(push, upstream))
++ if (branch_pushes_to_upstream(branch, upstream))
+ continue;
+ if (check_branch_commit(short_name, short_name,
+ &candidates.items[i]->objectname, NULL,
@@ builtin/branch.c: int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
-+ int delete_merged = 0;
++ struct strvec delete_merged = STRVEC_INIT;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
-+ OPT_BOOL(0, "delete-merged", &delete_merged,
-+ N_("delete local branches whose upstream matches <branch> and are merged")),
++ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
++ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
++ PARSE_OPT_NONEG, parse_opt_strvec),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !delete_merged &&
++ !show_current && !unset_upstream && !delete_merged.nr &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
-+ !!unset_upstream + !!delete_merged;
++ !!unset_upstream + !!delete_merged.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
-+ } else if (delete_merged) {
-+ ret = delete_merged_branches(argc, argv,
++ } else if (delete_merged.nr) {
++ ret = delete_merged_branches(&delete_merged, argv,
+ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
+@@ builtin/branch.c: int cmd_branch(int argc,
+ ret = 0;
+
+ out:
++ strvec_clear(&delete_merged);
+ string_list_clear(&sorting_options, 0);
+ return ret;
+ }
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
- test_cmp expect actual
+@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_grep "requires a value" err
'
+test_expect_success '--delete-merged: setup' '
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ cd repo &&
+ git remote add fork ../fork &&
+ git remote add other ../other &&
-+ git config remote.pushDefault fork &&
+ git config push.default current &&
+ git fetch other
+ )
+}
+
-+merged_branch () {
++create_merged_branch () {
+ (
+ cd repo &&
-+ git checkout -b "$1" "$2" &&
++ git checkout -b "$1" origin/next --track &&
+ git commit --allow-empty -m "$1 work" &&
-+ git push origin "$1:next" &&
-+ git fetch origin &&
-+ git branch --set-upstream-to="$2" "$1"
++ git push origin "$1:next"
+ )
+}
+
-+test_expect_success '--delete-merged deletes merged branches and spares the rest' '
-+ test_when_finished "rm -rf repo" &&
-+ setup_repo_for_delete_merged &&
-+ merged_branch merged origin/next &&
-+ (
-+ cd repo &&
-+ git checkout -b unmerged origin/next &&
-+ git commit --allow-empty -m "unmerged work" &&
-+ git branch --set-upstream-to=origin/next unmerged &&
-+ git checkout -b tracks-other other/main &&
-+ git branch --set-upstream-to=other/main tracks-other &&
-+ git checkout --detach
-+ ) &&
-+ sha=$(git -C repo rev-parse --short merged) &&
-+
-+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
-+
-+ echo "Deleted branch merged (was $sha)." >expect &&
-+ test_cmp expect actual &&
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ main
-+ tracks-other
-+ unmerged
-+ EOF
++check_branches () {
++ git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
++ cat >expect &&
+ test_cmp expect actual
-+'
++}
+
-+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps cloned main without a default push remote' '
+ setup_repo_for_delete_merged &&
-+ merged_branch on-next origin/next &&
-+ merged_branch checked-out origin/next &&
-+ merged_branch upstream-gone origin/next &&
+ (
+ cd repo &&
-+ git checkout -b mainline main &&
-+ git checkout -b on-local mainline &&
-+ git branch --set-upstream-to=mainline on-local &&
-+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
-+ git branch --set-upstream-to=origin/topic upstream-gone &&
-+ git update-ref -d refs/remotes/origin/topic &&
-+ git branch --set-upstream-to=origin/main main &&
-+ git config branch.main.pushRemote origin &&
-+ git checkout -b tracks-other other/main &&
-+ git branch --set-upstream-to=other/main tracks-other &&
-+ git checkout checked-out
-+ ) &&
++ git checkout --detach &&
+
-+ git -C repo branch --delete-merged origin/next mainline &&
++ git branch --delete-merged */* &&
+
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ checked-out
-+ main
-+ mainline
-+ tracks-other
-+ upstream-gone
-+ EOF
-+ test_cmp expect actual
++ check_branches <<-\EOF
++ main
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged requires at least one <branch>' '
-+ test_must_fail git -C forked branch --delete-merged 2>err &&
-+ test_grep "requires at least one <branch>" err
++test_expect_success '--delete-merged deletes only selected merged branches' '
++ setup_repo_for_delete_merged &&
++ create_merged_branch also-merged &&
++ create_merged_branch merged &&
++ (
++ cd repo &&
++ git checkout -b unmerged origin/next --track &&
++ git commit --allow-empty -m "unmerged work" &&
++ git checkout -b tracks-other other/main --track &&
++ sha=$(git rev-parse --short merged) &&
++
++ git branch --delete-merged origin/next merged >actual 2>&1 &&
++ echo "Deleted branch merged (was $sha)." >expect &&
++ test_cmp expect actual &&
++
++ check_branches <<-\EOF
++ also-merged
++ main
++ tracks-other
++ unmerged
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged keeps a branch that is an upstream' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps main despite a different default push remote' '
+ setup_repo_for_delete_merged &&
-+ merged_branch feature origin/next &&
++ create_merged_branch on-next &&
++ create_merged_branch checked-out &&
++ create_merged_branch upstream-gone &&
+ (
+ cd repo &&
-+ git checkout -b topic feature &&
-+ git commit --allow-empty -m "topic work" &&
-+ git branch --set-upstream-to=feature topic &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --dry-run --delete-merged origin/next >out &&
-+ test_grep ! "feature" out &&
-+
-+ git -C repo branch --delete-merged origin/next 2>err &&
-+
-+ test_must_be_empty err &&
-+ git -C repo rev-parse --verify refs/heads/feature &&
-+ git -C repo rev-parse --verify refs/heads/topic &&
-+ echo origin/next >expect &&
-+ git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
-+ test_cmp expect actual &&
-+ echo feature >expect &&
-+ git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
-+ test_cmp expect actual
++ git config remote.pushDefault fork &&
++ git checkout -b local-to-delete main --track &&
++ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
++ git branch --set-upstream-to=origin/topic upstream-gone &&
++ git update-ref -d refs/remotes/origin/topic &&
++ git checkout -b tracks-other other/main --track &&
++ git checkout checked-out &&
++
++ git branch --delete-merged origin/* \
++ --delete-merged main &&
++
++ check_branches <<-\EOF
++ checked-out
++ main
++ tracks-other
++ upstream-gone
++ EOF
++ )
+'
+
-+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ setup_repo_for_delete_merged &&
++ create_merged_branch feature &&
+ (
+ cd repo &&
-+ git branch b3 origin/next &&
-+ git branch --set-upstream-to=origin/next b3 &&
-+ git branch b2 origin/next &&
-+ git branch --set-upstream-to=b3 b2 &&
-+ git checkout -b b1 b2 &&
-+ git commit --allow-empty -m "b1 work" &&
-+ git branch --set-upstream-to=b2 b1 &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --delete-merged origin/next &&
++ git checkout -b topic feature --track &&
++ git commit --allow-empty -m "topic work" &&
+
-+ git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
-+ cat >expect <<-\EOF &&
-+ b1
-+ b2
-+ b3
-+ main
-+ EOF
-+ test_cmp expect actual
++ git branch --delete-merged origin/next 2>err &&
++
++ test_must_be_empty err &&
++ check_branches <<-\EOF &&
++ feature
++ main
++ topic
++ EOF
++
++ git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.feature.remote origin
++ branch.feature.merge refs/heads/next
++ branch.topic.remote .
++ branch.topic.merge refs/heads/feature
++ EOF
++ test_cmp expect actual
++ )
+'
+
-+test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
-+ test_when_finished "rm -rf repo" &&
++test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
-+ git branch lower origin/next &&
-+ git branch --set-upstream-to=origin/next lower &&
-+ git branch mid origin/next &&
-+ git branch --set-upstream-to=lower mid &&
-+ git checkout -b tip mid &&
++ git config remote.pushDefault fork &&
++ git branch lower origin/next --track &&
++ git branch mid lower --track &&
++ git checkout -b tip mid --track &&
+ git commit --allow-empty -m "tip work" &&
-+ git branch --set-upstream-to=mid tip &&
-+ git checkout --detach
-+ ) &&
-+
-+ git -C repo branch --delete-merged origin/next lower &&
+
-+ test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
-+ git -C repo rev-parse --verify refs/heads/mid &&
-+ test_must_fail git -C repo rev-parse mid@{upstream} &&
-+ echo mid >expect &&
-+ git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
-+ test_cmp expect actual
++ git branch --delete-merged origin/next \
++ --delete-merged lower &&
++
++ check_branches <<-\EOF &&
++ main
++ mid
++ tip
++ EOF
++
++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.tip.remote .
++ branch.tip.merge refs/heads/mid
++ EOF
++ test_cmp expect actual
++ )
+'
+
++test_expect_success '--delete-merged requires a value' '
++ test_must_fail git -C forked branch --delete-merged 2>err &&
++ test_grep "requires a value" err
++'
test_done
6: d52d717b70 ! 6: 734d27c908 branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/git-branch.adoc
@@ Documentation/git-branch.adoc: A branch is not deleted when:
+
--
- * its upstream remote-tracking branch no longer exists,
+ * its configured upstream ref no longer exists,
-* it is checked out in any worktree, or
+* it is checked out in any worktree,
- * its push destination (`<branch>@{push}`) equals its upstream
- (`<branch>@{upstream}`), so it cannot be distinguished from a
-- branch that just looks "fully merged" right after a pull.
-+ branch that just looks "fully merged" right after a pull, or
+ * pushing it by name to the remote configured by
+ `branch.<name>.remote` would update its upstream, so it cannot be
+ distinguished from a branch that just looks "fully merged" right
+- after a pull.
++ after a pull, or
+* `branch.<name>.deleteMerged` is set to `false`.
--
+
A branch whose work has not yet been merged into its upstream is
## builtin/branch.c ##
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
struct ref_array candidates = { 0 };
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
-+ bool quiet = flags & DELETE_BRANCH_QUIET;
- int i, ret = 0;
-
- if (!argc)
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ size_t i;
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
const char *short_name;
struct branch *branch;
- const char *upstream, *push;
+ const char *upstream;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ strbuf_addf(&key, "branch.%s.deletemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
-+ if (!quiet)
++ if (!(flags & DELETE_BRANCH_QUIET))
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
strset_add(&deletable, short_name);
}
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
DELETE_BRANCH_NO_HEAD_FALLBACK |
flags);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
ref_array_clear(&candidates);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
- test_cmp expect actual
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires a value" err
'
-
++
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
-+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
-+ merged_branch deleted origin/next &&
-+ merged_branch kept origin/next &&
-+ git -C repo config branch.kept.deleteMerged false &&
-+ git -C repo checkout --detach &&
++ create_merged_branch deleted &&
++ create_merged_branch kept &&
++ (
++ cd repo &&
++ git config branch.kept.deleteMerged false &&
++ git checkout --detach &&
+
-+ git -C repo branch --delete-merged origin/next 2>err &&
++ git branch --delete-merged origin/next 2>err &&
+
-+ test_grep "Skipping .kept." err &&
-+ test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
-+ git -C repo rev-parse --verify refs/heads/kept
++ test_grep "Skipping .kept." err &&
++ check_branches <<-\EOF
++ kept
++ main
++ EOF
++ )
+'
+
+test_expect_success "branch -d still deletes a deleteMerged=false branch" '
-+ test_when_finished "rm -rf repo" &&
+ setup_repo_for_delete_merged &&
-+ merged_branch kept origin/next &&
-+ git -C repo config branch.kept.deleteMerged false &&
-+ git -C repo checkout --detach &&
++ create_merged_branch kept &&
++ (
++ cd repo &&
++ git config branch.kept.deleteMerged false &&
++ git checkout --detach &&
++
++ git branch -d kept &&
+
-+ git -C repo branch -d kept &&
-+ test_must_fail git -C repo rev-parse --verify refs/heads/kept
++ check_branches <<-\EOF
++ main
++ EOF
++ )
+'
+
test_done
7: 8d0323f4b3 ! 7: 7aa9d5db14 branch: add --dry-run for --delete-merged
@@ Metadata
## Commit message ##
branch: add --dry-run for --delete-merged
- With --dry-run, --delete-merged prints the local branches it would
- delete, one "Would delete branch <name>" line each, and exits
- without touching any ref. The same filtering applies, so the output
- is exactly the set that the real run would delete.
+ "git branch --dry-run --delete-merged ..." prints one line per ref that
+ would be deleted without modifying refs or branch configuration.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch --delete-merged <branch>...
-+git branch [--dry-run] --delete-merged <branch>...
+-git branch (--delete-merged <branch>)... [<pattern>...]
++git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]
DESCRIPTION
-----------
@@ builtin/branch.c: enum delete_branch_flags {
};
static int check_branch_commit(const char *branchname, const char *refname,
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- bool quiet = flags & DELETE_BRANCH_QUIET;
- bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
- bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
-+ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
- struct strbuf bname = STRBUF_INIT;
- enum interpret_branch_kind allowed_interpret;
- struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
free(target);
}
- if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
-+ if (!dry_run &&
++ if (!(flags & DELETE_BRANCH_DRY_RUN) &&
+ refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
ret = 1;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
char *describe_ref = item->util;
char *name = item->string;
- if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
-+ if (dry_run) {
-+ if (!quiet)
++ if (flags & DELETE_BRANCH_DRY_RUN) {
++ if (!(flags & DELETE_BRANCH_QUIET))
+ printf(remote_branch
+ ? _("Would delete remote-tracking branch %s (was %s).\n")
+ : _("Would delete branch %s (was %s).\n"),
+ name + branch_name_pos, describe_ref);
+ } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
- if (!quiet)
+ if (!(flags & DELETE_BRANCH_QUIET))
printf(remote_branch
+@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)
+ * base is itself merged, so when its own upstream is also going away
+ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+-static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
++static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,
++ unsigned int flags)
+ {
+ struct strset spared = STRSET_INIT;
+ struct spare_data data = { .deletable = deletable, .spared = &spared };
+@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable
+
+ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
+
+- strset_for_each_entry(&spared, &iter, entry) {
+- struct branch *branch = branch_get(entry->key);
+- const char *upstream = branch_get_upstream(branch, NULL);
+- const char *up_short;
++ if (!(flags & DELETE_BRANCH_DRY_RUN)) {
++ strset_for_each_entry(&spared, &iter, entry) {
++ struct branch *branch = branch_get(entry->key);
++ const char *upstream = branch_get_upstream(branch, NULL);
++ const char *up_short;
+
+- if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+- !strset_contains(deletable, up_short))
+- continue;
++ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
++ !strset_contains(deletable, up_short))
++ continue;
+
+- strbuf_reset(&key);
+- strbuf_addf(&key, "branch.%s.merge", branch->name);
+- repo_config_set_gently(the_repository, key.buf, NULL);
+- strbuf_reset(&key);
+- strbuf_addf(&key, "branch.%s.remote", branch->name);
+- repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.merge", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ strbuf_reset(&key);
++ strbuf_addf(&key, "branch.%s.remote", branch->name);
++ repo_config_set_gently(the_repository, key.buf, NULL);
++ }
+ }
+
+ strbuf_release(&key);
+@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
+ strset_add(&deletable, short_name);
+ }
+
+- spare_stacked_bases(refs, &deletable);
++ spare_stacked_bases(refs, &deletable, flags);
+
+ strset_for_each_entry(&deletable, &iter, entry)
+ strvec_push(&to_delete, entry->key);
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int delete_merged = 0;
+ struct strvec delete_merged = STRVEC_INIT;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
- N_("edit the description for the branch")),
- OPT_BOOL(0, "delete-merged", &delete_merged,
- N_("delete local branches whose upstream matches <branch> and are merged")),
+ OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
+ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
+ OPT_BOOL(0, "dry-run", &dry_run,
+ N_("with --delete-merged, only print which branches would be deleted")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !delete_merged)
++ if (dry_run && !delete_merged.nr)
+ die(_("--dry-run requires --delete-merged"));
+
if (recurse_submodules_explicit) {
@@ builtin/branch.c: int cmd_branch(int argc,
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: int cmd_branch(int argc,
goto out;
- } else if (delete_merged) {
- ret = delete_merged_branches(argc, argv,
+ } else if (delete_merged.nr) {
+ ret = delete_merged_branches(&delete_merged, argv,
- quiet ? DELETE_BRANCH_QUIET : 0);
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
@@ builtin/branch.c: int cmd_branch(int argc,
print_current_branch_name();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes merged branches and spares the rest
- ) &&
- sha=$(git -C repo rev-parse --short merged) &&
-
-- git -C repo branch --delete-merged origin/next >actual 2>&1 &&
-+ git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&
-+ echo "Would delete branch merged (was $sha)." >expect &&
-+ test_cmp expect actual &&
-+ git -C repo rev-parse --verify refs/heads/merged &&
-
-+ git -C repo branch --delete-merged origin/next >actual 2>&1 &&
- echo "Deleted branch merged (was $sha)." >expect &&
- test_cmp expect actual &&
- git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '
+ git checkout -b tracks-other other/main --track &&
+ sha=$(git rev-parse --short merged) &&
+
++ git branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&
++ echo "Would delete branch merged (was $sha)." >expect &&
++ test_cmp expect actual &&
++ git rev-parse --verify refs/heads/merged &&
++
++ check_branches <<-\EOF &&
++ also-merged
++ main
++ merged
++ tracks-other
++ unmerged
++ EOF
++
+ git branch --delete-merged origin/next merged >actual 2>&1 &&
+ echo "Deleted branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ git checkout -b topic feature --track &&
+ git commit --allow-empty -m "topic work" &&
+
+- git branch --delete-merged origin/next 2>err &&
++ git branch --dry-run --delete-merged origin/next >out &&
++ test_grep ! "feature" out &&
+
++ git branch --delete-merged origin/next 2>err &&
+ test_must_be_empty err &&
++
+ check_branches <<-\EOF &&
+ feature
+ main
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra
+ git checkout -b tip mid --track &&
+ git commit --allow-empty -m "tip work" &&
+
++ git branch --dry-run --delete-merged origin/next \
++ --delete-merged lower &&
++
++ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
++ cat >expect <<-\EOF &&
++ branch.mid.remote .
++ branch.mid.merge refs/heads/lower
++ branch.tip.remote .
++ branch.tip.merge refs/heads/mid
++ EOF
++ test_cmp expect actual &&
++
+ git branch --delete-merged origin/next \
+ --delete-merged lower &&
+
@@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
- test_must_fail git -C repo rev-parse --verify refs/heads/kept
+ )
'
+test_expect_success '--dry-run without --delete-merged is rejected' '
--
gitgitgadget
^ permalink raw reply
* [PATCH v19 1/7] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 12 +++-
builtin/branch.c | 18 +++++-
ref-filter.c | 70 ++++++++++++++++++++
ref-filter.h | 10 +++
t/t3200-branch.sh | 117 ++++++++++++++++++++++++++++++++++
5 files changed, 224 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..b0d66a6deb 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are
reachable from the named commit) will be listed. With `--no-merged` only
branches not merged into the named commit will be listed. If the _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`), a remote name like `origin` for the branch its
+ `origin/HEAD` points at, or a shell-style glob (e.g.
+ `'origin/*'`). The option can be repeated to widen the
+ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..c159f45b4c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -30,7 +30,7 @@
#include "commit-reach.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
}
+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
+{
+ struct ref_filter *filter = opt->value;
+
+ BUG_ON_OPT_NEG(unset);
+ if (ref_filter_forked_add(filter, arg) < 0)
+ die(_("'%s' is not a valid branch or pattern"), arg);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ int cmd_branch(int argc,
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
+ OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
+ N_("print only branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_forked),
OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
OPT_REF_SORT(&sorting_options),
OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -815,7 +828,8 @@ int cmd_branch(int argc,
list = 1;
if (filter.with_commit || filter.no_commit ||
- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+ filter.reachable_from || filter.unreachable_from ||
+ filter.points_at.nr || filter.forked.nr)
list = 1;
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index 284796c49b..cbdac1a19a 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
return match_pattern(filter->exclude.v, refname, filter->ignore_case);
}
+static const char *short_upstream_name(const char *full_ref)
+{
+ const char *short_name = full_ref;
+ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+ skip_prefix(short_name, "refs/remotes/", &short_name));
+ return short_name;
+}
+
+/*
+ * Match the configured upstream of a branch against the registered
+ * --forked patterns. Exact patterns are compared against the full
+ * upstream refname so they are unambiguous; glob patterns are matched
+ * against the abbreviated upstream so that a glob such as origin/...
+ * works as typed.
+ */
+static int filter_forked_match(struct ref_filter *filter, const char *refname)
+{
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+ int i;
+
+ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
+
+ for (i = 0; i < filter->forked.nr; i++) {
+ const char *pattern = filter->forked.v[i];
+ if (has_glob_specials(pattern)) {
+ if (!wildmatch(pattern, short_upstream_name(upstream),
+ WM_PATHNAME))
+ return 1;
+ } else if (!strcmp(pattern, upstream)) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
+ struct object_id oid;
+ char *full_ref = NULL;
+
+ if (has_glob_specials(arg)) {
+ strvec_push(&filter->forked, arg);
+ return 0;
+ }
+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
+ starts_with(full_ref, "refs/remotes/"))) {
+ strvec_push(&filter->forked, full_ref);
+ free(full_ref);
+ return 0;
+ }
+ free(full_ref);
+ return -1;
+}
+
/*
* We need to seek to the reference right after a given marker but excluding any
* matching references. So we seek to the lexicographically next reference.
@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
return NULL;
+ if (filter->forked.nr && !filter_forked_match(filter, ref->name))
+ return NULL;
+
/*
* A merge filter is applied on refs pointing to commits. Hence
* obtain the commit using the 'oid' available and discard all
@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)
void ref_filter_clear(struct ref_filter *filter)
{
strvec_clear(&filter->exclude);
+ strvec_clear(&filter->forked);
oid_array_clear(&filter->points_at);
commit_list_free(filter->with_commit);
commit_list_free(filter->no_commit);
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..9361296e2a 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -67,6 +67,7 @@ struct ref_filter {
const char **name_patterns;
const char *start_after;
struct strvec exclude;
+ struct strvec forked;
struct oid_array points_at;
struct commit_list *with_commit;
struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {
#define REF_FILTER_INIT { \
.points_at = OID_ARRAY_INIT, \
.exclude = STRVEC_INIT, \
+ .forked = STRVEC_INIT, \
}
#define REF_FORMAT_INIT { \
.use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
struct ref_sorting *ref_sorting_options(struct string_list *);
/* Function to parse --merged and --no-merged options */
int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
+/*
+ * Register a --forked <branch> pattern on the filter. The argument is
+ * either a ref, which is resolved to its full refname, or a shell-style
+ * glob. Branches are kept only when their configured upstream matches
+ * one of the registered patterns. Returns -1 if the argument is not a
+ * valid ref or pattern.
+ */
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
/* Get the current HEAD's description */
char *get_head_description(void);
/* Set up translated strings in the output. */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..0c5a4ca62b 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,121 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ (
+ cd forked-upstream &&
+ test_commit base &&
+ git branch one base &&
+ git branch two base
+ ) &&
+
+ test_create_repo forked-other &&
+ (
+ cd forked-other &&
+ test_commit other-base &&
+ git branch foreign other-base
+ ) &&
+
+ git clone forked-upstream forked &&
+ (
+ cd forked &&
+ git remote add -f other ../forked-other &&
+ git branch local-base &&
+ git branch --track local-one origin/one &&
+ git branch --track local-two origin/two &&
+ git branch --track local-foreign other/foreign &&
+ git branch --track local-onbase local-base &&
+
+ git checkout local-one &&
+ test_commit --no-tag local-one-work local-one.t &&
+ git checkout local-foreign &&
+ test_commit --no-tag local-foreign-work local-foreign.t
+ )
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-onbase >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-onbase
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
+ git -C forked branch --forked origin --format="%(refname:short)" >actual &&
+ echo main >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked narrows a <pattern> argument' '
+ git -C forked branch --forked "origin/*" "local-*" \
+ --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v19 2/7] branch: convert delete_branches() to a flags argument
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() takes separate force and quiet parameters, while
check_branch_commit() takes force. The next commits would grow this
collection further. Replace them with a single unsigned flags argument
and an enum.
Test the FORCE and QUIET bits directly from flags at each use site so
that mutating or forwarding flags cannot leave cached values stale.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 40 ++++++++++++++++++++++++----------------
1 file changed, 24 insertions(+), 16 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..e905a13a95 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,
return merged;
}
+enum delete_branch_flags {
+ DELETE_BRANCH_FORCE = (1 << 0),
+ DELETE_BRANCH_QUIET = (1 << 1),
+};
+
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
struct commit *rev = lookup_commit_reference(the_repository, oid);
- if (!force && !rev) {
+ if (!(flags & DELETE_BRANCH_FORCE) && !rev) {
error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
- if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
+ if (!(flags & DELETE_BRANCH_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, "
@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
-static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
+static int delete_branches(int argc, const char **argv, int kinds,
+ unsigned int flags)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
remote_branch = 1;
allowed_interpret = INTERPRET_BRANCH_REMOTE;
- force = 1;
+ flags |= DELETE_BRANCH_FORCE;
break;
case FILTER_REFS_BRANCHES:
fmt = "refs/heads/%s";
@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
- if (!force)
+ if (!(flags & DELETE_BRANCH_FORCE))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
char *target = NULL;
- int flags = 0;
+ int ref_flags = 0;
copy_branchname(&bname, argv[i], allowed_interpret);
free(name);
@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
RESOLVE_REF_READING
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
- &oid, &flags);
+ &oid, &ref_flags);
if (!target) {
if (remote_branch) {
error(_("remote-tracking branch '%s' not found"), bname.buf);
@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
| RESOLVE_REF_NO_RECURSE
| RESOLVE_REF_ALLOW_BAD_NAME,
&oid,
- &flags);
+ &ref_flags);
FREE_AND_NULL(virtual_name);
if (virtual_target)
@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
continue;
}
- if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+ if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
+ flags)) {
ret = 1;
goto next;
}
item = string_list_append(&refs_to_delete, name);
- item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
- : (flags & REF_ISSYMREF) ? target
+ item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
+ : (ref_flags & REF_ISSYMREF) ? target
: repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
next:
@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
char *name = item->string;
if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
char *refname = name + branch_name_pos;
- if (!quiet)
+ if (!(flags & DELETE_BRANCH_QUIET))
printf(remote_branch
? _("Deleted remote-tracking branch %s (was %s).\n")
: _("Deleted branch %s (was %s).\n"),
@@ -872,7 +878,9 @@ int cmd_branch(int argc,
if (delete) {
if (!argc)
die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
+ ret = delete_branches(argc, argv, filter.kind,
+ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply related
* [PATCH v19 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a skip-unmerged mode to delete_branches() and check_branch_commit()
so a bulk caller can silently skip branches that are not fully merged
and carry on, rather than erroring with the "use 'git branch -D'"
advice that the plain "git branch -d" path emits. Existing callers are
unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index e905a13a95..568ae817d6 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,
enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
+ DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,
}
if (!(flags & DELETE_BRANCH_FORCE) &&
!branch_merged(kinds, branchname, rev, head_rev)) {
- error(_("the branch '%s' is not fully merged"), branchname);
- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
- _("If you are sure you want to delete it, "
- "run 'git branch -D %s'"), branchname);
+ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {
+ error(_("the branch '%s' is not fully merged"),
+ branchname);
+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+ _("If you are sure you want to delete it, "
+ "run 'git branch -D %s'"), branchname);
+ }
return -1;
}
return 0;
@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
flags)) {
- ret = 1;
+ if (!(flags & DELETE_BRANCH_SKIP_UNMERGED))
+ ret = 1;
goto next;
}
--
gitgitgadget
^ permalink raw reply related
* [PATCH v19 4/7] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() a new mode for the upcoming --delete-merged
caller that checks whether a branch is merged into its upstream without
falling back to HEAD when there is no upstream. Existing callers keep
their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 568ae817d6..23b2b7107c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,
* upstream, if any, otherwise with HEAD", we should just
* return the result of the repo_in_merge_bases() above without
* any of the following code, but during the transition period,
- * a gentle reminder is in order.
+ * a gentle reminder is in order. Callers that opt out of the
+ * HEAD fallback by passing head_rev=NULL are not interested in
+ * the reminder either: they have already established that the
+ * branch has an upstream, so HEAD is irrelevant to the decision.
*/
- if (head_rev != reference_rev) {
- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+ if (head_rev && head_rev != reference_rev) {
+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
if (expect < 0)
exit(128);
if (expect == merged)
@@ -193,6 +196,7 @@ enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
DELETE_BRANCH_SKIP_UNMERGED = (1 << 2),
+ DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
- if (!(flags & DELETE_BRANCH_FORCE))
+ if (!(flags & DELETE_BRANCH_FORCE) &&
+ !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
--
gitgitgadget
^ permalink raw reply related
* [PATCH v19 5/7] branch: add --delete-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch (--delete-merged <branch>)... [<pattern>...]
deletes local branches matching the optional patterns when their
configured upstream matches one of the --delete-merged arguments and
their tip is reachable from that upstream. The work has already landed
on the upstream they track, so the local copy is no longer needed.
The option can be repeated to widen the upstream match. Keeping the
candidate patterns as positional arguments lets users bound the set of
local branches that may be deleted independently of the upstream
selection.
A branch is not deleted when:
* it is checked out in any worktree
* its configured upstream ref no longer exists, since a missing
upstream is not by itself a sign of integration
* pushing it by name to the remote configured by
branch.<name>.remote would update its upstream, as determined by
mapping the branch ref through that remote's fetch refspec. For
example, a local "main" that tracks "origin/main" is kept even when
remote.pushDefault names a fork. Right after a pull it merely looks
fully merged.
A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.
A branch that another, surviving branch tracks as its upstream is
also kept, so a branch is never deleted out from under one stacked
on top of it. Such a kept branch is itself merged, so when its own
upstream is being deleted, clear its now-stale upstream config.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 31 +++++++
builtin/branch.c | 164 ++++++++++++++++++++++++++++++++-
t/t3200-branch.sh | 166 ++++++++++++++++++++++++++++++++++
3 files changed, 359 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index b0d66a6deb..cee3904cfd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch (--delete-merged <branch>)... [<pattern>...]
DESCRIPTION
-----------
@@ -201,6 +202,36 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--delete-merged <branch>`::
+ Delete local branches whose configured upstream matches
+ _<branch>_, but only when their tip is reachable from that
+ upstream. In other words, the work on the branch has already
+ landed on the upstream it tracks, so the local copy is no longer
+ needed. The option can be repeated to widen the upstream match.
+ Optional _<pattern>_ arguments limit which local branches are
+ considered, e.g. `git branch --delete-merged 'origin/*'
+ 'topic-*'`.
++
+A branch is not deleted when:
++
+--
+* its configured upstream ref no longer exists,
+* it is checked out in any worktree, or
+* pushing it by name to the remote configured by
+ `branch.<name>.remote` would update its upstream, so it cannot be
+ distinguished from a branch that just looks "fully merged" right
+ after a pull.
+--
++
+A branch whose work has not yet been merged into its upstream is
+silently skipped. Delete it with `git branch -D` if you want to
+remove it anyway.
++
+A branch that another, surviving branch tracks as its upstream is
+kept, so a branch is never deleted out from under one stacked on top
+of it. If that kept branch in turn tracks a branch that is being
+deleted, its now-stale upstream configuration is cleared.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 23b2b7107c..8ce8840fa7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -21,6 +21,7 @@
#include "branch.h"
#include "path.h"
#include "string-list.h"
+#include "strmap.h"
#include "column.h"
#include "utf8.h"
#include "ref-filter.h"
@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] (--delete-merged <branch>)... [<pattern>...]"),
NULL
};
@@ -699,6 +701,154 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+struct spare_data {
+ struct strset *deletable;
+ struct strset *spared;
+};
+
+/*
+ * A surviving branch stacked on a deletion candidate would lose its
+ * upstream, so drop that candidate from the delete set and remember it
+ * in "spared" so its own upstream can be tidied up afterwards.
+ */
+static int spare_stacked_base(const struct reference *ref, void *cb_data)
+{
+ struct spare_data *data = cb_data;
+ struct branch *branch;
+ const char *upstream, *up_short;
+
+ if (strset_contains(data->deletable, ref->name))
+ return 0;
+ branch = branch_get(ref->name);
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+ !strset_contains(data->deletable, up_short))
+ return 0;
+
+ strset_remove(data->deletable, up_short);
+ strset_add(data->spared, up_short);
+ return 0;
+}
+
+/*
+ * Keep any branch that a surviving branch tracks as its upstream, so we
+ * never delete a branch out from under one stacked on top of it. Such a
+ * base is itself merged, so when its own upstream is also going away
+ * (no surviving branch tracks it), clear the base's now-stale upstream.
+ */
+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
+{
+ struct strset spared = STRSET_INIT;
+ struct spare_data data = { .deletable = deletable, .spared = &spared };
+ struct strbuf key = STRBUF_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+
+ refs_for_each_branch_ref(refs, spare_stacked_base, &data);
+
+ strset_for_each_entry(&spared, &iter, entry) {
+ struct branch *branch = branch_get(entry->key);
+ const char *upstream = branch_get_upstream(branch, NULL);
+ const char *up_short;
+
+ if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
+ !strset_contains(deletable, up_short))
+ continue;
+
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.merge", branch->name);
+ repo_config_set_gently(the_repository, key.buf, NULL);
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.remote", branch->name);
+ repo_config_set_gently(the_repository, key.buf, NULL);
+ }
+
+ strbuf_release(&key);
+ strset_clear(&spared);
+}
+
+static int branch_pushes_to_upstream(struct branch *branch,
+ const char *upstream)
+{
+ struct remote *remote = remote_get(remote_for_branch(branch, NULL));
+ char *tracking = NULL;
+ int ret = 0;
+
+ if (remote)
+ tracking = apply_refspecs(&remote->fetch, branch->refname);
+ if (tracking && !strcmp(tracking, upstream))
+ ret = 1;
+
+ free(tracking);
+ return ret;
+}
+
+static int delete_merged_branches(const struct strvec *upstreams,
+ const char **argv, unsigned int flags)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates = { 0 };
+ struct strset deletable = STRSET_INIT;
+ struct strvec to_delete = STRVEC_INIT;
+ struct hashmap_iter iter;
+ struct strmap_entry *entry;
+ size_t i;
+ int ret = 0;
+
+ for (i = 0; i < upstreams->nr; i++)
+ if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"),
+ upstreams->v[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ filter.name_patterns = argv;
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < (size_t)candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ BUG("filter returned non-branch ref '%s'", full_name);
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
+ if (branch_pushes_to_upstream(branch, upstream))
+ continue;
+ if (check_branch_commit(short_name, short_name,
+ &candidates.items[i]->objectname, NULL,
+ FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
+ continue;
+
+ strset_add(&deletable, short_name);
+ }
+
+ spare_stacked_bases(refs, &deletable);
+
+ strset_for_each_entry(&deletable, &iter, entry)
+ strvec_push(&to_delete, entry->key);
+
+ if (to_delete.nr)
+ ret = delete_branches(to_delete.nr, to_delete.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
+ strvec_clear(&to_delete);
+ strset_clear(&deletable);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -740,6 +890,7 @@ int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ struct strvec delete_merged = STRVEC_INIT;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -793,6 +944,9 @@ 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_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
+ N_("delete merged branches whose upstream matches <branch> (repeatable)"),
+ PARSE_OPT_NONEG, parse_opt_strvec),
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")),
@@ -840,7 +994,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !delete_merged.nr &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -850,7 +1005,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!delete_merged.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -892,6 +1047,10 @@ int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (delete_merged.nr) {
+ ret = delete_merged_branches(&delete_merged, argv,
+ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
@@ -1051,6 +1210,7 @@ int cmd_branch(int argc,
ret = 0;
out:
+ strvec_clear(&delete_merged);
string_list_clear(&sorting_options, 0);
return ret;
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 0c5a4ca62b..fa8a60c9e7 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1834,4 +1834,170 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--delete-merged: setup' '
+ git init -b main upstream &&
+ (
+ cd upstream &&
+ test_commit base &&
+ git checkout -b next &&
+ test_commit next-work &&
+ git checkout main
+ ) &&
+ git init -b main other &&
+ test_commit -C other other-base &&
+ git init -b main fork
+'
+
+setup_repo_for_delete_merged () {
+ rm -rf repo &&
+ git clone upstream repo &&
+ (
+ cd repo &&
+ git remote add fork ../fork &&
+ git remote add other ../other &&
+ git config push.default current &&
+ git fetch other
+ )
+}
+
+create_merged_branch () {
+ (
+ cd repo &&
+ git checkout -b "$1" origin/next --track &&
+ git commit --allow-empty -m "$1 work" &&
+ git push origin "$1:next"
+ )
+}
+
+check_branches () {
+ git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success '--delete-merged keeps cloned main without a default push remote' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git checkout --detach &&
+
+ git branch --delete-merged */* &&
+
+ check_branches <<-\EOF
+ main
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged deletes only selected merged branches' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch also-merged &&
+ create_merged_branch merged &&
+ (
+ cd repo &&
+ git checkout -b unmerged origin/next --track &&
+ git commit --allow-empty -m "unmerged work" &&
+ git checkout -b tracks-other other/main --track &&
+ sha=$(git rev-parse --short merged) &&
+
+ git branch --delete-merged origin/next merged >actual 2>&1 &&
+ echo "Deleted branch merged (was $sha)." >expect &&
+ test_cmp expect actual &&
+
+ check_branches <<-\EOF
+ also-merged
+ main
+ tracks-other
+ unmerged
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged keeps main despite a different default push remote' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch on-next &&
+ create_merged_branch checked-out &&
+ create_merged_branch upstream-gone &&
+ (
+ cd repo &&
+ git config remote.pushDefault fork &&
+ git checkout -b local-to-delete main --track &&
+ git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
+ git branch --set-upstream-to=origin/topic upstream-gone &&
+ git update-ref -d refs/remotes/origin/topic &&
+ git checkout -b tracks-other other/main --track &&
+ git checkout checked-out &&
+
+ git branch --delete-merged origin/* \
+ --delete-merged main &&
+
+ check_branches <<-\EOF
+ checked-out
+ main
+ tracks-other
+ upstream-gone
+ EOF
+ )
+'
+
+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch feature &&
+ (
+ cd repo &&
+ git checkout -b topic feature --track &&
+ git commit --allow-empty -m "topic work" &&
+
+ git branch --delete-merged origin/next 2>err &&
+
+ test_must_be_empty err &&
+ check_branches <<-\EOF &&
+ feature
+ main
+ topic
+ EOF
+
+ git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
+ branch.feature.remote origin
+ branch.feature.merge refs/heads/next
+ branch.topic.remote .
+ branch.topic.merge refs/heads/feature
+ EOF
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '
+ setup_repo_for_delete_merged &&
+ (
+ cd repo &&
+ git config remote.pushDefault fork &&
+ git branch lower origin/next --track &&
+ git branch mid lower --track &&
+ git checkout -b tip mid --track &&
+ git commit --allow-empty -m "tip work" &&
+
+ git branch --delete-merged origin/next \
+ --delete-merged lower &&
+
+ check_branches <<-\EOF &&
+ main
+ mid
+ tip
+ EOF
+
+ git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
+ cat >expect <<-\EOF &&
+ branch.tip.remote .
+ branch.tip.merge refs/heads/mid
+ EOF
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--delete-merged requires a value' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires a value" err
+'
test_done
--
gitgitgadget
^ permalink raw reply related
* [PATCH v19 6/7] branch: add branch.<name>.deleteMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-07-14 18:24 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/config/branch.adoc | 7 +++++++
Documentation/git-branch.adoc | 5 +++--
builtin/branch.c | 14 +++++++++++++
t/t3200-branch.sh | 36 ++++++++++++++++++++++++++++++++
4 files changed, 60 insertions(+), 2 deletions(-)
diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..d8483acb4f 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>.deleteMerged`::
+ If set to `false`, branch _<name>_ is exempt from
+ `git branch --delete-merged`. Useful for a topic branch you
+ intend to develop further after an initial round has been
+ merged upstream. Defaults to true. Explicit deletion via
+ `git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index cee3904cfd..ffb39811ab 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -216,11 +216,12 @@ A branch is not deleted when:
+
--
* its configured upstream ref no longer exists,
-* it is checked out in any worktree, or
+* it is checked out in any worktree,
* pushing it by name to the remote configured by
`branch.<name>.remote` would update its upstream, so it cannot be
distinguished from a branch that just looks "fully merged" right
- after a pull.
+ after a pull, or
+* `branch.<name>.deleteMerged` is set to `false`.
--
+
A branch whose work has not yet been merged into its upstream is
diff --git a/builtin/branch.c b/builtin/branch.c
index 8ce8840fa7..61f414b3c7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -791,6 +791,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
struct ref_array candidates = { 0 };
struct strset deletable = STRSET_INIT;
struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
struct hashmap_iter iter;
struct strmap_entry *entry;
size_t i;
@@ -810,6 +811,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
const char *short_name;
struct branch *branch;
const char *upstream;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
@@ -827,6 +829,17 @@ static int delete_merged_branches(const struct strvec *upstreams,
FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
+ strbuf_reset(&key);
+ strbuf_addf(&key, "branch.%s.deletemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!(flags & DELETE_BRANCH_QUIET))
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
+ continue;
+ }
+
strset_add(&deletable, short_name);
}
@@ -842,6 +855,7 @@ static int delete_merged_branches(const struct strvec *upstreams,
DELETE_BRANCH_NO_HEAD_FALLBACK |
flags);
+ strbuf_release(&key);
strvec_clear(&to_delete);
strset_clear(&deletable);
ref_array_clear(&candidates);
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index fa8a60c9e7..54292bfbdf 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2000,4 +2000,40 @@ test_expect_success '--delete-merged requires a value' '
test_must_fail git -C forked branch --delete-merged 2>err &&
test_grep "requires a value" err
'
+
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
+ setup_repo_for_delete_merged &&
+ create_merged_branch deleted &&
+ create_merged_branch kept &&
+ (
+ cd repo &&
+ git config branch.kept.deleteMerged false &&
+ git checkout --detach &&
+
+ git branch --delete-merged origin/next 2>err &&
+
+ test_grep "Skipping .kept." err &&
+ check_branches <<-\EOF
+ kept
+ main
+ EOF
+ )
+'
+
+test_expect_success "branch -d still deletes a deleteMerged=false branch" '
+ setup_repo_for_delete_merged &&
+ create_merged_branch kept &&
+ (
+ cd repo &&
+ git config branch.kept.deleteMerged false &&
+ git checkout --detach &&
+
+ git branch -d kept &&
+
+ check_branches <<-\EOF
+ main
+ EOF
+ )
+'
+
test_done
--
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