Merge branch 'pz/fetch-submodule-errors-config' into seen

The 'git fetch' command has been updated to allow configuring how
submodule fetch errors are handled.  A new configuration variable
'fetch.submoduleErrors' and a corresponding '--submodule-errors'
command-line option have been introduced, allowing users to make
submodule fetch errors non-fatal (warn instead of fail).
Additionally, a premature failure during recursive submodule fetches
has been fixed by deferring the error until the OID-based retry phase
also fails.

* pz/fetch-submodule-errors-config:
  fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal
  submodule: fix premature failure in recursive submodule fetch
seen
Junio C Hamano 2026-07-24 17:44:29 -07:00
commit 7954206668
6 changed files with 301 additions and 17 deletions

View File

@ -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

View File

@ -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`::

View File

@ -111,8 +111,30 @@ struct fetch_config {
int recurse_submodules;
int parallel;
int submodule_fetch_jobs;
int submodule_errors;
};

/* really private - use accessors below to parse and format */
static const char *submodule_error_name[] = {
[SUBMODULE_ERRORS_FAIL] = "fail",
[SUBMODULE_ERRORS_WARN] = "warn",
};

static const char *submodule_error(unsigned num)
{
if (ARRAY_SIZE(submodule_error_name) <= num)
BUG("invalid submodule errors mode %u", num);
return submodule_error_name[num];
}

static int parse_submodule_error(const char *name)
{
for (unsigned num = 0; num < ARRAY_SIZE(submodule_error_name); num++)
if (!strcmp(submodule_error_name[num], name))
return num;
return -1;
}

static int git_fetch_config(const char *k, const char *v,
const struct config_context *ctx, void *cb)
{
@ -153,6 +175,19 @@ static int git_fetch_config(const char *k, const char *v,
return 0;
}

if (!strcmp(k, "fetch.submoduleerrors")) {
int mode;

if (!v)
return config_error_nonbool(k);
mode = parse_submodule_error(v);
if (mode < 0)
die(_("invalid value for '%s': '%s'"),
"fetch.submoduleErrors", v);
fetch_config->submodule_errors = mode;
return 0;
}

if (!strcmp(k, "fetch.parallel")) {
fetch_config->parallel = git_config_int(k, v, ctx->kvi);
if (fetch_config->parallel < 0)
@ -2243,6 +2278,9 @@ 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 != -1)
strvec_pushf(argv, "--submodule-errors=%s",
submodule_error(config->submodule_errors));
if (tags == TAGS_SET)
strvec_push(argv, "--tags");
else if (tags == TAGS_UNSET)
@ -2502,6 +2540,23 @@ 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;
int mode;

if (unset) {
*v = SUBMODULE_ERRORS_FAIL;
return 0;
}
mode = parse_submodule_error(arg);
if (mode < 0)
die(_("invalid value for '%s': '%s'"), "--submodule-errors", arg);
*v = mode;
return 0;
}

int cmd_fetch(int argc,
const char **argv,
const char *prefix,
@ -2516,6 +2571,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;
@ -2530,6 +2586,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;
@ -2566,6 +2623,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")),
@ -2657,6 +2718,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:
@ -2860,11 +2924,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);
@ -2874,7 +2941,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);
}

View File

@ -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, \
@ -1562,6 +1563,14 @@ 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)
{
if (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)
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 +1608,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 +1762,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 +1788,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 +1801,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 +1820,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 +1834,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;
@ -1827,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 = {
@ -1847,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;

View File

@ -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);


View File

@ -1262,4 +1262,165 @@ test_expect_success "fetch --all with --no-recurse-submodules only fetches super
test_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 '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 &&
test_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 &&
test_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 &&
test_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 &&
test_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 &&
test_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
# 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 &&
test_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 &&
test_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 &&
test_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 &&
test_grep "Could not access submodule" err &&
test_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&
test_grep "Could not access submodule" err
'

test_done