{"thread":{"id":"65970","subject":"[PATCH v3 0/2] fetch: make submodule fetch errors configurable","startedAt":"2026-07-10T12:27:01Z","lastAt":"2026-09-23T13:21:31Z","messageCount":20,"participants":["Paulius Zaleckas","Junio C Hamano"],"isPatch":true,"patchVersion":3,"patchTotal":2},"messages":[{"id":"547731","messageId":"20260710122655.3066377-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":null,"subject":"[PATCH v3 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-10T12:26:51Z","receivedAt":"2026-07-10T12:27:01Z","isPatch":true,"body":"When fetching with --recurse-submodules, git currently exits with a\nnon-zero status if any submodule references an OID that is not reachable\nfrom the submodule's remote.  This situation arises naturally when an\nupstream branch is still in preparation (e.g. a topic branch in a merge\nwindow): the local branch does not depend on the missing commit, so a\nhard failure is unnecessarily disruptive.\n\nPatch 1 fixes a pre-existing NEEDSWORK in submodule.c where a phase-1\nfetch failure was recorded immediately, even when a phase-2 OID-based\nretry was about to be scheduled.  After this fix the existing fatal\nbehaviour is preserved but the logic is now structured so that errors\nare only recorded when the phase-2 retry actually fails, or when there\nis no phase-2 retry to fall back on.\n\nPatch 2 introduces fetch.submoduleErrors (fail|warn) and\n--submodule-errors=(fail|warn) to let users opt into non-fatal\nbehaviour.  The default remains fail for full backwards compatibility.\n\nChanges in v3:\n- Report a phase-1 failure also when the gitlink commits are already\n  present locally, instead of silently succeeding\n- Route \"Could not access submodule\" through record_fetch_error() so it\n  shows up in the error summary and honors the warn mode\n- Forward --submodule-errors to child fetches so it takes effect for\n  fetch --all/--multiple and nested submodule recursion\n- Add tests for all of the above\n- Documentation: don't imply git pull takes --submodule-errors, minor\n  wording and placement fixes\n\nChanges in v2:\n- Fix option synopsis to use (fail|warn) instead of <fail|warn>\n- Add --submodule-errors documentation to Documentation/fetch-options.adoc\n\nPaulius Zaleckas (2):\n  submodule: fix premature failure in recursive submodule fetch\n  fetch: add fetch.submoduleErrors to make submodule fetch errors\n    non-fatal\n\n Documentation/config/fetch.adoc  |  14 +++\n Documentation/fetch-options.adoc |   8 ++\n builtin/fetch.c                  |  41 ++++++++-\n submodule.c                      |  58 ++++++++----\n submodule.h                      |   7 +-\n t/t5526-fetch-submodules.sh      | 148 +++++++++++++++++++++++++++++++\n 6 files changed, 259 insertions(+), 17 deletions(-)\n\n-- \n2.54.0\n\n"},{"id":"547732","messageId":"20260710122655.3066377-2-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260710122655.3066377-1-paulius.zaleckas@gmail.com","subject":"[PATCH v3 1/2] submodule: fix premature failure in recursive submodule fetch","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-10T12:26:52Z","receivedAt":"2026-07-10T12:27:04Z","isPatch":true,"body":"When git fetch --recurse-submodules encounters a failure fetching a\nsubmodule's refs (phase 1), it immediately marks the overall operation\nas failed, even though a subsequent OID-based fetch (phase 2) is about\nto be attempted for any missing commits.  If phase 2 succeeds, the\noverall result should be success, but the prematurely set failure flag\nmakes it look like an error.\n\nRestructure fetch_finish() so that a phase-1 failure does not record an\nerror immediately.  Instead, the decision is deferred:\n\n - If missing commits trigger a phase-2 (OID-based) retry and that\n   retry succeeds, no error is recorded.\n - If the phase-2 retry also fails, the error is recorded then.\n - If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON)\n   and is not in the changed list, a phase-1 failure is recorded right\n   away since there is no OID retry to fall back on.\n - If phase 1 fails but all required commits are already present\n   locally, there is no retry to defer to; the failure is still\n   recorded, since the fetch itself went wrong (e.g. a transport\n   error) even though the wanted commits happen to be available.\n\nThis resolves the NEEDSWORK comment added by bd5e567dc7 (submodule:\nexplain first attempt failure clearly, 2019-03-13).\n\nExtract the common error-recording logic into a helper\nrecord_fetch_error() and use it in fetch_start_failure() and for the\n\"Could not access submodule\" error in get_fetch_task_from_index() as\nwell; the latter now also lists the submodule in the final error\nsummary.\n\nAdd a test ensuring a failed submodule fetch is still reported when\nthe gitlinked commits happen to be present locally.\n\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n submodule.c                 | 52 +++++++++++++++++++--------\n t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++\n 2 files changed, 110 insertions(+), 14 deletions(-)\n\ndiff --git a/submodule.c b/submodule.c\nindex fd91201a92..8bcef68a42 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n \treturn NULL;\n }\n \n+static void record_fetch_error(struct submodule_parallel_fetch *spf,\n+\t\t\t       const char *name)\n+{\n+\tspf->result = 1;\n+\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n+}\n+\n static struct fetch_task *\n get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t  struct strbuf *err)\n@@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t\t\t\t\tce->name);\n \t\t\tif (S_ISGITLINK(ce->ce_mode) &&\n \t\t\t    !is_empty_dir(empty_submodule_path.buf)) {\n-\t\t\t\tspf->result = 1;\n+\t\t\t\trecord_fetch_error(spf, ce->name);\n \t\t\t\tstrbuf_addf(err,\n \t\t\t\t\t    _(\"Could not access submodule '%s'\\n\"),\n \t\t\t\t\t    ce->name);\n@@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED,\n \tstruct submodule_parallel_fetch *spf = cb;\n \tstruct fetch_task *task = task_cb;\n \n-\tspf->result = 1;\n+\trecord_fetch_error(spf, task->sub->name);\n \n \tfetch_task_free(task);\n \treturn 0;\n@@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \tif (!task || !task->sub)\n \t\tBUG(\"callback cookie bogus\");\n \n-\tif (retvalue) {\n+\tif (retvalue && task->commits) {\n \t\t/*\n-\t\t * NEEDSWORK: This indicates that the overall fetch\n-\t\t * failed, even though there may be a subsequent fetch\n-\t\t * by commit hash that might work. It may be a good\n-\t\t * idea to not indicate failure in this case, and only\n-\t\t * indicate failure if the subsequent fetch fails.\n+\t\t * This is the second pass (OID-based fetch) and it failed.\n+\t\t * The commits are genuinely unavailable from the remote.\n \t\t */\n-\t\tspf->result = 1;\n-\n-\t\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\",\n-\t\t\t    task->sub->name);\n+\t\trecord_fetch_error(spf, task->sub->name);\n \t}\n \n \t/* Is this the second time we process this submodule? */\n@@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\tgoto out;\n \n \tit = string_list_lookup(&spf->changed_submodule_names, task->sub->name);\n-\tif (!it)\n-\t\t/* Could be an unchanged submodule, not contained in the list */\n+\tif (!it) {\n+\t\t/*\n+\t\t * This submodule is not in the changed list (e.g. it was\n+\t\t * fetched because RECURSE_SUBMODULES_ON fetches all populated\n+\t\t * submodules). A phase 1 failure here has no OID-based retry\n+\t\t * to fall back on, so it is a genuine error.\n+\t\t */\n+\t\tif (retvalue)\n+\t\t\trecord_fetch_error(spf, task->sub->name);\n \t\tgoto out;\n+\t}\n \n \tcs_data = it->util;\n \toid_array_filter(&cs_data->new_commits,\n@@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \n \t/* Are there commits we want, but do not exist? */\n \tif (cs_data->new_commits.nr) {\n+\t\t/*\n+\t\t * Schedule an OID-based phase 2 fetch to retrieve the missing\n+\t\t * commits directly. Defer any error from phase 1: if phase 2\n+\t\t * succeeds, the overall operation should still succeed.\n+\t\t */\n \t\ttask->commits = &cs_data->new_commits;\n \t\tALLOC_GROW(spf->oid_fetch_tasks,\n \t\t\t   spf->oid_fetch_tasks_nr + 1,\n@@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\treturn 0;\n \t}\n \n+\t/*\n+\t * All required commits are already present locally (they were either\n+\t * fetched by phase 1 or existed beforehand), so there is no phase 2\n+\t * retry to defer to. If phase 1 failed, the fetch itself went wrong\n+\t * (e.g. a transport error) and must still be reported, even though\n+\t * the gitlinked commits are available.\n+\t */\n+\tif (retvalue)\n+\t\trecord_fetch_error(spf, task->sub->name);\n+\n out:\n \tfetch_task_free(task);\n \treturn 0;\ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 1242ee9185..188c674c89 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1262,4 +1262,76 @@ test_expect_success \"fetch --all with --no-recurse-submodules only fetches super\n \t! grep \"Fetching submodule\" fetch-log\n '\n \n+# Create an isolated environment for submodule fetch error tests.\n+#\n+# Sets up sub_bare (the submodule upstream), super_bare (the superproject\n+# upstream), super_work (a working clone of super_bare with an initialized\n+# submodule), and clone (a clone of super_bare with an initialized submodule\n+# at a reachable commit). The caller can then create an unreachable commit\n+# and push the superproject to put the clone one commit behind a state it\n+# cannot fully fetch.\n+#\n+# Usage: create_err_env <envdir>\n+create_err_env () {\n+\tlocal envdir=\"$1\" &&\n+\tmkdir \"$envdir\" &&\n+\n+\tgit init --bare \"$envdir/sub_bare\" &&\n+\tgit clone \"$envdir/sub_bare\" \"$envdir/sub_work\" &&\n+\ttest_commit -C \"$envdir/sub_work\" \"${envdir}_base\" &&\n+\tgit -C \"$envdir/sub_work\" push &&\n+\n+\tgit init --bare \"$envdir/super_bare\" &&\n+\tgit clone \"$envdir/super_bare\" \"$envdir/super_work\" &&\n+\tgit -C \"$envdir/super_work\" submodule add \\\n+\t\t\"$pwd/$envdir/sub_bare\" sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"add submodule\" &&\n+\tgit -C \"$envdir/super_work\" push &&\n+\n+\tgit clone \"$envdir/super_bare\" \"$envdir/clone\" &&\n+\tgit -C \"$envdir/clone\" submodule update --init\n+}\n+\n+# Push a commit to <envdir>/super_bare that records a submodule SHA that is\n+# present locally in super_work/sub but NOT pushed to sub_bare, making the\n+# submodule commit unreachable from clone's sub remote.\n+push_unreachable_commit () {\n+\tlocal envdir=\"$1\" &&\n+\tgit -C \"$envdir/super_work/sub\" commit --allow-empty -m \"unreachable\" &&\n+\tgit -C \"$envdir/super_work\" add sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"point sub to unreachable commit\" &&\n+\tgit -C \"$envdir/super_work\" push\n+}\n+\n+test_expect_success 'setup for submodule fetch error tests' '\n+\tgit config --global protocol.file.allow always\n+'\n+\n+test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n+\t# Create the same commit (unreferenced, via commit-tree with fixed\n+\t# dates) in both super_work/sub and clone/sub, point the gitlink at\n+\t# it, and break clone/sub'\\''s remote. The commit exists in clone/sub\n+\t# but is unreachable, so the submodule stays in the changed list; the\n+\t# fetch failure must still be reported even though there is nothing\n+\t# left to fetch by commit hash.\n+\ttest_when_finished \"rm -fr env_phase1\" &&\n+\tcreate_err_env env_phase1 &&\n+\tcommit=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t git -C env_phase1/super_work/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\tpresent=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t  GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t  git -C env_phase1/clone/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\ttest \"$commit\" = \"$present\" &&\n+\tgit -C env_phase1/super_work/sub checkout \"$commit\" &&\n+\tgit -C env_phase1/super_work add sub &&\n+\tgit -C env_phase1/super_work commit -m \"gitlink to locally-present commit\" &&\n+\tgit -C env_phase1/super_work push &&\n+\tgit -C env_phase1/clone/sub remote set-url origin \"$pwd/env_phase1/missing\" &&\n+\ttest_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"547733","messageId":"20260710122655.3066377-3-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260710122655.3066377-1-paulius.zaleckas@gmail.com","subject":"[PATCH v3 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-10T12:26:53Z","receivedAt":"2026-07-10T12:27:08Z","isPatch":true,"body":"When fetching with --recurse-submodules, a submodule commit that is not\nyet reachable from any of the submodule's remote refs causes the entire\nfetch to fail.  This is overly strict when the missing commit belongs to\nan upstream branch that is still being prepared (e.g. an in-progress\nmerge topic): the local branch does not need that commit, so there is no\nreason to treat its absence as fatal.\n\nAdd a new config key fetch.submoduleErrors (values: fail/warn) and a\ncorresponding --submodule-errors=(fail|warn) command-line option that\ncontrol this behaviour.  The default remains fail (existing behaviour);\nsetting the value to warn causes submodule fetch failures to be reported\non stderr without affecting the overall exit status of git fetch / git\npull.\n\nForward the option to child fetches in add_options_to_argv() so that it\nalso takes effect for `git fetch --all` / `--multiple` (where per-remote\nchild processes handle the submodule recursion themselves) and for\nnested submodule recursion.\n\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n Documentation/config/fetch.adoc  | 14 ++++++\n Documentation/fetch-options.adoc |  8 ++++\n builtin/fetch.c                  | 41 ++++++++++++++++-\n submodule.c                      |  8 +++-\n submodule.h                      |  7 ++-\n t/t5526-fetch-submodules.sh      | 76 ++++++++++++++++++++++++++++++++\n 6 files changed, 150 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\nindex 04ac90912d..5c9c942a70 100644\n--- a/Documentation/config/fetch.adoc\n+++ b/Documentation/config/fetch.adoc\n@@ -10,6 +10,20 @@\n \treference.\n \tDefaults to `on-demand`, or to the value of `submodule.recurse` if set.\n \n+`fetch.submoduleErrors`::\n+\tControls how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` or `git pull`\n+\tto exit with a non-zero status. When set to `warn`, submodule fetch\n+\terrors are reported to standard error but do not affect the exit\n+\tstatus of the command. This is useful when working in repositories\n+\twhere some branches reference submodule commits that are not yet\n+\tavailable on the submodule remote, but those commits are not needed\n+\tfor the currently checked-out branch.\n++\n+The value of this option can be overridden by the `--submodule-errors`\n+option of linkgit:git-fetch[1].\n+\n `fetch.fsckObjects`::\n \tIf it is set to true, git-fetch-pack will check all fetched\n \tobjects. See `transfer.fsckObjects` for what's\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 035f780e58..78525f6848 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -294,6 +294,14 @@ ifndef::git-pull[]\n `--no-recurse-submodules`::\n \tDisable recursive fetching of submodules (this has the same effect as\n \tusing the `--recurse-submodules=no` option).\n+\n+`--submodule-errors=(fail|warn)`::\n+\tControl how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` to exit with a\n+\tnon-zero status. When set to `warn`, submodule fetch errors are reported\n+\tto standard error but do not affect the exit status of the command. Can\n+\talso be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].\n endif::git-pull[]\n \n `--set-upstream`::\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f4..40daaf5cc7 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -110,6 +110,7 @@ struct fetch_config {\n \tint recurse_submodules;\n \tint parallel;\n \tint submodule_fetch_jobs;\n+\tint submodule_errors;\n };\n \n static int git_fetch_config(const char *k, const char *v,\n@@ -152,6 +153,19 @@ static int git_fetch_config(const char *k, const char *v,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\t\telse if (!strcasecmp(v, \"fail\"))\n+\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;\n+\t\telse if (!strcasecmp(v, \"warn\"))\n+\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;\n+\t\telse\n+\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n+\t\t\t    \"fetch.submoduleErrors\", v);\n+\t\treturn 0;\n+\t}\n+\n \tif (!strcmp(k, \"fetch.parallel\")) {\n \t\tfetch_config->parallel = git_config_int(k, v, ctx->kvi);\n \t\tif (fetch_config->parallel < 0)\n@@ -2205,6 +2219,8 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n+\tif (config->submodule_errors == SUBMODULE_ERRORS_WARN)\n+\t\tstrvec_push(argv, \"--submodule-errors=warn\");\n \tif (tags == TAGS_SET)\n \t\tstrvec_push(argv, \"--tags\");\n \telse if (tags == TAGS_UNSET)\n@@ -2464,6 +2480,19 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \treturn exit_code;\n }\n \n+static int option_parse_submodule_errors(const struct option *opt,\n+\t\t\t\t\t  const char *arg, int unset)\n+{\n+\tint *v = opt->value;\n+\tif (unset || !strcasecmp(arg, \"fail\"))\n+\t\t*v = SUBMODULE_ERRORS_FAIL;\n+\telse if (!strcasecmp(arg, \"warn\"))\n+\t\t*v = SUBMODULE_ERRORS_WARN;\n+\telse\n+\t\tdie(_(\"invalid value for '%s': '%s'\"), \"--submodule-errors\", arg);\n+\treturn 0;\n+}\n+\n int cmd_fetch(int argc,\n \t      const char **argv,\n \t      const char *prefix,\n@@ -2477,6 +2506,7 @@ int cmd_fetch(int argc,\n \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n \t\t.parallel = 1,\n \t\t.submodule_fetch_jobs = -1,\n+\t\t.submodule_errors = SUBMODULE_ERRORS_FAIL,\n \t};\n \tconst char *submodule_prefix = \"\";\n \tconst char *bundle_uri;\n@@ -2491,6 +2521,7 @@ int cmd_fetch(int argc,\n \tint max_jobs = -1;\n \tint recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;\n \tint recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;\n+\tint submodule_errors_cli = -1; /* -1: not set on command line */\n \tint fetch_write_commit_graph = -1;\n \tint stdin_refspecs = 0;\n \tint negotiate_only = 0;\n@@ -2527,6 +2558,10 @@ int cmd_fetch(int argc,\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n+\t\tOPT_CALLBACK_F(0, \"submodule-errors\", &submodule_errors_cli,\n+\t\t\t    N_(\"(fail|warn)\"),\n+\t\t\t    N_(\"control how submodule fetch errors are handled\"),\n+\t\t\t    0, option_parse_submodule_errors),\n \t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n \t\t\t N_(\"dry run\")),\n \t\tOPT_BOOL(0, \"porcelain\", &porcelain, N_(\"machine-readable output\")),\n@@ -2616,6 +2651,9 @@ int cmd_fetch(int argc,\n \tif (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)\n \t\tconfig.recurse_submodules = recurse_submodules_cli;\n \n+\tif (submodule_errors_cli != -1)\n+\t\tconfig.submodule_errors = submodule_errors_cli;\n+\n \tif (negotiate_only) {\n \t\tswitch (recurse_submodules_cli) {\n \t\tcase RECURSE_SUBMODULES_OFF:\n@@ -2833,7 +2871,8 @@ int cmd_fetch(int argc,\n \t\t\t\t\t  config.recurse_submodules,\n \t\t\t\t\t  recurse_submodules_default,\n \t\t\t\t\t  verbosity < 0,\n-\t\t\t\t\t  max_children);\n+\t\t\t\t\t  max_children,\n+\t\t\t\t\t  config.submodule_errors);\n \t\ttrace2_region_leave_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n \t\tstrvec_clear(&options);\n \t}\ndiff --git a/submodule.c b/submodule.c\nindex 8bcef68a42..da4ace751f 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch {\n \tint oid_fetch_tasks_nr, oid_fetch_tasks_alloc;\n \n \tstruct strbuf submodules_with_errors;\n+\tint submodule_errors;\n };\n #define SPF_INIT { \\\n \t.args = STRVEC_INIT, \\\n@@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n static void record_fetch_error(struct submodule_parallel_fetch *spf,\n \t\t\t       const char *name)\n {\n-\tspf->result = 1;\n+\tif (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)\n+\t\tspf->result = 1;\n \tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n }\n \n@@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix, int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs)\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors)\n {\n \tstruct submodule_parallel_fetch spf = SPF_INIT;\n \tconst struct run_process_parallel_opts opts = {\n@@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r,\n \tspf.default_option = default_option;\n \tspf.quiet = quiet;\n \tspf.prefix = prefix;\n+\tspf.submodule_errors = submodule_errors;\n \n \tif (!r->worktree)\n \t\tgoto out;\ndiff --git a/submodule.h b/submodule.h\nindex b10e16e6c0..c80b687d2a 100644\n--- a/submodule.h\n+++ b/submodule.h\n@@ -90,12 +90,17 @@ int should_update_submodules(void);\n  */\n const struct submodule *submodule_from_ce(const struct cache_entry *ce);\n void check_for_new_submodule_commits(struct object_id *oid);\n+/* Values for the submodule_errors parameter of fetch_submodules(). */\n+#define SUBMODULE_ERRORS_FAIL 0  /* submodule fetch errors are fatal (default) */\n+#define SUBMODULE_ERRORS_WARN 1  /* submodule fetch errors are non-fatal warnings */\n+\n int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix,\n \t\t     int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs);\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors);\n unsigned is_submodule_modified(const char *path, int ignore_untracked);\n int submodule_uses_gitfile(const char *path);\n \ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 188c674c89..b5db8fb5c2 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' '\n \tgit config --global protocol.file.allow always\n '\n \n+test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' '\n+\ttest_when_finished \"rm -fr env_default\" &&\n+\tcreate_err_env env_default &&\n+\tpush_unreachable_commit env_default &&\n+\ttest_must_fail git -C env_default/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cfg\" &&\n+\tcreate_err_env env_warn_cfg &&\n+\tpush_unreachable_commit env_warn_cfg &&\n+\tgit -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cli\" &&\n+\tcreate_err_env env_warn_cli &&\n+\tpush_unreachable_commit env_warn_cli &&\n+\tgit -C env_warn_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' '\n+\ttest_when_finished \"rm -fr env_fail_cli\" &&\n+\tcreate_err_env env_fail_cli &&\n+\tpush_unreachable_commit env_fail_cli &&\n+\ttest_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' '\n+\t# A new reachable submodule commit (pushed to sub_bare) should be\n+\t# fetched without any error summary.\n+\ttest_when_finished \"rm -fr env_ok\" &&\n+\tcreate_err_env env_ok &&\n+\ttest_commit -C env_ok/sub_work reachable_ok &&\n+\tgit -C env_ok/sub_work push &&\n+\tgit -C env_ok/super_work submodule update --remote &&\n+\tgit -C env_ok/super_work add sub &&\n+\tgit -C env_ok/super_work commit -m \"point sub to reachable commit\" &&\n+\tgit -C env_ok/super_work push &&\n+\tgit -C env_ok/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\t! grep \"Errors during submodule fetch\" err\n+'\n+\n test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n \t# Create the same commit (unreferenced, via commit-tree with fixed\n \t# dates) in both super_work/sub and clone/sub, point the gitlink at\n@@ -1334,4 +1385,29 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p\n \tgrep \"Errors during submodule fetch\" err\n '\n \n+test_expect_success '--submodule-errors=warn is honored by fetch --all' '\n+\t# A second remote forces fetch_multiple(), which hands the submodule\n+\t# recursion off to per-remote child processes; the option must be\n+\t# forwarded to them.\n+\ttest_when_finished \"rm -fr env_all\" &&\n+\tcreate_err_env env_all &&\n+\tpush_unreachable_commit env_all &&\n+\tgit -C env_all/clone remote add second \"$pwd/env_all/super_bare\" &&\n+\tgit -C env_all/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '\n+\ttest_when_finished \"rm -fr env_access\" &&\n+\tcreate_err_env env_access &&\n+\trm env_access/clone/sub/.git &&\n+\trm -r env_access/clone/.git/modules/sub &&\n+\tgit -C env_access/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\tgrep \"Could not access submodule\" err &&\n+\ttest_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Could not access submodule\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"547801","messageId":"xmqqmrvybi5t.fsf@gitster.g","threadId":"65970","inReplyTo":"20260710122655.3066377-3-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v3 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-10T22:21:02Z","receivedAt":"2026-07-10T22:21:06Z","isPatch":true,"body":"Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:\n\n> When fetching with --recurse-submodules, a submodule commit that is not\n> yet reachable from any of the submodule's remote refs causes the entire\n> fetch to fail.  This is overly strict when the missing commit belongs to\n> an upstream branch that is still being prepared (e.g. an in-progress\n> merge topic): the local branch does not need that commit, so there is no\n> reason to treat its absence as fatal.\n>\n> Add a new config key fetch.submoduleErrors (values: fail/warn) and a\n> corresponding --submodule-errors=(fail|warn) command-line option that\n> control this behaviour.  The default remains fail (existing behaviour);\n> setting the value to warn causes submodule fetch failures to be reported\n> on stderr without affecting the overall exit status of git fetch / git\n> pull.\n>\n> Forward the option to child fetches in add_options_to_argv() so that it\n> also takes effect for `git fetch --all` / `--multiple` (where per-remote\n> child processes handle the submodule recursion themselves) and for\n> nested submodule recursion.\n>\n> Signed-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n> ---\n>  Documentation/config/fetch.adoc  | 14 ++++++\n>  Documentation/fetch-options.adoc |  8 ++++\n>  builtin/fetch.c                  | 41 ++++++++++++++++-\n>  submodule.c                      |  8 +++-\n>  submodule.h                      |  7 ++-\n>  t/t5526-fetch-submodules.sh      | 76 ++++++++++++++++++++++++++++++++\n>  6 files changed, 150 insertions(+), 4 deletions(-)\n>\n> diff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\n> index 04ac90912d..5c9c942a70 100644\n> --- a/Documentation/config/fetch.adoc\n> +++ b/Documentation/config/fetch.adoc\n> @@ -10,6 +10,20 @@\n>  \treference.\n>  \tDefaults to `on-demand`, or to the value of `submodule.recurse` if set.\n>  \n> +`fetch.submoduleErrors`::\n> +\tControls how errors from submodule fetches are handled when\n> +\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n> +\tany submodule fetch error causes the overall `git fetch` or `git pull`\n> +\tto exit with a non-zero status. When set to `warn`, submodule fetch\n> +\terrors are reported to standard error but do not affect the exit\n> +\tstatus of the command. This is useful when working in repositories\n> +\twhere some branches reference submodule commits that are not yet\n> +\tavailable on the submodule remote, but those commits are not needed\n> +\tfor the currently checked-out branch.\n> ++\n> +The value of this option can be overridden by the `--submodule-errors`\n> +option of linkgit:git-fetch[1].\n> +\n>  `fetch.fsckObjects`::\n>  \tIf it is set to true, git-fetch-pack will check all fetched\n>  \tobjects. See `transfer.fsckObjects` for what's\n> diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\n> index 035f780e58..78525f6848 100644\n> --- a/Documentation/fetch-options.adoc\n> +++ b/Documentation/fetch-options.adoc\n> @@ -294,6 +294,14 @@ ifndef::git-pull[]\n>  `--no-recurse-submodules`::\n>  \tDisable recursive fetching of submodules (this has the same effect as\n>  \tusing the `--recurse-submodules=no` option).\n> +\n> +`--submodule-errors=(fail|warn)`::\n> +\tControl how errors from submodule fetches are handled when\n> +\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n> +\tany submodule fetch error causes the overall `git fetch` to exit with a\n> +\tnon-zero status. When set to `warn`, submodule fetch errors are reported\n> +\tto standard error but do not affect the exit status of the command. Can\n> +\talso be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].\n>  endif::git-pull[]\n>  \n>  `--set-upstream`::\n> diff --git a/builtin/fetch.c b/builtin/fetch.c\n> index c1d7c672f4..40daaf5cc7 100644\n> --- a/builtin/fetch.c\n> +++ b/builtin/fetch.c\n> @@ -110,6 +110,7 @@ struct fetch_config {\n>  \tint recurse_submodules;\n>  \tint parallel;\n>  \tint submodule_fetch_jobs;\n> +\tint submodule_errors;\n>  };\n>  \n>  static int git_fetch_config(const char *k, const char *v,\n> @@ -152,6 +153,19 @@ static int git_fetch_config(const char *k, const char *v,\n>  \t\treturn 0;\n>  \t}\n>  \n> +\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n> +\t\tif (!v)\n> +\t\t\treturn config_error_nonbool(k);\n> +\t\telse if (!strcasecmp(v, \"fail\"))\n> +\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;\n> +\t\telse if (!strcasecmp(v, \"warn\"))\n> +\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;\n> +\t\telse\n> +\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n> +\t\t\t    \"fetch.submoduleErrors\", v);\n> +\t\treturn 0;\n> +\t}\n> +\n>  \tif (!strcmp(k, \"fetch.parallel\")) {\n>  \t\tfetch_config->parallel = git_config_int(k, v, ctx->kvi);\n>  \t\tif (fetch_config->parallel < 0)\n\n\n> @@ -2205,6 +2219,8 @@ static void add_options_to_argv(struct strvec *argv,\n>  \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n>  \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n>  \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n> +\tif (config->submodule_errors == SUBMODULE_ERRORS_WARN)\n> +\t\tstrvec_push(argv, \"--submodule-errors=warn\");\n>  \tif (tags == TAGS_SET)\n>  \t\tstrvec_push(argv, \"--tags\");\n>  \telse if (tags == TAGS_UNSET)\n\nIf (config->submodule_errors != SUBMODULE_ERRORS_WARN), then the argv[]\nwould not see any --submodule-errors=<anything> to propagate down.\nSpecifically, this function is called when recurse-submodules is not\ndisabled, and prepares argv[] used to call fetch_submodules().\n\n>  int cmd_fetch(int argc,\n>  \t      const char **argv,\n>  \t      const char *prefix,\n> @@ -2477,6 +2506,7 @@ int cmd_fetch(int argc,\n>  \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n>  \t\t.parallel = 1,\n>  \t\t.submodule_fetch_jobs = -1,\n> +\t\t.submodule_errors = SUBMODULE_ERRORS_FAIL,\n>  \t};\n\nHere, .submodule_errors member is initialized to\nSUBMODULE_ERRORS_FAIL (i.e. 0).\n\n> @@ -2491,6 +2521,7 @@ int cmd_fetch(int argc,\n>  \tint max_jobs = -1;\n>  \tint recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;\n>  \tint recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;\n> +\tint submodule_errors_cli = -1; /* -1: not set on command line */\n>  \tint fetch_write_commit_graph = -1;\n>  \tint stdin_refspecs = 0;\n>  \tint negotiate_only = 0;\n> @@ -2527,6 +2558,10 @@ int cmd_fetch(int argc,\n>  \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n>  \t\t\t    N_(\"control recursive fetching of submodules\"),\n>  \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n> +\t\tOPT_CALLBACK_F(0, \"submodule-errors\", &submodule_errors_cli,\n> +\t\t\t    N_(\"(fail|warn)\"),\n> +\t\t\t    N_(\"control how submodule fetch errors are handled\"),\n> +\t\t\t    0, option_parse_submodule_errors),\n\nAnd command line option \"--submodule-errors={warn,fail}\" may update\nthe local variable submodule_errors_cli (initialied to -1) to one of\nSUBMODULE_ERRORS_{WARN,FAIL}.   These are different from -1, so we\ncan reliably tell if we saw a command line override, which is good.\n\n>  \t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n>  \t\t\t N_(\"dry run\")),\n>  \t\tOPT_BOOL(0, \"porcelain\", &porcelain, N_(\"machine-readable output\")),\n> @@ -2616,6 +2651,9 @@ int cmd_fetch(int argc,\n>  \tif (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)\n>  \t\tconfig.recurse_submodules = recurse_submodules_cli;\n>  \n> +\tif (submodule_errors_cli != -1)\n> +\t\tconfig.submodule_errors = submodule_errors_cli;\n\nAnd we override what we read from the configuration if we got a\ncommand line override.\n\nAnd the value in config.submodule_errors is used much later, in a\ncall to add_options_to_argv() we saw earlier, but this patch does\nnot touch the caller so we do not see the calling site.\n\nI do not do submodules, so my expectation here may be a bit skewed,\nbut what happens when we configure fetch.submoduleErrors to warn,\nbut override it from the command line to fail?  .submodule_errors is\nset to SUBMODULE_ERRORS_FAIL here?  As we saw, add_options_to_argv()\nstuff --submodule-error=<setting> only when config.submodule_errors\nis set to SUBMODULE_ERRORS_WARN, so we do not pass command line\noverride.  Is this desirable?  Don't we want to pass down not just\n--submodule-error=warn but --submodule-error=fail if that is what\nwas given from the command line?  Or does it not matter because fail\nis the default?\n\nThanks.\n\n\n"},{"id":"548126","messageId":"20260714132939.3368732-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"xmqqmrvybi5t.fsf@gitster.g","subject":"Re: [PATCH v3 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-14T13:29:39Z","receivedAt":"2026-07-14T13:29:42Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Don't we want to pass down not just\n> --submodule-error=warn but --submodule-error=fail if that is what\n> was given from the command line?  Or does it not matter because fail\n> is the default?\n\nGood catch, it does matter: the per-remote children of \"fetch --all\"\nre-read the repository configuration, so a configured warn silently\nwon over an explicit --submodule-errors=fail.\n\nFixed in v4 by forwarding the resolved value whenever it was set\nexplicitly, in either direction; when nothing is set, nothing is\nforwarded.  Added a test.\n\nThanks.\n"},{"id":"548127","messageId":"20260714132959.3368867-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260710122655.3066377-1-paulius.zaleckas@gmail.com","subject":"[PATCH v4 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-14T13:29:55Z","receivedAt":"2026-07-14T13:30:02Z","isPatch":true,"body":"When fetching with --recurse-submodules, git currently exits with a\nnon-zero status if any submodule references an OID that is not reachable\nfrom the submodule's remote.  This situation arises naturally when an\nupstream branch is still in preparation (e.g. a topic branch in a merge\nwindow): the local branch does not depend on the missing commit, so a\nhard failure is unnecessarily disruptive.\n\nPatch 1 fixes a pre-existing NEEDSWORK in submodule.c where a phase-1\nfetch failure was recorded immediately, even when a phase-2 OID-based\nretry was about to be scheduled.  After this fix the existing fatal\nbehaviour is preserved but the logic is now structured so that errors\nare only recorded when the phase-2 retry actually fails, or when there\nis no phase-2 retry to fall back on.\n\nPatch 2 introduces fetch.submoduleErrors (fail|warn) and\n--submodule-errors=(fail|warn) to let users opt into non-fatal\nbehaviour.  The default remains fail for full backwards compatibility.\n\nChanges in v4:\n- Forward an explicit --submodule-errors=fail to child fetches as well,\n  so the command line overrides fetch.submoduleErrors=warn config in\n  the per-remote children of fetch --all/--multiple (noticed by Junio)\n\nChanges in v3:\n- Report a phase-1 failure also when the gitlink commits are already\n  present locally, instead of silently succeeding\n- Route \"Could not access submodule\" through record_fetch_error() so it\n  shows up in the error summary and honors the warn mode\n- Forward --submodule-errors to child fetches so it takes effect for\n  fetch --all/--multiple and nested submodule recursion\n- Add tests for all of the above\n- Documentation: don't imply git pull takes --submodule-errors, minor\n  wording and placement fixes\n\nChanges in v2:\n- Fix option synopsis to use (fail|warn) instead of <fail|warn>\n- Add --submodule-errors documentation to Documentation/fetch-options.adoc\n\nPaulius Zaleckas (2):\n  submodule: fix premature failure in recursive submodule fetch\n  fetch: add fetch.submoduleErrors to make submodule fetch errors\n    non-fatal\n\n Documentation/config/fetch.adoc  |  14 +++\n Documentation/fetch-options.adoc |   8 ++\n builtin/fetch.c                  |  46 ++++++++-\n submodule.c                      |  58 ++++++++---\n submodule.h                      |   7 +-\n t/t5526-fetch-submodules.sh      | 161 +++++++++++++++++++++++++++++++\n 6 files changed, 277 insertions(+), 17 deletions(-)\n\n-- \n2.54.0\n\n"},{"id":"548128","messageId":"20260714132959.3368867-2-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260714132959.3368867-1-paulius.zaleckas@gmail.com","subject":"[PATCH v4 1/2] submodule: fix premature failure in recursive submodule fetch","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-14T13:29:56Z","receivedAt":"2026-07-14T13:30:04Z","isPatch":true,"body":"When git fetch --recurse-submodules encounters a failure fetching a\nsubmodule's refs (phase 1), it immediately marks the overall operation\nas failed, even though a subsequent OID-based fetch (phase 2) is about\nto be attempted for any missing commits.  If phase 2 succeeds, the\noverall result should be success, but the prematurely set failure flag\nmakes it look like an error.\n\nRestructure fetch_finish() so that a phase-1 failure does not record an\nerror immediately.  Instead, the decision is deferred:\n\n - If missing commits trigger a phase-2 (OID-based) retry and that\n   retry succeeds, no error is recorded.\n - If the phase-2 retry also fails, the error is recorded then.\n - If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON)\n   and is not in the changed list, a phase-1 failure is recorded right\n   away since there is no OID retry to fall back on.\n - If phase 1 fails but all required commits are already present\n   locally, there is no retry to defer to; the failure is still\n   recorded, since the fetch itself went wrong (e.g. a transport\n   error) even though the wanted commits happen to be available.\n\nThis resolves the NEEDSWORK comment added by bd5e567dc7 (submodule:\nexplain first attempt failure clearly, 2019-03-13).\n\nExtract the common error-recording logic into a helper\nrecord_fetch_error() and use it in fetch_start_failure() and for the\n\"Could not access submodule\" error in get_fetch_task_from_index() as\nwell; the latter now also lists the submodule in the final error\nsummary.\n\nAdd a test ensuring a failed submodule fetch is still reported when\nthe gitlinked commits happen to be present locally.\n\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n submodule.c                 | 52 +++++++++++++++++++--------\n t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++\n 2 files changed, 110 insertions(+), 14 deletions(-)\n\ndiff --git a/submodule.c b/submodule.c\nindex fd91201a92..8bcef68a42 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n \treturn NULL;\n }\n \n+static void record_fetch_error(struct submodule_parallel_fetch *spf,\n+\t\t\t       const char *name)\n+{\n+\tspf->result = 1;\n+\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n+}\n+\n static struct fetch_task *\n get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t  struct strbuf *err)\n@@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t\t\t\t\tce->name);\n \t\t\tif (S_ISGITLINK(ce->ce_mode) &&\n \t\t\t    !is_empty_dir(empty_submodule_path.buf)) {\n-\t\t\t\tspf->result = 1;\n+\t\t\t\trecord_fetch_error(spf, ce->name);\n \t\t\t\tstrbuf_addf(err,\n \t\t\t\t\t    _(\"Could not access submodule '%s'\\n\"),\n \t\t\t\t\t    ce->name);\n@@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED,\n \tstruct submodule_parallel_fetch *spf = cb;\n \tstruct fetch_task *task = task_cb;\n \n-\tspf->result = 1;\n+\trecord_fetch_error(spf, task->sub->name);\n \n \tfetch_task_free(task);\n \treturn 0;\n@@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \tif (!task || !task->sub)\n \t\tBUG(\"callback cookie bogus\");\n \n-\tif (retvalue) {\n+\tif (retvalue && task->commits) {\n \t\t/*\n-\t\t * NEEDSWORK: This indicates that the overall fetch\n-\t\t * failed, even though there may be a subsequent fetch\n-\t\t * by commit hash that might work. It may be a good\n-\t\t * idea to not indicate failure in this case, and only\n-\t\t * indicate failure if the subsequent fetch fails.\n+\t\t * This is the second pass (OID-based fetch) and it failed.\n+\t\t * The commits are genuinely unavailable from the remote.\n \t\t */\n-\t\tspf->result = 1;\n-\n-\t\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\",\n-\t\t\t    task->sub->name);\n+\t\trecord_fetch_error(spf, task->sub->name);\n \t}\n \n \t/* Is this the second time we process this submodule? */\n@@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\tgoto out;\n \n \tit = string_list_lookup(&spf->changed_submodule_names, task->sub->name);\n-\tif (!it)\n-\t\t/* Could be an unchanged submodule, not contained in the list */\n+\tif (!it) {\n+\t\t/*\n+\t\t * This submodule is not in the changed list (e.g. it was\n+\t\t * fetched because RECURSE_SUBMODULES_ON fetches all populated\n+\t\t * submodules). A phase 1 failure here has no OID-based retry\n+\t\t * to fall back on, so it is a genuine error.\n+\t\t */\n+\t\tif (retvalue)\n+\t\t\trecord_fetch_error(spf, task->sub->name);\n \t\tgoto out;\n+\t}\n \n \tcs_data = it->util;\n \toid_array_filter(&cs_data->new_commits,\n@@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \n \t/* Are there commits we want, but do not exist? */\n \tif (cs_data->new_commits.nr) {\n+\t\t/*\n+\t\t * Schedule an OID-based phase 2 fetch to retrieve the missing\n+\t\t * commits directly. Defer any error from phase 1: if phase 2\n+\t\t * succeeds, the overall operation should still succeed.\n+\t\t */\n \t\ttask->commits = &cs_data->new_commits;\n \t\tALLOC_GROW(spf->oid_fetch_tasks,\n \t\t\t   spf->oid_fetch_tasks_nr + 1,\n@@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\treturn 0;\n \t}\n \n+\t/*\n+\t * All required commits are already present locally (they were either\n+\t * fetched by phase 1 or existed beforehand), so there is no phase 2\n+\t * retry to defer to. If phase 1 failed, the fetch itself went wrong\n+\t * (e.g. a transport error) and must still be reported, even though\n+\t * the gitlinked commits are available.\n+\t */\n+\tif (retvalue)\n+\t\trecord_fetch_error(spf, task->sub->name);\n+\n out:\n \tfetch_task_free(task);\n \treturn 0;\ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 1242ee9185..188c674c89 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1262,4 +1262,76 @@ test_expect_success \"fetch --all with --no-recurse-submodules only fetches super\n \t! grep \"Fetching submodule\" fetch-log\n '\n \n+# Create an isolated environment for submodule fetch error tests.\n+#\n+# Sets up sub_bare (the submodule upstream), super_bare (the superproject\n+# upstream), super_work (a working clone of super_bare with an initialized\n+# submodule), and clone (a clone of super_bare with an initialized submodule\n+# at a reachable commit). The caller can then create an unreachable commit\n+# and push the superproject to put the clone one commit behind a state it\n+# cannot fully fetch.\n+#\n+# Usage: create_err_env <envdir>\n+create_err_env () {\n+\tlocal envdir=\"$1\" &&\n+\tmkdir \"$envdir\" &&\n+\n+\tgit init --bare \"$envdir/sub_bare\" &&\n+\tgit clone \"$envdir/sub_bare\" \"$envdir/sub_work\" &&\n+\ttest_commit -C \"$envdir/sub_work\" \"${envdir}_base\" &&\n+\tgit -C \"$envdir/sub_work\" push &&\n+\n+\tgit init --bare \"$envdir/super_bare\" &&\n+\tgit clone \"$envdir/super_bare\" \"$envdir/super_work\" &&\n+\tgit -C \"$envdir/super_work\" submodule add \\\n+\t\t\"$pwd/$envdir/sub_bare\" sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"add submodule\" &&\n+\tgit -C \"$envdir/super_work\" push &&\n+\n+\tgit clone \"$envdir/super_bare\" \"$envdir/clone\" &&\n+\tgit -C \"$envdir/clone\" submodule update --init\n+}\n+\n+# Push a commit to <envdir>/super_bare that records a submodule SHA that is\n+# present locally in super_work/sub but NOT pushed to sub_bare, making the\n+# submodule commit unreachable from clone's sub remote.\n+push_unreachable_commit () {\n+\tlocal envdir=\"$1\" &&\n+\tgit -C \"$envdir/super_work/sub\" commit --allow-empty -m \"unreachable\" &&\n+\tgit -C \"$envdir/super_work\" add sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"point sub to unreachable commit\" &&\n+\tgit -C \"$envdir/super_work\" push\n+}\n+\n+test_expect_success 'setup for submodule fetch error tests' '\n+\tgit config --global protocol.file.allow always\n+'\n+\n+test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n+\t# Create the same commit (unreferenced, via commit-tree with fixed\n+\t# dates) in both super_work/sub and clone/sub, point the gitlink at\n+\t# it, and break clone/sub'\\''s remote. The commit exists in clone/sub\n+\t# but is unreachable, so the submodule stays in the changed list; the\n+\t# fetch failure must still be reported even though there is nothing\n+\t# left to fetch by commit hash.\n+\ttest_when_finished \"rm -fr env_phase1\" &&\n+\tcreate_err_env env_phase1 &&\n+\tcommit=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t git -C env_phase1/super_work/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\tpresent=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t  GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t  git -C env_phase1/clone/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\ttest \"$commit\" = \"$present\" &&\n+\tgit -C env_phase1/super_work/sub checkout \"$commit\" &&\n+\tgit -C env_phase1/super_work add sub &&\n+\tgit -C env_phase1/super_work commit -m \"gitlink to locally-present commit\" &&\n+\tgit -C env_phase1/super_work push &&\n+\tgit -C env_phase1/clone/sub remote set-url origin \"$pwd/env_phase1/missing\" &&\n+\ttest_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"548129","messageId":"20260714132959.3368867-3-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260714132959.3368867-1-paulius.zaleckas@gmail.com","subject":"[PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-14T13:29:57Z","receivedAt":"2026-07-14T13:30:06Z","isPatch":true,"body":"When fetching with --recurse-submodules, a submodule commit that is not\nyet reachable from any of the submodule's remote refs causes the entire\nfetch to fail.  This is overly strict when the missing commit belongs to\nan upstream branch that is still being prepared (e.g. an in-progress\nmerge topic): the local branch does not need that commit, so there is no\nreason to treat its absence as fatal.\n\nAdd a new config key fetch.submoduleErrors (values: fail/warn) and a\ncorresponding --submodule-errors=(fail|warn) command-line option that\ncontrol this behaviour.  The default remains fail (existing behaviour);\nsetting the value to warn causes submodule fetch failures to be reported\non stderr without affecting the overall exit status of git fetch / git\npull.\n\nForward the option to child fetches in add_options_to_argv() so that it\nalso takes effect for `git fetch --all` / `--multiple` (where per-remote\nchild processes handle the submodule recursion themselves) and for\nnested submodule recursion.  The resolved value is forwarded whenever it\nwas set explicitly, in either direction: the per-remote children re-read\nthe repository configuration, so a command-line --submodule-errors=fail\nmust be passed down to them to override fetch.submoduleErrors=warn from\nthe configuration.  When neither the configuration nor the command line\nsets a value, nothing is forwarded and the child processes fall back to\ntheir own configuration.\n\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n Documentation/config/fetch.adoc  | 14 +++++\n Documentation/fetch-options.adoc |  8 +++\n builtin/fetch.c                  | 46 ++++++++++++++++-\n submodule.c                      |  8 ++-\n submodule.h                      |  7 ++-\n t/t5526-fetch-submodules.sh      | 89 ++++++++++++++++++++++++++++++++\n 6 files changed, 168 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\nindex 04ac90912d..5c9c942a70 100644\n--- a/Documentation/config/fetch.adoc\n+++ b/Documentation/config/fetch.adoc\n@@ -10,6 +10,20 @@\n \treference.\n \tDefaults to `on-demand`, or to the value of `submodule.recurse` if set.\n \n+`fetch.submoduleErrors`::\n+\tControls how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` or `git pull`\n+\tto exit with a non-zero status. When set to `warn`, submodule fetch\n+\terrors are reported to standard error but do not affect the exit\n+\tstatus of the command. This is useful when working in repositories\n+\twhere some branches reference submodule commits that are not yet\n+\tavailable on the submodule remote, but those commits are not needed\n+\tfor the currently checked-out branch.\n++\n+The value of this option can be overridden by the `--submodule-errors`\n+option of linkgit:git-fetch[1].\n+\n `fetch.fsckObjects`::\n \tIf it is set to true, git-fetch-pack will check all fetched\n \tobjects. See `transfer.fsckObjects` for what's\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 035f780e58..78525f6848 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -294,6 +294,14 @@ ifndef::git-pull[]\n `--no-recurse-submodules`::\n \tDisable recursive fetching of submodules (this has the same effect as\n \tusing the `--recurse-submodules=no` option).\n+\n+`--submodule-errors=(fail|warn)`::\n+\tControl how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` to exit with a\n+\tnon-zero status. When set to `warn`, submodule fetch errors are reported\n+\tto standard error but do not affect the exit status of the command. Can\n+\talso be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].\n endif::git-pull[]\n \n `--set-upstream`::\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f4..41122e17b3 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -110,6 +110,7 @@ struct fetch_config {\n \tint recurse_submodules;\n \tint parallel;\n \tint submodule_fetch_jobs;\n+\tint submodule_errors;\n };\n \n static int git_fetch_config(const char *k, const char *v,\n@@ -152,6 +153,19 @@ static int git_fetch_config(const char *k, const char *v,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\t\telse if (!strcasecmp(v, \"fail\"))\n+\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;\n+\t\telse if (!strcasecmp(v, \"warn\"))\n+\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;\n+\t\telse\n+\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n+\t\t\t    \"fetch.submoduleErrors\", v);\n+\t\treturn 0;\n+\t}\n+\n \tif (!strcmp(k, \"fetch.parallel\")) {\n \t\tfetch_config->parallel = git_config_int(k, v, ctx->kvi);\n \t\tif (fetch_config->parallel < 0)\n@@ -2205,6 +2219,10 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n+\tif (config->submodule_errors == SUBMODULE_ERRORS_FAIL)\n+\t\tstrvec_push(argv, \"--submodule-errors=fail\");\n+\telse if (config->submodule_errors == SUBMODULE_ERRORS_WARN)\n+\t\tstrvec_push(argv, \"--submodule-errors=warn\");\n \tif (tags == TAGS_SET)\n \t\tstrvec_push(argv, \"--tags\");\n \telse if (tags == TAGS_UNSET)\n@@ -2464,6 +2482,19 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \treturn exit_code;\n }\n \n+static int option_parse_submodule_errors(const struct option *opt,\n+\t\t\t\t\t  const char *arg, int unset)\n+{\n+\tint *v = opt->value;\n+\tif (unset || !strcasecmp(arg, \"fail\"))\n+\t\t*v = SUBMODULE_ERRORS_FAIL;\n+\telse if (!strcasecmp(arg, \"warn\"))\n+\t\t*v = SUBMODULE_ERRORS_WARN;\n+\telse\n+\t\tdie(_(\"invalid value for '%s': '%s'\"), \"--submodule-errors\", arg);\n+\treturn 0;\n+}\n+\n int cmd_fetch(int argc,\n \t      const char **argv,\n \t      const char *prefix,\n@@ -2477,6 +2508,7 @@ int cmd_fetch(int argc,\n \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n \t\t.parallel = 1,\n \t\t.submodule_fetch_jobs = -1,\n+\t\t.submodule_errors = -1, /* unset */\n \t};\n \tconst char *submodule_prefix = \"\";\n \tconst char *bundle_uri;\n@@ -2491,6 +2523,7 @@ int cmd_fetch(int argc,\n \tint max_jobs = -1;\n \tint recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;\n \tint recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;\n+\tint submodule_errors_cli = -1; /* -1: not set on command line */\n \tint fetch_write_commit_graph = -1;\n \tint stdin_refspecs = 0;\n \tint negotiate_only = 0;\n@@ -2527,6 +2560,10 @@ int cmd_fetch(int argc,\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n+\t\tOPT_CALLBACK_F(0, \"submodule-errors\", &submodule_errors_cli,\n+\t\t\t    N_(\"(fail|warn)\"),\n+\t\t\t    N_(\"control how submodule fetch errors are handled\"),\n+\t\t\t    0, option_parse_submodule_errors),\n \t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n \t\t\t N_(\"dry run\")),\n \t\tOPT_BOOL(0, \"porcelain\", &porcelain, N_(\"machine-readable output\")),\n@@ -2616,6 +2653,9 @@ int cmd_fetch(int argc,\n \tif (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)\n \t\tconfig.recurse_submodules = recurse_submodules_cli;\n \n+\tif (submodule_errors_cli != -1)\n+\t\tconfig.submodule_errors = submodule_errors_cli;\n+\n \tif (negotiate_only) {\n \t\tswitch (recurse_submodules_cli) {\n \t\tcase RECURSE_SUBMODULES_OFF:\n@@ -2819,11 +2859,14 @@ int cmd_fetch(int argc,\n \tif (!result && remote && (config.recurse_submodules != RECURSE_SUBMODULES_OFF)) {\n \t\tstruct strvec options = STRVEC_INIT;\n \t\tint max_children = max_jobs;\n+\t\tint submodule_errors = config.submodule_errors;\n \n \t\tif (max_children < 0)\n \t\t\tmax_children = config.submodule_fetch_jobs;\n \t\tif (max_children < 0)\n \t\t\tmax_children = config.parallel;\n+\t\tif (submodule_errors < 0)\n+\t\t\tsubmodule_errors = SUBMODULE_ERRORS_FAIL;\n \n \t\tadd_options_to_argv(&options, &config);\n \t\ttrace2_region_enter_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n@@ -2833,7 +2876,8 @@ int cmd_fetch(int argc,\n \t\t\t\t\t  config.recurse_submodules,\n \t\t\t\t\t  recurse_submodules_default,\n \t\t\t\t\t  verbosity < 0,\n-\t\t\t\t\t  max_children);\n+\t\t\t\t\t  max_children,\n+\t\t\t\t\t  submodule_errors);\n \t\ttrace2_region_leave_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n \t\tstrvec_clear(&options);\n \t}\ndiff --git a/submodule.c b/submodule.c\nindex 8bcef68a42..da4ace751f 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch {\n \tint oid_fetch_tasks_nr, oid_fetch_tasks_alloc;\n \n \tstruct strbuf submodules_with_errors;\n+\tint submodule_errors;\n };\n #define SPF_INIT { \\\n \t.args = STRVEC_INIT, \\\n@@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n static void record_fetch_error(struct submodule_parallel_fetch *spf,\n \t\t\t       const char *name)\n {\n-\tspf->result = 1;\n+\tif (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)\n+\t\tspf->result = 1;\n \tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n }\n \n@@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix, int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs)\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors)\n {\n \tstruct submodule_parallel_fetch spf = SPF_INIT;\n \tconst struct run_process_parallel_opts opts = {\n@@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r,\n \tspf.default_option = default_option;\n \tspf.quiet = quiet;\n \tspf.prefix = prefix;\n+\tspf.submodule_errors = submodule_errors;\n \n \tif (!r->worktree)\n \t\tgoto out;\ndiff --git a/submodule.h b/submodule.h\nindex b10e16e6c0..c80b687d2a 100644\n--- a/submodule.h\n+++ b/submodule.h\n@@ -90,12 +90,17 @@ int should_update_submodules(void);\n  */\n const struct submodule *submodule_from_ce(const struct cache_entry *ce);\n void check_for_new_submodule_commits(struct object_id *oid);\n+/* Values for the submodule_errors parameter of fetch_submodules(). */\n+#define SUBMODULE_ERRORS_FAIL 0  /* submodule fetch errors are fatal (default) */\n+#define SUBMODULE_ERRORS_WARN 1  /* submodule fetch errors are non-fatal warnings */\n+\n int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix,\n \t\t     int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs);\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors);\n unsigned is_submodule_modified(const char *path, int ignore_untracked);\n int submodule_uses_gitfile(const char *path);\n \ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 188c674c89..504ab200ef 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' '\n \tgit config --global protocol.file.allow always\n '\n \n+test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' '\n+\ttest_when_finished \"rm -fr env_default\" &&\n+\tcreate_err_env env_default &&\n+\tpush_unreachable_commit env_default &&\n+\ttest_must_fail git -C env_default/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cfg\" &&\n+\tcreate_err_env env_warn_cfg &&\n+\tpush_unreachable_commit env_warn_cfg &&\n+\tgit -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cli\" &&\n+\tcreate_err_env env_warn_cli &&\n+\tpush_unreachable_commit env_warn_cli &&\n+\tgit -C env_warn_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' '\n+\ttest_when_finished \"rm -fr env_fail_cli\" &&\n+\tcreate_err_env env_fail_cli &&\n+\tpush_unreachable_commit env_fail_cli &&\n+\ttest_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' '\n+\t# A new reachable submodule commit (pushed to sub_bare) should be\n+\t# fetched without any error summary.\n+\ttest_when_finished \"rm -fr env_ok\" &&\n+\tcreate_err_env env_ok &&\n+\ttest_commit -C env_ok/sub_work reachable_ok &&\n+\tgit -C env_ok/sub_work push &&\n+\tgit -C env_ok/super_work submodule update --remote &&\n+\tgit -C env_ok/super_work add sub &&\n+\tgit -C env_ok/super_work commit -m \"point sub to reachable commit\" &&\n+\tgit -C env_ok/super_work push &&\n+\tgit -C env_ok/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\t! grep \"Errors during submodule fetch\" err\n+'\n+\n test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n \t# Create the same commit (unreferenced, via commit-tree with fixed\n \t# dates) in both super_work/sub and clone/sub, point the gitlink at\n@@ -1334,4 +1385,42 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p\n \tgrep \"Errors during submodule fetch\" err\n '\n \n+test_expect_success '--submodule-errors=warn is honored by fetch --all' '\n+\t# A second remote forces fetch_multiple(), which hands the submodule\n+\t# recursion off to per-remote child processes; the option must be\n+\t# forwarded to them.\n+\ttest_when_finished \"rm -fr env_all\" &&\n+\tcreate_err_env env_all &&\n+\tpush_unreachable_commit env_all &&\n+\tgit -C env_all/clone remote add second \"$pwd/env_all/super_bare\" &&\n+\tgit -C env_all/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail overrides warn config for fetch --all' '\n+\t# The per-remote child processes re-read the repository config, so\n+\t# the command-line override must be forwarded to them explicitly.\n+\ttest_when_finished \"rm -fr env_override\" &&\n+\tcreate_err_env env_override &&\n+\tpush_unreachable_commit env_override &&\n+\tgit -C env_override/clone remote add second \"$pwd/env_override/super_bare\" &&\n+\tgit -C env_override/clone config fetch.submoduleErrors warn &&\n+\ttest_must_fail git -C env_override/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\tgrep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '\n+\ttest_when_finished \"rm -fr env_access\" &&\n+\tcreate_err_env env_access &&\n+\trm env_access/clone/sub/.git &&\n+\trm -r env_access/clone/.git/modules/sub &&\n+\tgit -C env_access/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\tgrep \"Could not access submodule\" err &&\n+\ttest_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&\n+\tgrep \"Could not access submodule\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"548131","messageId":"xmqqldbdvb3x.fsf@gitster.g","threadId":"65970","inReplyTo":"20260714132959.3368867-3-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-14T15:34:26Z","receivedAt":"2026-07-14T15:34:29Z","isPatch":true,"body":"Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:\n\n>  t/t5526-fetch-submodules.sh      | 89 ++++++++++++++++++++++++++++++++\n>  6 files changed, 168 insertions(+), 4 deletions(-)\n\nIn addition to what was pointed out by Ramsay in his squashable\npatch <387a34d5-fdf5-4513-9aaf-4e73d9304c1d@ramsayjones.plus.com>\nthis round adds another use of raw grep that is caught by the test\nframework.\n\ncommit 8f7761ee72b3669c1aee98142852437d016c785c\nAuthor: Junio C Hamano <gitster@pobox.com>\nDate:   Tue Jul 14 08:31:57 2026 -0700\n\n    fixup! fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal\n\ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 614d45ab71..19d17440cf 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1408,7 +1408,7 @@ test_expect_success '--submodule-errors=fail overrides warn config for fetch --a\n \tgit -C env_override/clone config fetch.submoduleErrors warn &&\n \ttest_must_fail git -C env_override/clone fetch --all --recurse-submodules \\\n \t\t--submodule-errors=fail 2>err &&\n-\tgrep \"Errors during submodule fetch\" err\n+\ttest_grep \"Errors during submodule fetch\" err\n '\n \n test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '\n"},{"id":"548137","messageId":"xmqq1pd5trx5.fsf@gitster.g","threadId":"65970","inReplyTo":"20260714132959.3368867-3-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v4 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-14T17:14:14Z","receivedAt":"2026-07-14T17:14:17Z","isPatch":true,"body":"Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:\n\n> +\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n> +\t\tif (!v)\n> +\t\t\treturn config_error_nonbool(k);\n> +\t\telse if (!strcasecmp(v, \"fail\"))\n> +\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_FAIL;\n> +\t\telse if (!strcasecmp(v, \"warn\"))\n> +\t\t\tfetch_config->submodule_errors = SUBMODULE_ERRORS_WARN;\n> +\t\telse\n> +\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n> +\t\t\t    \"fetch.submoduleErrors\", v);\n> +\t\treturn 0;\n> +\t}\n\nTwo points.\n\n * Do not use strcasecmp() on the value.\n\n   While \"fetch.submoduleerrors\" may be case-insenstive, the value\n   does not have to be.  We do not want to encourage users to write\n   \"[fetch] submoduleErrors = Fail\", as some people may want to\n   write third-party add-on scripts that parse \"git config --get\n   fetch.submoduleerrors\" output.  For example:\n\n\terror_handling=$(git config --get fetch.submoduleErrors)\n\tcase \"$error_handling\" in\n\tfail)\n\t\t... do something ... ;;\n\twarn)\n\t\t... do something else ... ;;\n\tesac\n\n   We should not force them to write extra code to handle the value\n   case-insensitively.\n\n * Since you need to convert between the enum and the string here,\n   in option_parse_submodule_errors(), and in add_options_to_argv(),\n   defining a pair of parse/format functions would be cleaner.\n\n\t/* really private - use accessors to parse and format */\n\tstatic const char *submodule_errors_[] = {\n        \t[SUBMODULE_ERRORS_FAIL] = \"fail\",\n        \t[SUBMODULE_ERRORS_WARN] = \"warn\",\n\t};\n\n\tstatic const char *submodule_error(int num)\n\t{\n\t\tassert(0 <= num && num < ARRAY_SIZE(submodule_errors_));\n\t\treturn submodule_errors[num];\n\t}\n\n\tstatic int parse_submodule_error(const char *name)\n\t{\n\t\tfor (int num = 0; num <\tARRAY_SIZE(submodule_errors_); num++)\n\t\t\tif (!strcmp(submodule_errors_[num], name))\n\t\t\t\treturn num;\n\t\treturn -1;\n\t}\n\nThe configuration parsing block would then become:\n\n\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n\t\tint num;\n\n\t\tif (!v)\n\t\t\treturn config_error_nonbool(k);\n\t\tnum = parse_submodule_error(v);\n\t\tif (num < 0)\n\t\t\tdie(_(\"invalid value...\"), ...);\n\t\tfetch_config->submodule_errors = num;\n\t\treturn 0;\n\t}\n\nThis approach is much more maintainable.  You only need to keep the\nsubmodule_errors_[] array up to date with respect to the error-handling\npreprocessor macros.  Some reviewers might suggest converting these macros\ninto a proper enum.  I would not object to that change, but I would not\nbother doing it myself as I do not personally care much about the\ndistinction between an enum and a preprocessor macro in this context.\n\n> @@ -2205,6 +2219,10 @@ static void add_options_to_argv(struct strvec *argv,\n>  \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n>  \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n>  \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n> +\tif (config->submodule_errors == SUBMODULE_ERRORS_FAIL)\n> +\t\tstrvec_push(argv, \"--submodule-errors=fail\");\n> +\telse if (config->submodule_errors == SUBMODULE_ERRORS_WARN)\n> +\t\tstrvec_push(argv, \"--submodule-errors=warn\");\n\nThis part then becomes:\n\n\tif (config->submodule_errors < 0)\n\t\t; /* nothing */\n\telse {\n\t\tconst char *name = submodule_error(config->submodule_errors);\n\t\tstrvec_push(argv, \"--submodule-errors=%s\", name);\n\t}\n\nThis is, again, much more miantainable.\n\n> +static int option_parse_submodule_errors(const struct option *opt,\n> +\t\t\t\t\t  const char *arg, int unset)\n> +{\n> +\tint *v = opt->value;\n> +\tif (unset || !strcasecmp(arg, \"fail\"))\n> +\t\t*v = SUBMODULE_ERRORS_FAIL;\n> +\telse if (!strcasecmp(arg, \"warn\"))\n> +\t\t*v = SUBMODULE_ERRORS_WARN;\n> +\telse\n> +\t\tdie(_(\"invalid value for '%s': '%s'\"), \"--submodule-errors\", arg);\n> +\treturn 0;\n> +}\n\nUpdating this function is left as an exercise ;-)\n"},{"id":"548270","messageId":"20260715103518.526326-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260714132959.3368867-1-paulius.zaleckas@gmail.com","subject":"[PATCH v5 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-15T10:35:14Z","receivedAt":"2026-07-15T10:35:23Z","isPatch":true,"body":"When fetching with --recurse-submodules, git currently exits with a\nnon-zero status if any submodule references an OID that is not reachable\nfrom the submodule's remote.  This situation arises naturally when an\nupstream branch is still in preparation (e.g. a topic branch in a merge\nwindow): the local branch does not depend on the missing commit, so a\nhard failure is unnecessarily disruptive.\n\nPatch 1 fixes a pre-existing NEEDSWORK in submodule.c where a phase-1\nfetch failure was recorded immediately, even when a phase-2 OID-based\nretry was about to be scheduled.  After this fix the existing fatal\nbehaviour is preserved but the logic is now structured so that errors\nare only recorded when the phase-2 retry actually fails, or when there\nis no phase-2 retry to fall back on.\n\nPatch 2 introduces fetch.submoduleErrors (fail|warn) and\n--submodule-errors=(fail|warn) to let users opt into non-fatal\nbehaviour.  The default remains fail for full backwards compatibility.\n\nChanges in v5:\n- Use test_grep instead of raw grep in the new tests (Ramsay, Junio)\n- Parse and format the fail/warn values through a single name array\n  shared by config, option parsing and option forwarding; values are\n  now matched case-sensitively (Junio)\n- Credit Jean-Noël for the v2 documentation fixes, which I forgot to\n  do back then\n\nChanges in v4:\n- Forward an explicit --submodule-errors=fail to child fetches as well,\n  so the command line overrides fetch.submoduleErrors=warn config in\n  the per-remote children of fetch --all/--multiple (noticed by Junio)\n\nChanges in v3:\n- Report a phase-1 failure also when the gitlink commits are already\n  present locally, instead of silently succeeding\n- Route \"Could not access submodule\" through record_fetch_error() so it\n  shows up in the error summary and honors the warn mode\n- Forward --submodule-errors to child fetches so it takes effect for\n  fetch --all/--multiple and nested submodule recursion\n- Add tests for all of the above\n- Documentation: don't imply git pull takes --submodule-errors, minor\n  wording and placement fixes\n\nChanges in v2:\n- Fix option synopsis to use (fail|warn) instead of <fail|warn>\n  (Jean-Noël)\n- Add --submodule-errors documentation to Documentation/fetch-options.adoc\n  (Jean-Noël)\n\nPaulius Zaleckas (2):\n  submodule: fix premature failure in recursive submodule fetch\n  fetch: add fetch.submoduleErrors to make submodule fetch errors\n    non-fatal\n\n Documentation/config/fetch.adoc  |  14 +++\n Documentation/fetch-options.adoc |   8 ++\n builtin/fetch.c                  |  72 +++++++++++++-\n submodule.c                      |  58 ++++++++---\n submodule.h                      |   7 +-\n t/t5526-fetch-submodules.sh      | 161 +++++++++++++++++++++++++++++++\n 6 files changed, 303 insertions(+), 17 deletions(-)\n\n-- \n2.54.0\n\n"},{"id":"548271","messageId":"20260715103518.526326-2-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260715103518.526326-1-paulius.zaleckas@gmail.com","subject":"[PATCH v5 1/2] submodule: fix premature failure in recursive submodule fetch","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-15T10:35:15Z","receivedAt":"2026-07-15T10:35:24Z","isPatch":true,"body":"When git fetch --recurse-submodules encounters a failure fetching a\nsubmodule's refs (phase 1), it immediately marks the overall operation\nas failed, even though a subsequent OID-based fetch (phase 2) is about\nto be attempted for any missing commits.  If phase 2 succeeds, the\noverall result should be success, but the prematurely set failure flag\nmakes it look like an error.\n\nRestructure fetch_finish() so that a phase-1 failure does not record an\nerror immediately.  Instead, the decision is deferred:\n\n - If missing commits trigger a phase-2 (OID-based) retry and that\n   retry succeeds, no error is recorded.\n - If the phase-2 retry also fails, the error is recorded then.\n - If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON)\n   and is not in the changed list, a phase-1 failure is recorded right\n   away since there is no OID retry to fall back on.\n - If phase 1 fails but all required commits are already present\n   locally, there is no retry to defer to; the failure is still\n   recorded, since the fetch itself went wrong (e.g. a transport\n   error) even though the wanted commits happen to be available.\n\nThis resolves the NEEDSWORK comment added by bd5e567dc7 (submodule:\nexplain first attempt failure clearly, 2019-03-13).\n\nExtract the common error-recording logic into a helper\nrecord_fetch_error() and use it in fetch_start_failure() and for the\n\"Could not access submodule\" error in get_fetch_task_from_index() as\nwell; the latter now also lists the submodule in the final error\nsummary.\n\nAdd a test ensuring a failed submodule fetch is still reported when\nthe gitlinked commits happen to be present locally.\n\nHelped-by: Ramsay Jones <ramsay@ramsayjones.plus.com>\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n submodule.c                 | 52 +++++++++++++++++++--------\n t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++\n 2 files changed, 110 insertions(+), 14 deletions(-)\n\ndiff --git a/submodule.c b/submodule.c\nindex fd91201a92..8bcef68a42 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n \treturn NULL;\n }\n \n+static void record_fetch_error(struct submodule_parallel_fetch *spf,\n+\t\t\t       const char *name)\n+{\n+\tspf->result = 1;\n+\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n+}\n+\n static struct fetch_task *\n get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t  struct strbuf *err)\n@@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t\t\t\t\tce->name);\n \t\t\tif (S_ISGITLINK(ce->ce_mode) &&\n \t\t\t    !is_empty_dir(empty_submodule_path.buf)) {\n-\t\t\t\tspf->result = 1;\n+\t\t\t\trecord_fetch_error(spf, ce->name);\n \t\t\t\tstrbuf_addf(err,\n \t\t\t\t\t    _(\"Could not access submodule '%s'\\n\"),\n \t\t\t\t\t    ce->name);\n@@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED,\n \tstruct submodule_parallel_fetch *spf = cb;\n \tstruct fetch_task *task = task_cb;\n \n-\tspf->result = 1;\n+\trecord_fetch_error(spf, task->sub->name);\n \n \tfetch_task_free(task);\n \treturn 0;\n@@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \tif (!task || !task->sub)\n \t\tBUG(\"callback cookie bogus\");\n \n-\tif (retvalue) {\n+\tif (retvalue && task->commits) {\n \t\t/*\n-\t\t * NEEDSWORK: This indicates that the overall fetch\n-\t\t * failed, even though there may be a subsequent fetch\n-\t\t * by commit hash that might work. It may be a good\n-\t\t * idea to not indicate failure in this case, and only\n-\t\t * indicate failure if the subsequent fetch fails.\n+\t\t * This is the second pass (OID-based fetch) and it failed.\n+\t\t * The commits are genuinely unavailable from the remote.\n \t\t */\n-\t\tspf->result = 1;\n-\n-\t\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\",\n-\t\t\t    task->sub->name);\n+\t\trecord_fetch_error(spf, task->sub->name);\n \t}\n \n \t/* Is this the second time we process this submodule? */\n@@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\tgoto out;\n \n \tit = string_list_lookup(&spf->changed_submodule_names, task->sub->name);\n-\tif (!it)\n-\t\t/* Could be an unchanged submodule, not contained in the list */\n+\tif (!it) {\n+\t\t/*\n+\t\t * This submodule is not in the changed list (e.g. it was\n+\t\t * fetched because RECURSE_SUBMODULES_ON fetches all populated\n+\t\t * submodules). A phase 1 failure here has no OID-based retry\n+\t\t * to fall back on, so it is a genuine error.\n+\t\t */\n+\t\tif (retvalue)\n+\t\t\trecord_fetch_error(spf, task->sub->name);\n \t\tgoto out;\n+\t}\n \n \tcs_data = it->util;\n \toid_array_filter(&cs_data->new_commits,\n@@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \n \t/* Are there commits we want, but do not exist? */\n \tif (cs_data->new_commits.nr) {\n+\t\t/*\n+\t\t * Schedule an OID-based phase 2 fetch to retrieve the missing\n+\t\t * commits directly. Defer any error from phase 1: if phase 2\n+\t\t * succeeds, the overall operation should still succeed.\n+\t\t */\n \t\ttask->commits = &cs_data->new_commits;\n \t\tALLOC_GROW(spf->oid_fetch_tasks,\n \t\t\t   spf->oid_fetch_tasks_nr + 1,\n@@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\treturn 0;\n \t}\n \n+\t/*\n+\t * All required commits are already present locally (they were either\n+\t * fetched by phase 1 or existed beforehand), so there is no phase 2\n+\t * retry to defer to. If phase 1 failed, the fetch itself went wrong\n+\t * (e.g. a transport error) and must still be reported, even though\n+\t * the gitlinked commits are available.\n+\t */\n+\tif (retvalue)\n+\t\trecord_fetch_error(spf, task->sub->name);\n+\n out:\n \tfetch_task_free(task);\n \treturn 0;\ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 1242ee9185..7ad274ce04 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1262,4 +1262,76 @@ test_expect_success \"fetch --all with --no-recurse-submodules only fetches super\n \t! grep \"Fetching submodule\" fetch-log\n '\n \n+# Create an isolated environment for submodule fetch error tests.\n+#\n+# Sets up sub_bare (the submodule upstream), super_bare (the superproject\n+# upstream), super_work (a working clone of super_bare with an initialized\n+# submodule), and clone (a clone of super_bare with an initialized submodule\n+# at a reachable commit). The caller can then create an unreachable commit\n+# and push the superproject to put the clone one commit behind a state it\n+# cannot fully fetch.\n+#\n+# Usage: create_err_env <envdir>\n+create_err_env () {\n+\tlocal envdir=\"$1\" &&\n+\tmkdir \"$envdir\" &&\n+\n+\tgit init --bare \"$envdir/sub_bare\" &&\n+\tgit clone \"$envdir/sub_bare\" \"$envdir/sub_work\" &&\n+\ttest_commit -C \"$envdir/sub_work\" \"${envdir}_base\" &&\n+\tgit -C \"$envdir/sub_work\" push &&\n+\n+\tgit init --bare \"$envdir/super_bare\" &&\n+\tgit clone \"$envdir/super_bare\" \"$envdir/super_work\" &&\n+\tgit -C \"$envdir/super_work\" submodule add \\\n+\t\t\"$pwd/$envdir/sub_bare\" sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"add submodule\" &&\n+\tgit -C \"$envdir/super_work\" push &&\n+\n+\tgit clone \"$envdir/super_bare\" \"$envdir/clone\" &&\n+\tgit -C \"$envdir/clone\" submodule update --init\n+}\n+\n+# Push a commit to <envdir>/super_bare that records a submodule SHA that is\n+# present locally in super_work/sub but NOT pushed to sub_bare, making the\n+# submodule commit unreachable from clone's sub remote.\n+push_unreachable_commit () {\n+\tlocal envdir=\"$1\" &&\n+\tgit -C \"$envdir/super_work/sub\" commit --allow-empty -m \"unreachable\" &&\n+\tgit -C \"$envdir/super_work\" add sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"point sub to unreachable commit\" &&\n+\tgit -C \"$envdir/super_work\" push\n+}\n+\n+test_expect_success 'setup for submodule fetch error tests' '\n+\tgit config --global protocol.file.allow always\n+'\n+\n+test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n+\t# Create the same commit (unreferenced, via commit-tree with fixed\n+\t# dates) in both super_work/sub and clone/sub, point the gitlink at\n+\t# it, and break clone/sub'\\''s remote. The commit exists in clone/sub\n+\t# but is unreachable, so the submodule stays in the changed list; the\n+\t# fetch failure must still be reported even though there is nothing\n+\t# left to fetch by commit hash.\n+\ttest_when_finished \"rm -fr env_phase1\" &&\n+\tcreate_err_env env_phase1 &&\n+\tcommit=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t git -C env_phase1/super_work/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\tpresent=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t  GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t  git -C env_phase1/clone/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\ttest \"$commit\" = \"$present\" &&\n+\tgit -C env_phase1/super_work/sub checkout \"$commit\" &&\n+\tgit -C env_phase1/super_work add sub &&\n+\tgit -C env_phase1/super_work commit -m \"gitlink to locally-present commit\" &&\n+\tgit -C env_phase1/super_work push &&\n+\tgit -C env_phase1/clone/sub remote set-url origin \"$pwd/env_phase1/missing\" &&\n+\ttest_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"548272","messageId":"20260715103518.526326-3-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260715103518.526326-1-paulius.zaleckas@gmail.com","subject":"[PATCH v5 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-15T10:35:16Z","receivedAt":"2026-07-15T10:35:27Z","isPatch":true,"body":"When fetching with --recurse-submodules, a submodule commit that is not\nyet reachable from any of the submodule's remote refs causes the entire\nfetch to fail.  This is overly strict when the missing commit belongs to\nan upstream branch that is still being prepared (e.g. an in-progress\nmerge topic): the local branch does not need that commit, so there is no\nreason to treat its absence as fatal.\n\nAdd a new config key fetch.submoduleErrors (values: fail/warn) and a\ncorresponding --submodule-errors=(fail|warn) command-line option that\ncontrol this behaviour.  The default remains fail (existing behaviour);\nsetting the value to warn causes submodule fetch failures to be reported\non stderr without affecting the overall exit status of git fetch / git\npull.\n\nForward the option to child fetches in add_options_to_argv() so that it\nalso takes effect for `git fetch --all` / `--multiple` (where per-remote\nchild processes handle the submodule recursion themselves) and for\nnested submodule recursion.  The resolved value is forwarded whenever it\nwas set explicitly, in either direction: the per-remote children re-read\nthe repository configuration, so a command-line --submodule-errors=fail\nmust be passed down to them to override fetch.submoduleErrors=warn from\nthe configuration.  When neither the configuration nor the command line\nsets a value, nothing is forwarded and the child processes fall back to\ntheir own configuration.\n\nHelped-by: Jean-Noël Avila <avila.jn@gmail.com>\nHelped-by: Ramsay Jones <ramsay@ramsayjones.plus.com>\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n Documentation/config/fetch.adoc  | 14 +++++\n Documentation/fetch-options.adoc |  8 +++\n builtin/fetch.c                  | 72 +++++++++++++++++++++++++-\n submodule.c                      |  8 ++-\n submodule.h                      |  7 ++-\n t/t5526-fetch-submodules.sh      | 89 ++++++++++++++++++++++++++++++++\n 6 files changed, 194 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\nindex 04ac90912d..5c9c942a70 100644\n--- a/Documentation/config/fetch.adoc\n+++ b/Documentation/config/fetch.adoc\n@@ -10,6 +10,20 @@\n \treference.\n \tDefaults to `on-demand`, or to the value of `submodule.recurse` if set.\n \n+`fetch.submoduleErrors`::\n+\tControls how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` or `git pull`\n+\tto exit with a non-zero status. When set to `warn`, submodule fetch\n+\terrors are reported to standard error but do not affect the exit\n+\tstatus of the command. This is useful when working in repositories\n+\twhere some branches reference submodule commits that are not yet\n+\tavailable on the submodule remote, but those commits are not needed\n+\tfor the currently checked-out branch.\n++\n+The value of this option can be overridden by the `--submodule-errors`\n+option of linkgit:git-fetch[1].\n+\n `fetch.fsckObjects`::\n \tIf it is set to true, git-fetch-pack will check all fetched\n \tobjects. See `transfer.fsckObjects` for what's\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 035f780e58..78525f6848 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -294,6 +294,14 @@ ifndef::git-pull[]\n `--no-recurse-submodules`::\n \tDisable recursive fetching of submodules (this has the same effect as\n \tusing the `--recurse-submodules=no` option).\n+\n+`--submodule-errors=(fail|warn)`::\n+\tControl how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` to exit with a\n+\tnon-zero status. When set to `warn`, submodule fetch errors are reported\n+\tto standard error but do not affect the exit status of the command. Can\n+\talso be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].\n endif::git-pull[]\n \n `--set-upstream`::\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f4..b0eb1eb301 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -110,8 +110,32 @@ struct fetch_config {\n \tint recurse_submodules;\n \tint parallel;\n \tint submodule_fetch_jobs;\n+\tint submodule_errors;\n };\n \n+/* really private - use accessors below to parse and format */\n+static const char *submodule_errors_names[] = {\n+\t[SUBMODULE_ERRORS_FAIL] = \"fail\",\n+\t[SUBMODULE_ERRORS_WARN] = \"warn\",\n+};\n+\n+static const char *submodule_errors_to_string(int mode)\n+{\n+\tif (mode < 0 || (size_t)mode >= ARRAY_SIZE(submodule_errors_names))\n+\t\tBUG(\"invalid submodule errors mode %d\", mode);\n+\treturn submodule_errors_names[mode];\n+}\n+\n+static int parse_submodule_errors(const char *name)\n+{\n+\tsize_t i;\n+\n+\tfor (i = 0; i < ARRAY_SIZE(submodule_errors_names); i++)\n+\t\tif (!strcmp(submodule_errors_names[i], name))\n+\t\t\treturn i;\n+\treturn -1;\n+}\n+\n static int git_fetch_config(const char *k, const char *v,\n \t\t\t    const struct config_context *ctx, void *cb)\n {\n@@ -152,6 +176,19 @@ static int git_fetch_config(const char *k, const char *v,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n+\t\tint mode;\n+\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\t\tmode = parse_submodule_errors(v);\n+\t\tif (mode < 0)\n+\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n+\t\t\t    \"fetch.submoduleErrors\", v);\n+\t\tfetch_config->submodule_errors = mode;\n+\t\treturn 0;\n+\t}\n+\n \tif (!strcmp(k, \"fetch.parallel\")) {\n \t\tfetch_config->parallel = git_config_int(k, v, ctx->kvi);\n \t\tif (fetch_config->parallel < 0)\n@@ -2205,6 +2242,9 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n+\tif (config->submodule_errors != -1)\n+\t\tstrvec_pushf(argv, \"--submodule-errors=%s\",\n+\t\t\t     submodule_errors_to_string(config->submodule_errors));\n \tif (tags == TAGS_SET)\n \t\tstrvec_push(argv, \"--tags\");\n \telse if (tags == TAGS_UNSET)\n@@ -2464,6 +2504,23 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \treturn exit_code;\n }\n \n+static int option_parse_submodule_errors(const struct option *opt,\n+\t\t\t\t\t  const char *arg, int unset)\n+{\n+\tint *v = opt->value;\n+\tint mode;\n+\n+\tif (unset) {\n+\t\t*v = SUBMODULE_ERRORS_FAIL;\n+\t\treturn 0;\n+\t}\n+\tmode = parse_submodule_errors(arg);\n+\tif (mode < 0)\n+\t\tdie(_(\"invalid value for '%s': '%s'\"), \"--submodule-errors\", arg);\n+\t*v = mode;\n+\treturn 0;\n+}\n+\n int cmd_fetch(int argc,\n \t      const char **argv,\n \t      const char *prefix,\n@@ -2477,6 +2534,7 @@ int cmd_fetch(int argc,\n \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n \t\t.parallel = 1,\n \t\t.submodule_fetch_jobs = -1,\n+\t\t.submodule_errors = -1, /* unset */\n \t};\n \tconst char *submodule_prefix = \"\";\n \tconst char *bundle_uri;\n@@ -2491,6 +2549,7 @@ int cmd_fetch(int argc,\n \tint max_jobs = -1;\n \tint recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;\n \tint recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;\n+\tint submodule_errors_cli = -1; /* -1: not set on command line */\n \tint fetch_write_commit_graph = -1;\n \tint stdin_refspecs = 0;\n \tint negotiate_only = 0;\n@@ -2527,6 +2586,10 @@ int cmd_fetch(int argc,\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n+\t\tOPT_CALLBACK_F(0, \"submodule-errors\", &submodule_errors_cli,\n+\t\t\t    N_(\"(fail|warn)\"),\n+\t\t\t    N_(\"control how submodule fetch errors are handled\"),\n+\t\t\t    0, option_parse_submodule_errors),\n \t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n \t\t\t N_(\"dry run\")),\n \t\tOPT_BOOL(0, \"porcelain\", &porcelain, N_(\"machine-readable output\")),\n@@ -2616,6 +2679,9 @@ int cmd_fetch(int argc,\n \tif (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)\n \t\tconfig.recurse_submodules = recurse_submodules_cli;\n \n+\tif (submodule_errors_cli != -1)\n+\t\tconfig.submodule_errors = submodule_errors_cli;\n+\n \tif (negotiate_only) {\n \t\tswitch (recurse_submodules_cli) {\n \t\tcase RECURSE_SUBMODULES_OFF:\n@@ -2819,11 +2885,14 @@ int cmd_fetch(int argc,\n \tif (!result && remote && (config.recurse_submodules != RECURSE_SUBMODULES_OFF)) {\n \t\tstruct strvec options = STRVEC_INIT;\n \t\tint max_children = max_jobs;\n+\t\tint submodule_errors = config.submodule_errors;\n \n \t\tif (max_children < 0)\n \t\t\tmax_children = config.submodule_fetch_jobs;\n \t\tif (max_children < 0)\n \t\t\tmax_children = config.parallel;\n+\t\tif (submodule_errors < 0)\n+\t\t\tsubmodule_errors = SUBMODULE_ERRORS_FAIL;\n \n \t\tadd_options_to_argv(&options, &config);\n \t\ttrace2_region_enter_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n@@ -2833,7 +2902,8 @@ int cmd_fetch(int argc,\n \t\t\t\t\t  config.recurse_submodules,\n \t\t\t\t\t  recurse_submodules_default,\n \t\t\t\t\t  verbosity < 0,\n-\t\t\t\t\t  max_children);\n+\t\t\t\t\t  max_children,\n+\t\t\t\t\t  submodule_errors);\n \t\ttrace2_region_leave_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n \t\tstrvec_clear(&options);\n \t}\ndiff --git a/submodule.c b/submodule.c\nindex 8bcef68a42..da4ace751f 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch {\n \tint oid_fetch_tasks_nr, oid_fetch_tasks_alloc;\n \n \tstruct strbuf submodules_with_errors;\n+\tint submodule_errors;\n };\n #define SPF_INIT { \\\n \t.args = STRVEC_INIT, \\\n@@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n static void record_fetch_error(struct submodule_parallel_fetch *spf,\n \t\t\t       const char *name)\n {\n-\tspf->result = 1;\n+\tif (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)\n+\t\tspf->result = 1;\n \tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n }\n \n@@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix, int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs)\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors)\n {\n \tstruct submodule_parallel_fetch spf = SPF_INIT;\n \tconst struct run_process_parallel_opts opts = {\n@@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r,\n \tspf.default_option = default_option;\n \tspf.quiet = quiet;\n \tspf.prefix = prefix;\n+\tspf.submodule_errors = submodule_errors;\n \n \tif (!r->worktree)\n \t\tgoto out;\ndiff --git a/submodule.h b/submodule.h\nindex b10e16e6c0..c80b687d2a 100644\n--- a/submodule.h\n+++ b/submodule.h\n@@ -90,12 +90,17 @@ int should_update_submodules(void);\n  */\n const struct submodule *submodule_from_ce(const struct cache_entry *ce);\n void check_for_new_submodule_commits(struct object_id *oid);\n+/* Values for the submodule_errors parameter of fetch_submodules(). */\n+#define SUBMODULE_ERRORS_FAIL 0  /* submodule fetch errors are fatal (default) */\n+#define SUBMODULE_ERRORS_WARN 1  /* submodule fetch errors are non-fatal warnings */\n+\n int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix,\n \t\t     int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs);\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors);\n unsigned is_submodule_modified(const char *path, int ignore_untracked);\n int submodule_uses_gitfile(const char *path);\n \ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 7ad274ce04..19d17440cf 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' '\n \tgit config --global protocol.file.allow always\n '\n \n+test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' '\n+\ttest_when_finished \"rm -fr env_default\" &&\n+\tcreate_err_env env_default &&\n+\tpush_unreachable_commit env_default &&\n+\ttest_must_fail git -C env_default/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cfg\" &&\n+\tcreate_err_env env_warn_cfg &&\n+\tpush_unreachable_commit env_warn_cfg &&\n+\tgit -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cli\" &&\n+\tcreate_err_env env_warn_cli &&\n+\tpush_unreachable_commit env_warn_cli &&\n+\tgit -C env_warn_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' '\n+\ttest_when_finished \"rm -fr env_fail_cli\" &&\n+\tcreate_err_env env_fail_cli &&\n+\tpush_unreachable_commit env_fail_cli &&\n+\ttest_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' '\n+\t# A new reachable submodule commit (pushed to sub_bare) should be\n+\t# fetched without any error summary.\n+\ttest_when_finished \"rm -fr env_ok\" &&\n+\tcreate_err_env env_ok &&\n+\ttest_commit -C env_ok/sub_work reachable_ok &&\n+\tgit -C env_ok/sub_work push &&\n+\tgit -C env_ok/super_work submodule update --remote &&\n+\tgit -C env_ok/super_work add sub &&\n+\tgit -C env_ok/super_work commit -m \"point sub to reachable commit\" &&\n+\tgit -C env_ok/super_work push &&\n+\tgit -C env_ok/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep ! \"Errors during submodule fetch\" err\n+'\n+\n test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n \t# Create the same commit (unreferenced, via commit-tree with fixed\n \t# dates) in both super_work/sub and clone/sub, point the gitlink at\n@@ -1334,4 +1385,42 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p\n \ttest_grep \"Errors during submodule fetch\" err\n '\n \n+test_expect_success '--submodule-errors=warn is honored by fetch --all' '\n+\t# A second remote forces fetch_multiple(), which hands the submodule\n+\t# recursion off to per-remote child processes; the option must be\n+\t# forwarded to them.\n+\ttest_when_finished \"rm -fr env_all\" &&\n+\tcreate_err_env env_all &&\n+\tpush_unreachable_commit env_all &&\n+\tgit -C env_all/clone remote add second \"$pwd/env_all/super_bare\" &&\n+\tgit -C env_all/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail overrides warn config for fetch --all' '\n+\t# The per-remote child processes re-read the repository config, so\n+\t# the command-line override must be forwarded to them explicitly.\n+\ttest_when_finished \"rm -fr env_override\" &&\n+\tcreate_err_env env_override &&\n+\tpush_unreachable_commit env_override &&\n+\tgit -C env_override/clone remote add second \"$pwd/env_override/super_bare\" &&\n+\tgit -C env_override/clone config fetch.submoduleErrors warn &&\n+\ttest_must_fail git -C env_override/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '\n+\ttest_when_finished \"rm -fr env_access\" &&\n+\tcreate_err_env env_access &&\n+\trm env_access/clone/sub/.git &&\n+\trm -r env_access/clone/.git/modules/sub &&\n+\tgit -C env_access/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep \"Could not access submodule\" err &&\n+\ttest_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Could not access submodule\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"548319","messageId":"xmqq7bmwm5g6.fsf@gitster.g","threadId":"65970","inReplyTo":"20260715103518.526326-3-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v5 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-15T19:13:45Z","receivedAt":"2026-07-15T19:13:53Z","isPatch":true,"body":"Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:\n\n> +/* really private - use accessors below to parse and format */\n> +static const char *submodule_errors_names[] = {\n> +\t[SUBMODULE_ERRORS_FAIL] = \"fail\",\n> +\t[SUBMODULE_ERRORS_WARN] = \"warn\",\n> +};\n> +\n> +static const char *submodule_errors_to_string(int mode)\n> +{\n> +\tif (mode < 0 || (size_t)mode >= ARRAY_SIZE(submodule_errors_names))\n> +\t\tBUG(\"invalid submodule errors mode %d\", mode);\n> +\treturn submodule_errors_names[mode];\n> +}\n> +\n\nI am ranting here, and it is not entirely your fault, but I\nhave to mention that this is the kind of bad code that\n\"-Wsign-compare\" forces on us.  We know that 'mode' is a small\ninteger used to index into the submodule_errors_names[] array.\nTheoretically, an array might contain as many elements as\n(size_t)(-1), but we know nobody needs to feed us a number\nthat does not fit in a platform-natural \"int\".\n\n\tSide note: submodule_errors_names[] is a horrible name.\n\tIt should be submodule_error_name[].  Look for \"Array names\"\n\tin the CodingGuidelines document.\n\nWorking around \"-Wsign-compare\" has forced an unnecessary cast on\nus here.  If anything, we could have just done:\n\n\tstatic const char *submodule_errors_to_string(unsigned mode)\n\nand\n\n\tif (ARRAY_SIZE(submodule_error_names) <= mode)\n\t\tBUG(...);\n\nwhich would have been vastly more readable.  To me, a plain \"int\"\nis also fine, but if we must squelch \"-Wsign-compare\", using\n\"unsigned\" is much saner than turning everything into \"size_t\".\n\n> +static int parse_submodule_errors(const char *name)\n> +{\n> +\tsize_t i;\n> +\n> +\tfor (i = 0; i < ARRAY_SIZE(submodule_errors_names); i++)\n> +\t\tif (!strcmp(submodule_errors_names[i], name))\n> +\t\t\treturn i;\n> +\treturn -1;\n> +}\n\nAnd there is no sensible way to justify \"size_t i\" here.  Using\na platform-natural \"unsigned\" would have been much easier to\nunderstand.\n\nIt is a disease to bend our code only to appease the compiler's\nwarnings; we should resist such temptation.\n\nAlso worth reading:\n\nhttps://staticthinking.wordpress.com/2023/07/25/wsign-compare-is-garbage/\n\nThanks.\n"},{"id":"548413","messageId":"20260716140956.1023740-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260715103518.526326-1-paulius.zaleckas@gmail.com","subject":"[PATCH v6 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-16T14:09:52Z","receivedAt":"2026-07-16T14:10:00Z","isPatch":true,"body":"When fetching with --recurse-submodules, git currently exits with a\nnon-zero status if any submodule references an OID that is not reachable\nfrom the submodule's remote.  This situation arises naturally when an\nupstream branch is still in preparation (e.g. a topic branch in a merge\nwindow): the local branch does not depend on the missing commit, so a\nhard failure is unnecessarily disruptive.\n\nPatch 1 fixes a pre-existing NEEDSWORK in submodule.c where a phase-1\nfetch failure was recorded immediately, even when a phase-2 OID-based\nretry was about to be scheduled.  After this fix the existing fatal\nbehaviour is preserved but the logic is now structured so that errors\nare only recorded when the phase-2 retry actually fails, or when there\nis no phase-2 retry to fall back on.\n\nPatch 2 introduces fetch.submoduleErrors (fail|warn) and\n--submodule-errors=(fail|warn) to let users opt into non-fatal\nbehaviour.  The default remains fail for full backwards compatibility.\n\nChanges in v6:\n- Clean up the fail/warn helpers to match the example Junio gave\n  earlier: singular array name, plain unsigned instead of size_t\n  casts\n\nChanges in v5:\n- Use test_grep instead of raw grep in the new tests (Ramsay, Junio)\n- Parse and format the fail/warn values through a single name array\n  shared by config, option parsing and option forwarding; values are\n  now matched case-sensitively (Junio)\n- Credit Jean-Noël for the v2 documentation fixes, which I forgot to\n  do back then\n\nChanges in v4:\n- Forward an explicit --submodule-errors=fail to child fetches as well,\n  so the command line overrides fetch.submoduleErrors=warn config in\n  the per-remote children of fetch --all/--multiple (noticed by Junio)\n\nChanges in v3:\n- Report a phase-1 failure also when the gitlink commits are already\n  present locally, instead of silently succeeding\n- Route \"Could not access submodule\" through record_fetch_error() so it\n  shows up in the error summary and honors the warn mode\n- Forward --submodule-errors to child fetches so it takes effect for\n  fetch --all/--multiple and nested submodule recursion\n- Add tests for all of the above\n- Documentation: don't imply git pull takes --submodule-errors, minor\n  wording and placement fixes\n\nChanges in v2:\n- Fix option synopsis to use (fail|warn) instead of <fail|warn>\n  (Jean-Noël)\n- Add --submodule-errors documentation to Documentation/fetch-options.adoc\n  (Jean-Noël)\n\nPaulius Zaleckas (2):\n  submodule: fix premature failure in recursive submodule fetch\n  fetch: add fetch.submoduleErrors to make submodule fetch errors\n    non-fatal\n\n Documentation/config/fetch.adoc  |  14 +++\n Documentation/fetch-options.adoc |   8 ++\n builtin/fetch.c                  |  70 +++++++++++++-\n submodule.c                      |  58 ++++++++---\n submodule.h                      |   7 +-\n t/t5526-fetch-submodules.sh      | 161 +++++++++++++++++++++++++++++++\n 6 files changed, 301 insertions(+), 17 deletions(-)\n\n-- \n2.54.0\n\n"},{"id":"548414","messageId":"20260716140956.1023740-2-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260716140956.1023740-1-paulius.zaleckas@gmail.com","subject":"[PATCH v6 1/2] submodule: fix premature failure in recursive submodule fetch","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-16T14:09:53Z","receivedAt":"2026-07-16T14:10:01Z","isPatch":true,"body":"When git fetch --recurse-submodules encounters a failure fetching a\nsubmodule's refs (phase 1), it immediately marks the overall operation\nas failed, even though a subsequent OID-based fetch (phase 2) is about\nto be attempted for any missing commits.  If phase 2 succeeds, the\noverall result should be success, but the prematurely set failure flag\nmakes it look like an error.\n\nRestructure fetch_finish() so that a phase-1 failure does not record an\nerror immediately.  Instead, the decision is deferred:\n\n - If missing commits trigger a phase-2 (OID-based) retry and that\n   retry succeeds, no error is recorded.\n - If the phase-2 retry also fails, the error is recorded then.\n - If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON)\n   and is not in the changed list, a phase-1 failure is recorded right\n   away since there is no OID retry to fall back on.\n - If phase 1 fails but all required commits are already present\n   locally, there is no retry to defer to; the failure is still\n   recorded, since the fetch itself went wrong (e.g. a transport\n   error) even though the wanted commits happen to be available.\n\nThis resolves the NEEDSWORK comment added by bd5e567dc7 (submodule:\nexplain first attempt failure clearly, 2019-03-13).\n\nExtract the common error-recording logic into a helper\nrecord_fetch_error() and use it in fetch_start_failure() and for the\n\"Could not access submodule\" error in get_fetch_task_from_index() as\nwell; the latter now also lists the submodule in the final error\nsummary.\n\nAdd a test ensuring a failed submodule fetch is still reported when\nthe gitlinked commits happen to be present locally.\n\nHelped-by: Ramsay Jones <ramsay@ramsayjones.plus.com>\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n submodule.c                 | 52 +++++++++++++++++++--------\n t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++\n 2 files changed, 110 insertions(+), 14 deletions(-)\n\ndiff --git a/submodule.c b/submodule.c\nindex fd91201a92..8bcef68a42 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n \treturn NULL;\n }\n \n+static void record_fetch_error(struct submodule_parallel_fetch *spf,\n+\t\t\t       const char *name)\n+{\n+\tspf->result = 1;\n+\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n+}\n+\n static struct fetch_task *\n get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t  struct strbuf *err)\n@@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf,\n \t\t\t\t\t\t\tce->name);\n \t\t\tif (S_ISGITLINK(ce->ce_mode) &&\n \t\t\t    !is_empty_dir(empty_submodule_path.buf)) {\n-\t\t\t\tspf->result = 1;\n+\t\t\t\trecord_fetch_error(spf, ce->name);\n \t\t\t\tstrbuf_addf(err,\n \t\t\t\t\t    _(\"Could not access submodule '%s'\\n\"),\n \t\t\t\t\t    ce->name);\n@@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED,\n \tstruct submodule_parallel_fetch *spf = cb;\n \tstruct fetch_task *task = task_cb;\n \n-\tspf->result = 1;\n+\trecord_fetch_error(spf, task->sub->name);\n \n \tfetch_task_free(task);\n \treturn 0;\n@@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \tif (!task || !task->sub)\n \t\tBUG(\"callback cookie bogus\");\n \n-\tif (retvalue) {\n+\tif (retvalue && task->commits) {\n \t\t/*\n-\t\t * NEEDSWORK: This indicates that the overall fetch\n-\t\t * failed, even though there may be a subsequent fetch\n-\t\t * by commit hash that might work. It may be a good\n-\t\t * idea to not indicate failure in this case, and only\n-\t\t * indicate failure if the subsequent fetch fails.\n+\t\t * This is the second pass (OID-based fetch) and it failed.\n+\t\t * The commits are genuinely unavailable from the remote.\n \t\t */\n-\t\tspf->result = 1;\n-\n-\t\tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\",\n-\t\t\t    task->sub->name);\n+\t\trecord_fetch_error(spf, task->sub->name);\n \t}\n \n \t/* Is this the second time we process this submodule? */\n@@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\tgoto out;\n \n \tit = string_list_lookup(&spf->changed_submodule_names, task->sub->name);\n-\tif (!it)\n-\t\t/* Could be an unchanged submodule, not contained in the list */\n+\tif (!it) {\n+\t\t/*\n+\t\t * This submodule is not in the changed list (e.g. it was\n+\t\t * fetched because RECURSE_SUBMODULES_ON fetches all populated\n+\t\t * submodules). A phase 1 failure here has no OID-based retry\n+\t\t * to fall back on, so it is a genuine error.\n+\t\t */\n+\t\tif (retvalue)\n+\t\t\trecord_fetch_error(spf, task->sub->name);\n \t\tgoto out;\n+\t}\n \n \tcs_data = it->util;\n \toid_array_filter(&cs_data->new_commits,\n@@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \n \t/* Are there commits we want, but do not exist? */\n \tif (cs_data->new_commits.nr) {\n+\t\t/*\n+\t\t * Schedule an OID-based phase 2 fetch to retrieve the missing\n+\t\t * commits directly. Defer any error from phase 1: if phase 2\n+\t\t * succeeds, the overall operation should still succeed.\n+\t\t */\n \t\ttask->commits = &cs_data->new_commits;\n \t\tALLOC_GROW(spf->oid_fetch_tasks,\n \t\t\t   spf->oid_fetch_tasks_nr + 1,\n@@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED,\n \t\treturn 0;\n \t}\n \n+\t/*\n+\t * All required commits are already present locally (they were either\n+\t * fetched by phase 1 or existed beforehand), so there is no phase 2\n+\t * retry to defer to. If phase 1 failed, the fetch itself went wrong\n+\t * (e.g. a transport error) and must still be reported, even though\n+\t * the gitlinked commits are available.\n+\t */\n+\tif (retvalue)\n+\t\trecord_fetch_error(spf, task->sub->name);\n+\n out:\n \tfetch_task_free(task);\n \treturn 0;\ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 1242ee9185..7ad274ce04 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1262,4 +1262,76 @@ test_expect_success \"fetch --all with --no-recurse-submodules only fetches super\n \t! grep \"Fetching submodule\" fetch-log\n '\n \n+# Create an isolated environment for submodule fetch error tests.\n+#\n+# Sets up sub_bare (the submodule upstream), super_bare (the superproject\n+# upstream), super_work (a working clone of super_bare with an initialized\n+# submodule), and clone (a clone of super_bare with an initialized submodule\n+# at a reachable commit). The caller can then create an unreachable commit\n+# and push the superproject to put the clone one commit behind a state it\n+# cannot fully fetch.\n+#\n+# Usage: create_err_env <envdir>\n+create_err_env () {\n+\tlocal envdir=\"$1\" &&\n+\tmkdir \"$envdir\" &&\n+\n+\tgit init --bare \"$envdir/sub_bare\" &&\n+\tgit clone \"$envdir/sub_bare\" \"$envdir/sub_work\" &&\n+\ttest_commit -C \"$envdir/sub_work\" \"${envdir}_base\" &&\n+\tgit -C \"$envdir/sub_work\" push &&\n+\n+\tgit init --bare \"$envdir/super_bare\" &&\n+\tgit clone \"$envdir/super_bare\" \"$envdir/super_work\" &&\n+\tgit -C \"$envdir/super_work\" submodule add \\\n+\t\t\"$pwd/$envdir/sub_bare\" sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"add submodule\" &&\n+\tgit -C \"$envdir/super_work\" push &&\n+\n+\tgit clone \"$envdir/super_bare\" \"$envdir/clone\" &&\n+\tgit -C \"$envdir/clone\" submodule update --init\n+}\n+\n+# Push a commit to <envdir>/super_bare that records a submodule SHA that is\n+# present locally in super_work/sub but NOT pushed to sub_bare, making the\n+# submodule commit unreachable from clone's sub remote.\n+push_unreachable_commit () {\n+\tlocal envdir=\"$1\" &&\n+\tgit -C \"$envdir/super_work/sub\" commit --allow-empty -m \"unreachable\" &&\n+\tgit -C \"$envdir/super_work\" add sub &&\n+\tgit -C \"$envdir/super_work\" commit -m \"point sub to unreachable commit\" &&\n+\tgit -C \"$envdir/super_work\" push\n+}\n+\n+test_expect_success 'setup for submodule fetch error tests' '\n+\tgit config --global protocol.file.allow always\n+'\n+\n+test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n+\t# Create the same commit (unreferenced, via commit-tree with fixed\n+\t# dates) in both super_work/sub and clone/sub, point the gitlink at\n+\t# it, and break clone/sub'\\''s remote. The commit exists in clone/sub\n+\t# but is unreachable, so the submodule stays in the changed list; the\n+\t# fetch failure must still be reported even though there is nothing\n+\t# left to fetch by commit hash.\n+\ttest_when_finished \"rm -fr env_phase1\" &&\n+\tcreate_err_env env_phase1 &&\n+\tcommit=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t git -C env_phase1/super_work/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\tpresent=$(GIT_AUTHOR_DATE=\"1234567890 +0000\" \\\n+\t\t  GIT_COMMITTER_DATE=\"1234567890 +0000\" \\\n+\t\t  git -C env_phase1/clone/sub commit-tree \\\n+\t\t\t\"HEAD^{tree}\" -p HEAD -m present) &&\n+\ttest \"$commit\" = \"$present\" &&\n+\tgit -C env_phase1/super_work/sub checkout \"$commit\" &&\n+\tgit -C env_phase1/super_work add sub &&\n+\tgit -C env_phase1/super_work commit -m \"gitlink to locally-present commit\" &&\n+\tgit -C env_phase1/super_work push &&\n+\tgit -C env_phase1/clone/sub remote set-url origin \"$pwd/env_phase1/missing\" &&\n+\ttest_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"548415","messageId":"20260716140956.1023740-3-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260716140956.1023740-1-paulius.zaleckas@gmail.com","subject":"[PATCH v6 2/2] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-07-16T14:09:54Z","receivedAt":"2026-07-16T14:10:03Z","isPatch":true,"body":"When fetching with --recurse-submodules, a submodule commit that is not\nyet reachable from any of the submodule's remote refs causes the entire\nfetch to fail.  This is overly strict when the missing commit belongs to\nan upstream branch that is still being prepared (e.g. an in-progress\nmerge topic): the local branch does not need that commit, so there is no\nreason to treat its absence as fatal.\n\nAdd a new config key fetch.submoduleErrors (values: fail/warn) and a\ncorresponding --submodule-errors=(fail|warn) command-line option that\ncontrol this behaviour.  The default remains fail (existing behaviour);\nsetting the value to warn causes submodule fetch failures to be reported\non stderr without affecting the overall exit status of git fetch / git\npull.\n\nForward the option to child fetches in add_options_to_argv() so that it\nalso takes effect for `git fetch --all` / `--multiple` (where per-remote\nchild processes handle the submodule recursion themselves) and for\nnested submodule recursion.  The resolved value is forwarded whenever it\nwas set explicitly, in either direction: the per-remote children re-read\nthe repository configuration, so a command-line --submodule-errors=fail\nmust be passed down to them to override fetch.submoduleErrors=warn from\nthe configuration.  When neither the configuration nor the command line\nsets a value, nothing is forwarded and the child processes fall back to\ntheir own configuration.\n\nHelped-by: Jean-Noël Avila <avila.jn@gmail.com>\nHelped-by: Ramsay Jones <ramsay@ramsayjones.plus.com>\nHelped-by: Junio C Hamano <gitster@pobox.com>\nSigned-off-by: Paulius Zaleckas <paulius.zaleckas@gmail.com>\n---\n Documentation/config/fetch.adoc  | 14 +++++\n Documentation/fetch-options.adoc |  8 +++\n builtin/fetch.c                  | 70 ++++++++++++++++++++++++-\n submodule.c                      |  8 ++-\n submodule.h                      |  7 ++-\n t/t5526-fetch-submodules.sh      | 89 ++++++++++++++++++++++++++++++++\n 6 files changed, 192 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\nindex 04ac90912d..5c9c942a70 100644\n--- a/Documentation/config/fetch.adoc\n+++ b/Documentation/config/fetch.adoc\n@@ -10,6 +10,20 @@\n \treference.\n \tDefaults to `on-demand`, or to the value of `submodule.recurse` if set.\n \n+`fetch.submoduleErrors`::\n+\tControls how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` or `git pull`\n+\tto exit with a non-zero status. When set to `warn`, submodule fetch\n+\terrors are reported to standard error but do not affect the exit\n+\tstatus of the command. This is useful when working in repositories\n+\twhere some branches reference submodule commits that are not yet\n+\tavailable on the submodule remote, but those commits are not needed\n+\tfor the currently checked-out branch.\n++\n+The value of this option can be overridden by the `--submodule-errors`\n+option of linkgit:git-fetch[1].\n+\n `fetch.fsckObjects`::\n \tIf it is set to true, git-fetch-pack will check all fetched\n \tobjects. See `transfer.fsckObjects` for what's\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 035f780e58..78525f6848 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -294,6 +294,14 @@ ifndef::git-pull[]\n `--no-recurse-submodules`::\n \tDisable recursive fetching of submodules (this has the same effect as\n \tusing the `--recurse-submodules=no` option).\n+\n+`--submodule-errors=(fail|warn)`::\n+\tControl how errors from submodule fetches are handled when\n+\t`--recurse-submodules` is in effect. When set to `fail` (the default),\n+\tany submodule fetch error causes the overall `git fetch` to exit with a\n+\tnon-zero status. When set to `warn`, submodule fetch errors are reported\n+\tto standard error but do not affect the exit status of the command. Can\n+\talso be configured via `fetch.submoduleErrors`. See linkgit:git-config[1].\n endif::git-pull[]\n \n `--set-upstream`::\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex c1d7c672f4..2c583ed0cc 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -110,8 +110,30 @@ struct fetch_config {\n \tint recurse_submodules;\n \tint parallel;\n \tint submodule_fetch_jobs;\n+\tint submodule_errors;\n };\n \n+/* really private - use accessors below to parse and format */\n+static const char *submodule_error_name[] = {\n+\t[SUBMODULE_ERRORS_FAIL] = \"fail\",\n+\t[SUBMODULE_ERRORS_WARN] = \"warn\",\n+};\n+\n+static const char *submodule_error(unsigned num)\n+{\n+\tif (ARRAY_SIZE(submodule_error_name) <= num)\n+\t\tBUG(\"invalid submodule errors mode %u\", num);\n+\treturn submodule_error_name[num];\n+}\n+\n+static int parse_submodule_error(const char *name)\n+{\n+\tfor (unsigned num = 0; num < ARRAY_SIZE(submodule_error_name); num++)\n+\t\tif (!strcmp(submodule_error_name[num], name))\n+\t\t\treturn num;\n+\treturn -1;\n+}\n+\n static int git_fetch_config(const char *k, const char *v,\n \t\t\t    const struct config_context *ctx, void *cb)\n {\n@@ -152,6 +174,19 @@ static int git_fetch_config(const char *k, const char *v,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(k, \"fetch.submoduleerrors\")) {\n+\t\tint mode;\n+\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\t\tmode = parse_submodule_error(v);\n+\t\tif (mode < 0)\n+\t\t\tdie(_(\"invalid value for '%s': '%s'\"),\n+\t\t\t    \"fetch.submoduleErrors\", v);\n+\t\tfetch_config->submodule_errors = mode;\n+\t\treturn 0;\n+\t}\n+\n \tif (!strcmp(k, \"fetch.parallel\")) {\n \t\tfetch_config->parallel = git_config_int(k, v, ctx->kvi);\n \t\tif (fetch_config->parallel < 0)\n@@ -2205,6 +2240,9 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, \"--no-recurse-submodules\");\n \telse if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND)\n \t\tstrvec_push(argv, \"--recurse-submodules=on-demand\");\n+\tif (config->submodule_errors != -1)\n+\t\tstrvec_pushf(argv, \"--submodule-errors=%s\",\n+\t\t\t     submodule_error(config->submodule_errors));\n \tif (tags == TAGS_SET)\n \t\tstrvec_push(argv, \"--tags\");\n \telse if (tags == TAGS_UNSET)\n@@ -2464,6 +2502,23 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \treturn exit_code;\n }\n \n+static int option_parse_submodule_errors(const struct option *opt,\n+\t\t\t\t\t  const char *arg, int unset)\n+{\n+\tint *v = opt->value;\n+\tint mode;\n+\n+\tif (unset) {\n+\t\t*v = SUBMODULE_ERRORS_FAIL;\n+\t\treturn 0;\n+\t}\n+\tmode = parse_submodule_error(arg);\n+\tif (mode < 0)\n+\t\tdie(_(\"invalid value for '%s': '%s'\"), \"--submodule-errors\", arg);\n+\t*v = mode;\n+\treturn 0;\n+}\n+\n int cmd_fetch(int argc,\n \t      const char **argv,\n \t      const char *prefix,\n@@ -2477,6 +2532,7 @@ int cmd_fetch(int argc,\n \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n \t\t.parallel = 1,\n \t\t.submodule_fetch_jobs = -1,\n+\t\t.submodule_errors = -1, /* unset */\n \t};\n \tconst char *submodule_prefix = \"\";\n \tconst char *bundle_uri;\n@@ -2491,6 +2547,7 @@ int cmd_fetch(int argc,\n \tint max_jobs = -1;\n \tint recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT;\n \tint recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND;\n+\tint submodule_errors_cli = -1; /* -1: not set on command line */\n \tint fetch_write_commit_graph = -1;\n \tint stdin_refspecs = 0;\n \tint negotiate_only = 0;\n@@ -2527,6 +2584,10 @@ int cmd_fetch(int argc,\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n+\t\tOPT_CALLBACK_F(0, \"submodule-errors\", &submodule_errors_cli,\n+\t\t\t    N_(\"(fail|warn)\"),\n+\t\t\t    N_(\"control how submodule fetch errors are handled\"),\n+\t\t\t    0, option_parse_submodule_errors),\n \t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n \t\t\t N_(\"dry run\")),\n \t\tOPT_BOOL(0, \"porcelain\", &porcelain, N_(\"machine-readable output\")),\n@@ -2616,6 +2677,9 @@ int cmd_fetch(int argc,\n \tif (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT)\n \t\tconfig.recurse_submodules = recurse_submodules_cli;\n \n+\tif (submodule_errors_cli != -1)\n+\t\tconfig.submodule_errors = submodule_errors_cli;\n+\n \tif (negotiate_only) {\n \t\tswitch (recurse_submodules_cli) {\n \t\tcase RECURSE_SUBMODULES_OFF:\n@@ -2819,11 +2883,14 @@ int cmd_fetch(int argc,\n \tif (!result && remote && (config.recurse_submodules != RECURSE_SUBMODULES_OFF)) {\n \t\tstruct strvec options = STRVEC_INIT;\n \t\tint max_children = max_jobs;\n+\t\tint submodule_errors = config.submodule_errors;\n \n \t\tif (max_children < 0)\n \t\t\tmax_children = config.submodule_fetch_jobs;\n \t\tif (max_children < 0)\n \t\t\tmax_children = config.parallel;\n+\t\tif (submodule_errors < 0)\n+\t\t\tsubmodule_errors = SUBMODULE_ERRORS_FAIL;\n \n \t\tadd_options_to_argv(&options, &config);\n \t\ttrace2_region_enter_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n@@ -2833,7 +2900,8 @@ int cmd_fetch(int argc,\n \t\t\t\t\t  config.recurse_submodules,\n \t\t\t\t\t  recurse_submodules_default,\n \t\t\t\t\t  verbosity < 0,\n-\t\t\t\t\t  max_children);\n+\t\t\t\t\t  max_children,\n+\t\t\t\t\t  submodule_errors);\n \t\ttrace2_region_leave_printf(\"fetch\", \"recurse-submodule\", the_repository, \"%s\", submodule_prefix);\n \t\tstrvec_clear(&options);\n \t}\ndiff --git a/submodule.c b/submodule.c\nindex 8bcef68a42..da4ace751f 100644\n--- a/submodule.c\n+++ b/submodule.c\n@@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch {\n \tint oid_fetch_tasks_nr, oid_fetch_tasks_alloc;\n \n \tstruct strbuf submodules_with_errors;\n+\tint submodule_errors;\n };\n #define SPF_INIT { \\\n \t.args = STRVEC_INIT, \\\n@@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf\n static void record_fetch_error(struct submodule_parallel_fetch *spf,\n \t\t\t       const char *name)\n {\n-\tspf->result = 1;\n+\tif (spf->submodule_errors == SUBMODULE_ERRORS_FAIL)\n+\t\tspf->result = 1;\n \tstrbuf_addf(&spf->submodules_with_errors, \"\\t%s\\n\", name);\n }\n \n@@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix, int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs)\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors)\n {\n \tstruct submodule_parallel_fetch spf = SPF_INIT;\n \tconst struct run_process_parallel_opts opts = {\n@@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r,\n \tspf.default_option = default_option;\n \tspf.quiet = quiet;\n \tspf.prefix = prefix;\n+\tspf.submodule_errors = submodule_errors;\n \n \tif (!r->worktree)\n \t\tgoto out;\ndiff --git a/submodule.h b/submodule.h\nindex b10e16e6c0..c80b687d2a 100644\n--- a/submodule.h\n+++ b/submodule.h\n@@ -90,12 +90,17 @@ int should_update_submodules(void);\n  */\n const struct submodule *submodule_from_ce(const struct cache_entry *ce);\n void check_for_new_submodule_commits(struct object_id *oid);\n+/* Values for the submodule_errors parameter of fetch_submodules(). */\n+#define SUBMODULE_ERRORS_FAIL 0  /* submodule fetch errors are fatal (default) */\n+#define SUBMODULE_ERRORS_WARN 1  /* submodule fetch errors are non-fatal warnings */\n+\n int fetch_submodules(struct repository *r,\n \t\t     const struct strvec *options,\n \t\t     const char *prefix,\n \t\t     int command_line_option,\n \t\t     int default_option,\n-\t\t     int quiet, int max_parallel_jobs);\n+\t\t     int quiet, int max_parallel_jobs,\n+\t\t     int submodule_errors);\n unsigned is_submodule_modified(const char *path, int ignore_untracked);\n int submodule_uses_gitfile(const char *path);\n \ndiff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh\nindex 7ad274ce04..19d17440cf 100755\n--- a/t/t5526-fetch-submodules.sh\n+++ b/t/t5526-fetch-submodules.sh\n@@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' '\n \tgit config --global protocol.file.allow always\n '\n \n+test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' '\n+\ttest_when_finished \"rm -fr env_default\" &&\n+\tcreate_err_env env_default &&\n+\tpush_unreachable_commit env_default &&\n+\ttest_must_fail git -C env_default/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cfg\" &&\n+\tcreate_err_env env_warn_cfg &&\n+\tpush_unreachable_commit env_warn_cfg &&\n+\tgit -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' '\n+\ttest_when_finished \"rm -fr env_warn_cli\" &&\n+\tcreate_err_env env_warn_cli &&\n+\tpush_unreachable_commit env_warn_cli &&\n+\tgit -C env_warn_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' '\n+\ttest_when_finished \"rm -fr env_fail_cli\" &&\n+\tcreate_err_env env_fail_cli &&\n+\tpush_unreachable_commit env_fail_cli &&\n+\ttest_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' '\n+\t# A new reachable submodule commit (pushed to sub_bare) should be\n+\t# fetched without any error summary.\n+\ttest_when_finished \"rm -fr env_ok\" &&\n+\tcreate_err_env env_ok &&\n+\ttest_commit -C env_ok/sub_work reachable_ok &&\n+\tgit -C env_ok/sub_work push &&\n+\tgit -C env_ok/super_work submodule update --remote &&\n+\tgit -C env_ok/super_work add sub &&\n+\tgit -C env_ok/super_work commit -m \"point sub to reachable commit\" &&\n+\tgit -C env_ok/super_work push &&\n+\tgit -C env_ok/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep ! \"Errors during submodule fetch\" err\n+'\n+\n test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' '\n \t# Create the same commit (unreferenced, via commit-tree with fixed\n \t# dates) in both super_work/sub and clone/sub, point the gitlink at\n@@ -1334,4 +1385,42 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p\n \ttest_grep \"Errors during submodule fetch\" err\n '\n \n+test_expect_success '--submodule-errors=warn is honored by fetch --all' '\n+\t# A second remote forces fetch_multiple(), which hands the submodule\n+\t# recursion off to per-remote child processes; the option must be\n+\t# forwarded to them.\n+\ttest_when_finished \"rm -fr env_all\" &&\n+\tcreate_err_env env_all &&\n+\tpush_unreachable_commit env_all &&\n+\tgit -C env_all/clone remote add second \"$pwd/env_all/super_bare\" &&\n+\tgit -C env_all/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=warn 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success '--submodule-errors=fail overrides warn config for fetch --all' '\n+\t# The per-remote child processes re-read the repository config, so\n+\t# the command-line override must be forwarded to them explicitly.\n+\ttest_when_finished \"rm -fr env_override\" &&\n+\tcreate_err_env env_override &&\n+\tpush_unreachable_commit env_override &&\n+\tgit -C env_override/clone remote add second \"$pwd/env_override/super_bare\" &&\n+\tgit -C env_override/clone config fetch.submoduleErrors warn &&\n+\ttest_must_fail git -C env_override/clone fetch --all --recurse-submodules \\\n+\t\t--submodule-errors=fail 2>err &&\n+\ttest_grep \"Errors during submodule fetch\" err\n+'\n+\n+test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' '\n+\ttest_when_finished \"rm -fr env_access\" &&\n+\tcreate_err_env env_access &&\n+\trm env_access/clone/sub/.git &&\n+\trm -r env_access/clone/.git/modules/sub &&\n+\tgit -C env_access/clone -c fetch.submoduleErrors=warn \\\n+\t\tfetch --recurse-submodules 2>err &&\n+\ttest_grep \"Could not access submodule\" err &&\n+\ttest_must_fail git -C env_access/clone fetch --recurse-submodules 2>err &&\n+\ttest_grep \"Could not access submodule\" err\n+'\n+\n test_done\n-- \n2.54.0\n\n"},{"id":"550192","messageId":"20260810150844.4003918-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260716140956.1023740-1-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v6 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-08-10T15:08:44Z","receivedAt":"2026-08-10T15:08:47Z","isPatch":true,"body":"Gentle ping.  This has been marked \"Needs review\" in What's cooking\nfor a few weeks.  Ramsay, Jean-Noël: you already looked at earlier\nrounds -- would one of you have time to review the series?\n\nThanks.\n"},{"id":"551301","messageId":"xmqq1pbkiy50.fsf@gitster.g","threadId":"65970","inReplyTo":"20260810150844.4003918-1-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v6 0/2] fetch: make submodule fetch errors configurable","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-26T17:34:35Z","receivedAt":"2026-08-26T17:34:44Z","isPatch":true,"body":"Paulius Zaleckas <paulius.zaleckas@gmail.com> writes:\n\n> Gentle ping.  This has been marked \"Needs review\" in What's cooking\n> for a few weeks.  Ramsay, Jean-Noël: you already looked at earlier\n> rounds -- would one of you have time to review the series?\n>\n> Thanks.\n\nIt has been more than a few weeks now.  I read the topic back then\nand pointed out a few obvious issues in earlier rounds, which the\nauthor seems to have addressed.  However, I was not aiming to be\nexhaustive, so I cannot say I gave it a thorough review.\n\nAny takers?\n\nThanks.\n"},{"id":"553059","messageId":"20260923132116.134736-1-paulius.zaleckas@gmail.com","threadId":"65970","inReplyTo":"20260716140956.1023740-1-paulius.zaleckas@gmail.com","subject":"Re: [PATCH v6 0/2] fetch: make submodule fetch errors configurable","fromName":"Paulius Zaleckas","fromEmail":"paulius.zaleckas@gmail.com","sentAt":"2026-09-23T13:21:15Z","receivedAt":"2026-09-23T13:21:31Z","isPatch":true,"body":"Another gentle ping -- this is still sitting in \"seen\" marked \"Needs\nreview\" a month after my last ping, with no comments in between.\n\nRamsay, Jean-Noël: since you reviewed earlier rounds, would either of\nyou have a few minutes to look at v6? Happy to send a v7 if there's\nanything left to address.\n\nThanks.\n"}]}