{"thread":{"id":"66319","subject":"[PATCH] pull: avoid crash of invalid merge head","startedAt":"2026-09-12T22:34:27Z","lastAt":"2026-09-21T14:32:48Z","messageCount":3,"participants":["Jiri Kuncar via GitGitGadget","Junio C Hamano"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"552631","messageId":"pull.2223.git.1789252459520.gitgitgadget@gmail.com","threadId":"66319","inReplyTo":null,"subject":"[PATCH] pull: avoid crash of invalid merge head","fromName":"Jiri Kuncar via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-12T22:34:19Z","receivedAt":"2026-09-12T22:34:27Z","isPatch":true,"body":"From: Jiri Kuncar <jiri.kuncar@gmail.com>\n\nAdds NULL guards for lookup_commit_reference() to avoid segfaults.\n\nThose invalid references are possibly caused by parallel fetches or\ngc racing on the same repository.\n\nThis effectively treats failed lookup as \"not up to date\" so caller\nfalls to a normal merge, which reports the broken object instead of\ncrashing.\n\nSigned-off-by: Jiri Kuncar <jiri.kuncar@gmail.com>\n---\n    pull: avoid crash of invalid merge head\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2223%2Fjirikuncar%2Fjk%2Fpull-null-merge-head-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2223/jirikuncar/jk/pull-null-merge-head-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2223\n\n builtin/pull.c  | 10 +++++++++-\n t/t5520-pull.sh | 26 ++++++++++++++++++++++++++\n 2 files changed, 35 insertions(+), 1 deletion(-)\n\ndiff --git a/builtin/pull.c b/builtin/pull.c\nindex db3ee0aab3..80e79daeb9 100644\n--- a/builtin/pull.c\n+++ b/builtin/pull.c\n@@ -800,8 +800,12 @@ static int get_can_ff(struct object_id *orig_head,\n \n \torig_merge_head = &merge_heads->oid[0];\n \thead = lookup_commit_reference(the_repository, orig_head);\n-\tcommit_list_insert(head, &list);\n+\tif (!head)\n+\t\treturn 0;\n \tmerge_head = lookup_commit_reference(the_repository, orig_merge_head);\n+\tif (!merge_head)\n+\t\treturn 0;\n+\tcommit_list_insert(head, &list);\n \tret = repo_is_descendant_of(the_repository, merge_head, list);\n \tcommit_list_free(list);\n \tif (ret < 0)\n@@ -820,12 +824,16 @@ static int already_up_to_date(struct object_id *orig_head,\n \tstruct commit *ours;\n \n \tours = lookup_commit_reference(the_repository, orig_head);\n+\tif (!ours)\n+\t\treturn 0;\n \tfor (size_t i = 0; i < merge_heads->nr; i++) {\n \t\tstruct commit_list *list = NULL;\n \t\tstruct commit *theirs;\n \t\tint ok;\n \n \t\ttheirs = lookup_commit_reference(the_repository, &merge_heads->oid[i]);\n+\t\tif (!theirs)\n+\t\t\treturn 0;\n \t\tcommit_list_insert(theirs, &list);\n \t\tok = repo_is_descendant_of(the_repository, ours, list);\n \t\tcommit_list_free(list);\ndiff --git a/t/t5520-pull.sh b/t/t5520-pull.sh\nindex 27f38ab3c8..7a3eadddd3 100755\n--- a/t/t5520-pull.sh\n+++ b/t/t5520-pull.sh\n@@ -888,4 +888,30 @@ test_expect_success 'git pull --rebase against local branch' '\n \ttest_cmp expect file2\n '\n \n+test_expect_success 'pull does not crash when a merge head does not resolve' '\n+\ttest_when_finished \"rm -rf up dn\" &&\n+\tgit init up &&\n+\t(\n+\t\tcd up &&\n+\t\ttest_commit base &&\n+\t\tgit switch -c sideA &&\n+\t\ttest_commit a &&\n+\t\tgit switch -c sideB base &&\n+\t\ttest_commit b\n+\t) &&\n+\tgit clone up dn &&\n+\t(\n+\t\tcd dn &&\n+\t\tgit -c fetch.unpackLimit=1000 fetch origin \\\n+\t\t\t\"+refs/heads/*:refs/remotes/origin/*\" &&\n+\t\tgit commit-graph write --reachable &&\n+\t\toid=$(git rev-parse refs/remotes/origin/sideA) &&\n+\t\tobj=.git/objects/$(test_oid_to_path \"$oid\") &&\n+\t\ttest -f \"$obj\" &&\n+\t\tchmod u+w \"$obj\" &&\n+\t\t>\"$obj\" &&\n+\t\ttest_must_fail git pull --no-rebase origin sideA sideB\n+\t)\n+'\n+\n test_done\n\nbase-commit: fa7f9290efe2bd22dd736689597b474b93798e11\n-- \ngitgitgadget\n"},{"id":"552772","messageId":"xmqqld92xixa.fsf@gitster.g","threadId":"66319","inReplyTo":"pull.2223.git.1789252459520.gitgitgadget@gmail.com","subject":"Re: [PATCH] pull: avoid crash of invalid merge head","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-15T22:13:05Z","receivedAt":"2026-09-15T22:13:07Z","isPatch":true,"body":"\"Jiri Kuncar via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Jiri Kuncar <jiri.kuncar@gmail.com>\n>\n> Adds NULL guards for lookup_commit_reference() to avoid segfaults.\n>\n> Those invalid references are possibly caused by parallel fetches or\n> gc racing on the same repository.\n>\n> This effectively treats failed lookup as \"not up to date\" so caller\n> falls to a normal merge, which reports the broken object instead of\n> crashing.\n>\n> Signed-off-by: Jiri Kuncar <jiri.kuncar@gmail.com>\n> ---\n>     pull: avoid crash of invalid merge head\n\nThe log message sounds a bit unusual from our norm (see\nDocumentation/SubmittingPatches).\n\nIt is of course good to deal with a corrupt state more gracefully\nrather than crashing.  From a cursory look, the particular solution\nchosen, to drive the caller to perform a merge and have it fail, may\nsmell a bit like cheating, in that we could diagnose the breakage\nbetter by reporting what was broken at each place, but it probably\nis a good choice.\n\nIf we really want to improve the situation for 'orig_head', for\nexample, we would probably want to turn it into a commit object\ninstance a lot earlier and pass the commit object instance around in\nthe call chain.  Passing around many struct object_id instances\ninstead of object instances is an unnatural consequence of how this\nprogram evolved.  It was originally written as a shell script, and\nof course passing hexadecimal object names was the only way the\nscript could drive 'git merge-base' and other programs to see if the\ncommit recorded as the current 'HEAD' will fast-forward to the\ncommit that is fetched from the remote to be merged in, for example.\nOnce we go that route to resolve object names early to object\ninstances, we will not have multiple lookup_commit_reference() calls\non the same object name (which require us to watch out for failures)\nto begin with.\n\nThe above is a long-winded way to say that it is a good improvement\nthat does not do more than it needs to do and we will not have to\nspend too much effort to undo when we revamp the internals to do\n\"the right thing\" later.\n\n\n> diff --git a/t/t5520-pull.sh b/t/t5520-pull.sh\n> index 27f38ab3c8..7a3eadddd3 100755\n> --- a/t/t5520-pull.sh\n> +++ b/t/t5520-pull.sh\n> @@ -888,4 +888,30 @@ test_expect_success 'git pull --rebase against local branch' '\n>  \ttest_cmp expect file2\n>  '\n>  \n> +test_expect_success 'pull does not crash when a merge head does not resolve' '\n> +\ttest_when_finished \"rm -rf up dn\" &&\n> +\tgit init up &&\n> +\t(\n> +\t\tcd up &&\n> +\t\ttest_commit base &&\n> +\t\tgit switch -c sideA &&\n> +\t\ttest_commit a &&\n> +\t\tgit switch -c sideB base &&\n> +\t\ttest_commit b\n> +\t) &&\n> +\tgit clone up dn &&\n> +\t(\n> +\t\tcd dn &&\n> +\t\tgit -c fetch.unpackLimit=1000 fetch origin \\\n> +\t\t\t\"+refs/heads/*:refs/remotes/origin/*\" &&\n> +\t\tgit commit-graph write --reachable &&\n> +\t\toid=$(git rev-parse refs/remotes/origin/sideA) &&\n> +\t\tobj=.git/objects/$(test_oid_to_path \"$oid\") &&\n> +\t\ttest -f \"$obj\" &&\n> +\t\tchmod u+w \"$obj\" &&\n> +\t\t>\"$obj\" &&\n> +\t\ttest_must_fail git pull --no-rebase origin sideA sideB\n> +\t)\n> +'\n\nThe \"test -f\" there smells more like a debugging aid for this test\nthan making sure the fixed program works as expected.  I wonder if\nit is simpler (and more portable to non-POSIX environments) if we\nreplace the \"corrupt $obj\" step with 'rm -f \"$obj\"'.\n\nThanks.\n"},{"id":"552934","messageId":"pull.2223.v2.git.1790001166646.gitgitgadget@gmail.com","threadId":"66319","inReplyTo":"pull.2223.git.1789252459520.gitgitgadget@gmail.com","subject":"[PATCH v2] pull: avoid segfault when commit lookup fails","fromName":"Jiri Kuncar via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-09-21T14:32:46Z","receivedAt":"2026-09-21T14:32:48Z","isPatch":true,"body":"From: Jiri Kuncar <jiri.kuncar@gmail.com>\n\nget_can_ff() and already_up_to_date() pass the result of\nlookup_commit_reference() straight to commit_list_insert() and\nrepo_is_descendant_of() without checking it.  When the object\nbehind HEAD or one of the merge heads cannot be parsed, e.g. because\na loose object was left truncated by a fetch or gc racing on the\nsame repository, lookup_commit_reference() returns NULL and\n\"git pull\" segfaults instead of reporting the corruption.\n\nTreat a failed lookup as \"cannot fast-forward\" and \"not up to date\",\nso that the caller falls through to the normal merge path, which\nalready diagnoses the broken object and fails cleanly.\n\nAn alternative would be to report the breakage at each lookup site,\nwhich could give a more precise diagnosis.  The minimal guards are\npreferred because they do no more than is needed to avoid the\ncrash, and will be easy to drop once \"git pull\" is reworked to\nresolve object names into commit objects early and pass those\naround, at which point there will not be multiple lookups of the\nsame object name to guard in the first place.\n\nThe test corrupts the loose object in place rather than removing\nit: a missing object that is still recorded in the commit-graph is\ncaught by the consistency check in fetch-pack before \"git pull\"\nreaches the fast-forward check, so removing it would not exercise\nthe crash.\n\nSigned-off-by: Jiri Kuncar <jiri.kuncar@gmail.com>\n---\n    pull: avoid segfault when commit lookup fails\n    \n    Changes since v1:\n    \n     * Rewrite the commit message per SubmittingPatches (imperative mood,\n       present-tense problem statement, alternatives considered), as pointed\n       out by Junio.\n     * Drop the \"test -f\"/\"chmod\"/truncate steps from the test in favour of\n       \"rm -f && echo garbage >\", the idiom already used in t1450. Plain \"rm\n       -f\" alone does not reproduce the crash: a missing object that is\n       still in the commit-graph is caught by fetch-pack's consistency check\n       before \"git pull\" reaches get_can_ff(), so the object has to remain\n       present but unparseable. Documented this in a test comment and in the\n       log message.\n     * Drop the redundant \"git fetch\" in the test setup; \"git clone\" already\n       populates refs/remotes/origin/*.\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2223%2Fjirikuncar%2Fjk%2Fpull-null-merge-head-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2223/jirikuncar/jk/pull-null-merge-head-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2223\n\nRange-diff vs v1:\n\n 1:  db6ecf62ec ! 1:  c00ae9d699 pull: avoid crash of invalid merge head\n     @@ Metadata\n      Author: Jiri Kuncar <jiri.kuncar@gmail.com>\n      \n       ## Commit message ##\n     -    pull: avoid crash of invalid merge head\n     +    pull: avoid segfault when commit lookup fails\n      \n     -    Adds NULL guards for lookup_commit_reference() to avoid segfaults.\n     +    get_can_ff() and already_up_to_date() pass the result of\n     +    lookup_commit_reference() straight to commit_list_insert() and\n     +    repo_is_descendant_of() without checking it.  When the object\n     +    behind HEAD or one of the merge heads cannot be parsed, e.g. because\n     +    a loose object was left truncated by a fetch or gc racing on the\n     +    same repository, lookup_commit_reference() returns NULL and\n     +    \"git pull\" segfaults instead of reporting the corruption.\n      \n     -    Those invalid references are possibly caused by parallel fetches or\n     -    gc racing on the same repository.\n     +    Treat a failed lookup as \"cannot fast-forward\" and \"not up to date\",\n     +    so that the caller falls through to the normal merge path, which\n     +    already diagnoses the broken object and fails cleanly.\n      \n     -    This effectively treats failed lookup as \"not up to date\" so caller\n     -    falls to a normal merge, which reports the broken object instead of\n     -    crashing.\n     +    An alternative would be to report the breakage at each lookup site,\n     +    which could give a more precise diagnosis.  The minimal guards are\n     +    preferred because they do no more than is needed to avoid the\n     +    crash, and will be easy to drop once \"git pull\" is reworked to\n     +    resolve object names into commit objects early and pass those\n     +    around, at which point there will not be multiple lookups of the\n     +    same object name to guard in the first place.\n     +\n     +    The test corrupts the loose object in place rather than removing\n     +    it: a missing object that is still recorded in the commit-graph is\n     +    caught by the consistency check in fetch-pack before \"git pull\"\n     +    reaches the fast-forward check, so removing it would not exercise\n     +    the crash.\n      \n          Signed-off-by: Jiri Kuncar <jiri.kuncar@gmail.com>\n      \n     @@ t/t5520-pull.sh: test_expect_success 'git pull --rebase against local branch' '\n      +\tgit clone up dn &&\n      +\t(\n      +\t\tcd dn &&\n     -+\t\tgit -c fetch.unpackLimit=1000 fetch origin \\\n     -+\t\t\t\"+refs/heads/*:refs/remotes/origin/*\" &&\n      +\t\tgit commit-graph write --reachable &&\n      +\t\toid=$(git rev-parse refs/remotes/origin/sideA) &&\n      +\t\tobj=.git/objects/$(test_oid_to_path \"$oid\") &&\n     -+\t\ttest -f \"$obj\" &&\n     -+\t\tchmod u+w \"$obj\" &&\n     -+\t\t>\"$obj\" &&\n     ++\n     ++\t\t# Corrupt the object instead of removing it: a missing\n     ++\t\t# object that is still in the commit-graph is caught by\n     ++\t\t# fetch before pull ever reaches the fast-forward check.\n     ++\t\trm -f \"$obj\" &&\n     ++\t\techo garbage >\"$obj\" &&\n      +\t\ttest_must_fail git pull --no-rebase origin sideA sideB\n      +\t)\n      +'\n\n\n builtin/pull.c  | 10 +++++++++-\n t/t5520-pull.sh | 27 +++++++++++++++++++++++++++\n 2 files changed, 36 insertions(+), 1 deletion(-)\n\ndiff --git a/builtin/pull.c b/builtin/pull.c\nindex db3ee0aab3..80e79daeb9 100644\n--- a/builtin/pull.c\n+++ b/builtin/pull.c\n@@ -800,8 +800,12 @@ static int get_can_ff(struct object_id *orig_head,\n \n \torig_merge_head = &merge_heads->oid[0];\n \thead = lookup_commit_reference(the_repository, orig_head);\n-\tcommit_list_insert(head, &list);\n+\tif (!head)\n+\t\treturn 0;\n \tmerge_head = lookup_commit_reference(the_repository, orig_merge_head);\n+\tif (!merge_head)\n+\t\treturn 0;\n+\tcommit_list_insert(head, &list);\n \tret = repo_is_descendant_of(the_repository, merge_head, list);\n \tcommit_list_free(list);\n \tif (ret < 0)\n@@ -820,12 +824,16 @@ static int already_up_to_date(struct object_id *orig_head,\n \tstruct commit *ours;\n \n \tours = lookup_commit_reference(the_repository, orig_head);\n+\tif (!ours)\n+\t\treturn 0;\n \tfor (size_t i = 0; i < merge_heads->nr; i++) {\n \t\tstruct commit_list *list = NULL;\n \t\tstruct commit *theirs;\n \t\tint ok;\n \n \t\ttheirs = lookup_commit_reference(the_repository, &merge_heads->oid[i]);\n+\t\tif (!theirs)\n+\t\t\treturn 0;\n \t\tcommit_list_insert(theirs, &list);\n \t\tok = repo_is_descendant_of(the_repository, ours, list);\n \t\tcommit_list_free(list);\ndiff --git a/t/t5520-pull.sh b/t/t5520-pull.sh\nindex 27f38ab3c8..b3ab8f4c94 100755\n--- a/t/t5520-pull.sh\n+++ b/t/t5520-pull.sh\n@@ -888,4 +888,31 @@ test_expect_success 'git pull --rebase against local branch' '\n \ttest_cmp expect file2\n '\n \n+test_expect_success 'pull does not crash when a merge head does not resolve' '\n+\ttest_when_finished \"rm -rf up dn\" &&\n+\tgit init up &&\n+\t(\n+\t\tcd up &&\n+\t\ttest_commit base &&\n+\t\tgit switch -c sideA &&\n+\t\ttest_commit a &&\n+\t\tgit switch -c sideB base &&\n+\t\ttest_commit b\n+\t) &&\n+\tgit clone up dn &&\n+\t(\n+\t\tcd dn &&\n+\t\tgit commit-graph write --reachable &&\n+\t\toid=$(git rev-parse refs/remotes/origin/sideA) &&\n+\t\tobj=.git/objects/$(test_oid_to_path \"$oid\") &&\n+\n+\t\t# Corrupt the object instead of removing it: a missing\n+\t\t# object that is still in the commit-graph is caught by\n+\t\t# fetch before pull ever reaches the fast-forward check.\n+\t\trm -f \"$obj\" &&\n+\t\techo garbage >\"$obj\" &&\n+\t\ttest_must_fail git pull --no-rebase origin sideA sideB\n+\t)\n+'\n+\n test_done\n\nbase-commit: fa7f9290efe2bd22dd736689597b474b93798e11\n-- \ngitgitgadget\n"}]}