{"thread":{"id":"66445","subject":"[PATCH 0/2] fetch: write commit-graph using updated refs only","startedAt":"2026-10-02T08:33:41Z","lastAt":"2026-10-06T09:46:32Z","messageCount":10,"participants":["Kristofer Karlsson via GitGitGadget","Patrick Steinhardt","Kristofer Karlsson"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"553925","messageId":"pull.2239.git.1790930019.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":null,"subject":"[PATCH 0/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-02T08:33:36Z","receivedAt":"2026-10-02T08:33:41Z","isPatch":true,"body":"When fetch.writeCommitGraph is enabled, the commit-graph is currently\nrebuilt from all reachable refs after every fetch. This is unnecessarily\nexpensive on repositories with many refs, since add_ref_to_set() validates\neach ref against the odb.\n\nThis series optimizes the commit-graph write by using only the newly updated\nrefs as seeds instead of scanning all refs. It introduces a three-mode enum\n(REACHABLE / TIPS / SKIP) to make the policy explicit:\n\n * No-op fetch: skip the commit-graph write entirely\n * Updated refs + existing graph: write incrementally from updated tips only\n * No existing graph or multi-remote fetch: fall back to full reachable scan\n\nPatch 1 adds a commit-info subcommand to test-tool read-graph for verifying\ngraph contents in tests.\n\nPatch 2 implements the optimization in builtin/fetch.c with four tests\ncovering the incremental, unrelated-commit, no-op, and fallback cases.\n\nKristofer Karlsson (2):\n  test-tool read-graph: add commit-info subcommand\n  fetch: write commit-graph using updated refs only\n\n builtin/fetch.c            | 65 ++++++++++++++++++++++++++++++++------\n commit-graph.c             |  2 +-\n commit-graph.h             |  1 +\n t/helper/test-read-graph.c | 23 +++++++++++++-\n t/t5510-fetch.sh           | 58 ++++++++++++++++++++++++++++++++++\n 5 files changed, 138 insertions(+), 11 deletions(-)\n\n\nbase-commit: 0f8e75abebff0877cae681a3d5ff31ac47f54220\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2239%2Fspkrka%2Fkrka%2Fincremental-commit-graph-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2239/spkrka/krka/incremental-commit-graph-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2239\n-- \ngitgitgadget\n"},{"id":"553926","messageId":"36cc3ff3b329375d9d87d7ffbaf33b019f91b4b0.1790930019.git.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":"pull.2239.git.1790930019.gitgitgadget@gmail.com","subject":"[PATCH 1/2] test-tool read-graph: add commit-info subcommand","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-02T08:33:37Z","receivedAt":"2026-10-02T08:33:42Z","isPatch":true,"body":"From: Kristofer Karlsson <krka@spotify.com>\n\nThe test infrastructure has no way to check whether a specific commit\nis present in the commit-graph, making it hard to verify graph state\nafter operations like fetch.\n\nAdd a \"commit-info\" subcommand to test-tool read-graph that queries\nwhether specific commits are present in the commit-graph and prints\ntheir generation numbers.  Returns 1 if any commit is not found.\n\nSigned-off-by: Kristofer Karlsson <krka@spotify.com>\n---\n t/helper/test-read-graph.c | 23 ++++++++++++++++++++++-\n 1 file changed, 22 insertions(+), 1 deletion(-)\n\ndiff --git a/t/helper/test-read-graph.c b/t/helper/test-read-graph.c\nindex 9f07b9c25a..0ab9cf8f2b 100644\n--- a/t/helper/test-read-graph.c\n+++ b/t/helper/test-read-graph.c\n@@ -2,6 +2,9 @@\n \n #include \"test-tool.h\"\n #include \"commit-graph.h\"\n+#include \"commit.h\"\n+#include \"hex.h\"\n+#include \"object-name.h\"\n #include \"repository.h\"\n #include \"odb.h\"\n #include \"bloom.h\"\n@@ -91,7 +94,25 @@ int cmd__read_graph(int argc, const char **argv)\n \t\tdump_graph_info(graph);\n \telse if (!strcmp(argv[1], \"bloom-filters\"))\n \t\tdump_graph_bloom_filters(graph);\n-\telse {\n+\telse if (!strcmp(argv[1], \"commit-info\")) {\n+\t\tint i;\n+\t\tfor (i = 2; i < argc; i++) {\n+\t\t\tstruct object_id oid;\n+\t\t\tstruct commit *c;\n+\n+\t\t\tif (repo_get_oid(the_repository, argv[i], &oid))\n+\t\t\t\tdie(\"not a valid object name: '%s'\", argv[i]);\n+\t\t\tc = lookup_commit_in_graph(the_repository, &oid);\n+\t\t\tif (!c) {\n+\t\t\t\tfprintf(stderr, \"%s: not in graph\\n\", argv[i]);\n+\t\t\t\tret = 1;\n+\t\t\t\tcontinue;\n+\t\t\t}\n+\t\t\tprintf(\"%s generation %\"PRIuMAX\"\\n\",\n+\t\t\t       oid_to_hex(&oid),\n+\t\t\t       (uintmax_t)commit_graph_generation(c));\n+\t\t}\n+\t} else {\n \t\tfprintf(stderr, \"unknown sub-command: '%s'\\n\", argv[1]);\n \t\tret = 1;\n \t}\n-- \ngitgitgadget\n\n"},{"id":"553927","messageId":"fee92f3c2009f8f282fe98e6b16d403704db9ad9.1790930019.git.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":"pull.2239.git.1790930019.gitgitgadget@gmail.com","subject":"[PATCH 2/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-02T08:33:38Z","receivedAt":"2026-10-02T08:33:43Z","isPatch":true,"body":"From: Kristofer Karlsson <krka@spotify.com>\n\nWhen fetch.writeCommitGraph was introduced in\n\n    50f26bd035 (fetch: add fetch.writeCommitGraph config\n                setting, 2019-09-02),\n\nthe stated goal was to stay updated with the latest commits after\nfetching new objects.  The implementation used\nwrite_commit_graph_reachable() because it was the only API available,\nbut two things have changed since then:\n\n 1. write_commit_graph() was added, and it accepts an explicit set of\n    commits as seeds, enabling more targeted commit-graph updates.\n\n 2. The ref-scanning callback add_ref_to_set() became more expensive\n    in\n        630cd5194e (commit-graph.c: peel refs in 'add_ref_to_set',\n                    2020-07-22)\n    when it started to validate the refs against the odb\n    for correctness.  On a repository with many refs, this makes the\n    full reachable scan unnecessarily costly for a targeted fetch.\n\nOptimize the commit-graph write by using only the newly updated refs\nas seeds instead of scanning all refs after every fetch.  To keep\nthis change small, skip the optimization for multi-remote fetches\n(since that would require propagating the set of refs across process\nboundaries).\n\nSince do_fetch() already knows which refs were updated, collect them\ninto an oidset and then pass them directly to write_commit_graph().\nIn split mode, close_reachable() walks from the updated tips and\nstops at commits already present in the graph, efficiently adding\nthe newly fetched history.  This reachability closure also covers\nauto-followed tags, since their targets are reachable from the\nfetched tips that caused them to be auto-followed.\n\nAfter fetch_one() returns, call prepare_commit_graph() (which is\nmade non-static by this commit) to determine the graph-write mode:\n\n - If no commit-graph exists yet, fall back to the full reachable\n   scan so the first graph creation covers all refs.\n\n - If a commit-graph exists and the fetch updated at least one ref,\n   write incrementally using only the new refs as seeds.\n\n - If a commit-graph exists but the fetch is a no-op, skip the\n   commit-graph write entirely.\n\n - For the multi-remote path (fetch --all), where child processes\n   do the actual fetching, fall back to the full reachable scan.\n\nFull commit-graph coverage of all refs remains the responsibility\nof \"git maintenance\", \"git gc\" and \"git commit-graph write\".\nRegular Git operations may trigger \"git maintenance run --auto\",\nwhich periodically rebuilds the commit-graph from all reachable\nrefs.\n\nSigned-off-by: Kristofer Karlsson <krka@spotify.com>\n---\n builtin/fetch.c  | 65 +++++++++++++++++++++++++++++++++++++++++-------\n commit-graph.c   |  2 +-\n commit-graph.h   |  1 +\n t/t5510-fetch.sh | 58 ++++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 116 insertions(+), 10 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 533fdfe7d8..8ad7331640 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1903,10 +1903,30 @@ out:\n \treturn retcode;\n }\n \n+static void collect_updated_tips(struct oidset *tips, struct ref *ref_map)\n+{\n+\tstruct ref *rm;\n+\tfor (rm = ref_map; rm; rm = rm->next) {\n+\t\tstruct commit *commit;\n+\t\tif (rm->status == REF_STATUS_REJECT_SHALLOW)\n+\t\t\tcontinue;\n+\t\tif (is_null_oid(&rm->old_oid))\n+\t\t\tcontinue;\n+\t\tif (rm->peer_ref &&\n+\t\t    oideq(&rm->old_oid, &rm->peer_ref->old_oid))\n+\t\t\tcontinue;\n+\t\tcommit = lookup_commit_reference_gently(the_repository,\n+\t\t\t\t\t\t\t&rm->old_oid, 1);\n+\t\tif (commit)\n+\t\t\toidset_insert(tips, &commit->object.oid);\n+\t}\n+}\n+\n static int do_fetch(struct transport *transport,\n \t\t    struct refspec *rs,\n \t\t    const struct fetch_config *config,\n-\t\t    struct list_objects_filter_options *filter_options)\n+\t\t    struct list_objects_filter_options *filter_options,\n+\t\t    struct oidset *updated_tips)\n {\n \tstruct ref_transaction *transaction = NULL;\n \tstruct ref *ref_map = NULL;\n@@ -2111,6 +2131,8 @@ static int do_fetch(struct transport *transport,\n \n \tcommit_fetch_head(&fetch_head);\n \n+\tcollect_updated_tips(updated_tips, ref_map);\n+\n \tif (set_upstream) {\n \t\tstruct branch *branch = branch_get(\"HEAD\");\n \t\tstruct ref *rm;\n@@ -2427,7 +2449,8 @@ static inline void fetch_one_setup_partial(struct remote *remote,\n static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t     int prune_tags_ok, int use_stdin_refspecs,\n \t\t     const struct fetch_config *config,\n-\t\t     struct list_objects_filter_options *filter_options)\n+\t\t     struct list_objects_filter_options *filter_options,\n+\t\t     struct oidset *updated_tips)\n {\n \tstruct refspec rs = REFSPEC_INIT_FETCH(the_hash_algo);\n \tint i;\n@@ -2494,7 +2517,8 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \tsigchain_push_common(unlock_pack_on_signal);\n \tatexit(unlock_pack_atexit);\n \tsigchain_push(SIGPIPE, SIG_IGN);\n-\texit_code = do_fetch(gtransport, &rs, config, filter_options);\n+\texit_code = do_fetch(gtransport, &rs, config, filter_options,\n+\t\t\t     updated_tips);\n \tsigchain_pop(SIGPIPE);\n \trefspec_clear(&rs);\n \ttransport_disconnect(gtransport);\n@@ -2535,6 +2559,12 @@ int cmd_fetch(int argc,\n \tint negotiate_only = 0;\n \tint porcelain = 0;\n \tint i;\n+\tenum {\n+\t\tGRAPH_WRITE_REACHABLE,\n+\t\tGRAPH_WRITE_TIPS,\n+\t\tGRAPH_WRITE_SKIP,\n+\t} graph_write_mode = GRAPH_WRITE_REACHABLE;\n+\tstruct oidset updated_tips = OIDSET_INIT;\n \n \tstruct option builtin_fetch_options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -2822,7 +2852,13 @@ int cmd_fetch(int argc,\n \t\t}\n \t\ttrace2_region_enter(\"fetch\", \"fetch-one\", the_repository);\n \t\tresult = fetch_one(remote, argc, argv, prune_tags_ok, stdin_refspecs,\n-\t\t\t\t   &config, &filter_options);\n+\t\t\t\t   &config, &filter_options, &updated_tips);\n+\t\tif (prepare_commit_graph(the_repository)) {\n+\t\t\tif (oidset_size(&updated_tips))\n+\t\t\t\tgraph_write_mode = GRAPH_WRITE_TIPS;\n+\t\t\telse\n+\t\t\t\tgraph_write_mode = GRAPH_WRITE_SKIP;\n+\t\t}\n \t\ttrace2_region_leave(\"fetch\", \"fetch-one\", the_repository);\n \t} else {\n \t\tint max_children = max_jobs;\n@@ -2899,11 +2935,21 @@ int cmd_fetch(int argc,\n \t\tif (progress)\n \t\t\tcommit_graph_flags |= COMMIT_GRAPH_WRITE_PROGRESS;\n \n-\t\ttrace2_region_enter(\"fetch\", \"write-commit-graph\", the_repository);\n-\t\twrite_commit_graph_reachable(the_repository->objects->sources,\n-\t\t\t\t\t     commit_graph_flags,\n-\t\t\t\t\t     NULL);\n-\t\ttrace2_region_leave(\"fetch\", \"write-commit-graph\", the_repository);\n+\t\tif (graph_write_mode != GRAPH_WRITE_SKIP) {\n+\t\t\ttrace2_region_enter(\"fetch\", \"write-commit-graph\",\n+\t\t\t\t\t    the_repository);\n+\t\t\tif (graph_write_mode == GRAPH_WRITE_TIPS)\n+\t\t\t\twrite_commit_graph(\n+\t\t\t\t\tthe_repository->objects->sources,\n+\t\t\t\t\tNULL, &updated_tips,\n+\t\t\t\t\tcommit_graph_flags, NULL);\n+\t\t\telse\n+\t\t\t\twrite_commit_graph_reachable(\n+\t\t\t\t\tthe_repository->objects->sources,\n+\t\t\t\t\tcommit_graph_flags, NULL);\n+\t\t\ttrace2_region_leave(\"fetch\", \"write-commit-graph\",\n+\t\t\t\t\t    the_repository);\n+\t\t}\n \t}\n \n \tif (enable_auto_gc) {\n@@ -2927,6 +2973,7 @@ int cmd_fetch(int argc,\n \t}\n \n  cleanup:\n+\toidset_clear(&updated_tips);\n \tstring_list_clear(&list, 0);\n \tlist_objects_filter_release(&filter_options);\n \treturn result;\ndiff --git a/commit-graph.c b/commit-graph.c\nindex 983c11ce85..d042752ff4 100644\n--- a/commit-graph.c\n+++ b/commit-graph.c\n@@ -733,7 +733,7 @@ struct commit_graph *read_commit_graph_one(struct odb_source *source)\n  * On the first invocation, this function attempts to load the commit\n  * graph if the repository is configured to have one.\n  */\n-static struct commit_graph *prepare_commit_graph(struct repository *r)\n+struct commit_graph *prepare_commit_graph(struct repository *r)\n {\n \tstruct odb_source *source;\n \ndiff --git a/commit-graph.h b/commit-graph.h\nindex 13ca4ff010..7e48b0ccc0 100644\n--- a/commit-graph.h\n+++ b/commit-graph.h\n@@ -31,6 +31,7 @@ struct string_list;\n \n char *get_commit_graph_filename(struct odb_source *source);\n char *get_commit_graph_chain_filename(struct odb_source *source);\n+struct commit_graph *prepare_commit_graph(struct repository *r);\n int open_commit_graph(const char *graph_file, int *fd, struct stat *st);\n int open_commit_graph_chain(const char *chain_file, int *fd, struct stat *st,\n \t\t\t    const struct git_hash_algo *hash_algo);\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex a8d38d9176..e0b4d75d96 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -1087,6 +1087,64 @@ test_expect_success 'fetch.writeCommitGraph' '\n \t)\n '\n \n+test_expect_success 'fetch.writeCommitGraph adds fetched commits incrementally' '\n+\tgit init incremental-source &&\n+\ttest_commit -C incremental-source one &&\n+\tgit clone incremental-source incremental-dest &&\n+\tgit -C incremental-dest commit-graph write --reachable --split &&\n+\ttest_commit -C incremental-source two &&\n+\ttest_commit -C incremental-source three &&\n+\t(\n+\t\tcd incremental-dest &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info three two\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph does not add unrelated commits' '\n+\tgit init unrelated-source &&\n+\ttest_commit -C unrelated-source initial &&\n+\tgit clone unrelated-source unrelated-dest &&\n+\tgit -C unrelated-dest commit-graph write --reachable --split &&\n+\ttest_commit -C unrelated-source fetched &&\n+\t(\n+\t\tcd unrelated-dest &&\n+\t\ttest_env GIT_TEST_COMMIT_GRAPH=0 test_commit local-only &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info fetched &&\n+\t\ttest_expect_code 1 \\\n+\t\t\ttest-tool read-graph commit-info local-only 2>/dev/null\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph skips write on no-op fetch' '\n+\tgit init noop-source &&\n+\ttest_commit -C noop-source one &&\n+\tgit clone noop-source noop-dest &&\n+\tgit -C noop-dest commit-graph write --reachable --split &&\n+\t(\n+\t\tcd noop-dest &&\n+\t\tGIT_TRACE2_EVENT=\"$(pwd)/trace2.txt\" \\\n+\t\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest_region ! fetch write-commit-graph trace2.txt\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph falls back to reachable scan without existing graph' '\n+\tgit init first-graph-source &&\n+\ttest_commit -C first-graph-source base &&\n+\tgit clone first-graph-source first-graph-dest &&\n+\ttest_commit -C first-graph-source fetched &&\n+\t(\n+\t\tcd first-graph-dest &&\n+\t\ttest_commit local &&\n+\t\trm -rf .git/objects/info/commit-graphs &&\n+\t\trm -f .git/objects/info/commit-graph &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info fetched local base\n+\t)\n+'\n+\n test_expect_success 'fetch.writeCommitGraph with submodules' '\n \ttest_config_global protocol.file.allow always &&\n \tgit clone dups super &&\n-- \ngitgitgadget\n"},{"id":"553957","messageId":"ar-T2y54X1uDQ4mX@pks.im","threadId":"66445","inReplyTo":"fee92f3c2009f8f282fe98e6b16d403704db9ad9.1790930019.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 2/2] fetch: write commit-graph using updated refs only","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-10-02T11:22:03Z","receivedAt":"2026-10-02T11:22:11Z","isPatch":true,"body":"On Fri, Oct 02, 2026 at 08:33:38AM +0000, Kristofer Karlsson via GitGitGadget wrote:\n> From: Kristofer Karlsson <krka@spotify.com>\n> \n> When fetch.writeCommitGraph was introduced in\n> \n>     50f26bd035 (fetch: add fetch.writeCommitGraph config\n>                 setting, 2019-09-02),\n> \n> the stated goal was to stay updated with the latest commits after\n> fetching new objects.  The implementation used\n> write_commit_graph_reachable() because it was the only API available,\n> but two things have changed since then:\n> \n>  1. write_commit_graph() was added, and it accepts an explicit set of\n>     commits as seeds, enabling more targeted commit-graph updates.\n\nHm. The big question here is whether these additional seeds are additive\nor exclusive. That is, if I have an existing commit graph already, would\nit basically just extend the commit graph with the additional object IDs\nor would it replace the commit graph with a new one that only considers\nthe passe object IDs as input?\n\nI would hope that it's additive, because otherwise you may now lose\ncommit graph coverage for stuff that was covered before the patch.\n\n>  2. The ref-scanning callback add_ref_to_set() became more expensive\n>     in\n>         630cd5194e (commit-graph.c: peel refs in 'add_ref_to_set',\n>                     2020-07-22)\n>     when it started to validate the refs against the odb\n>     for correctness.  On a repository with many refs, this makes the\n>     full reachable scan unnecessarily costly for a targeted fetch.\n\nI was wondering whether incremental commit graphs would also be part of\nthe reasoning. Because in theory, now that we have those, we could even\nextend the commit graph on a fetch by just writing another layer.\n\n> Optimize the commit-graph write by using only the newly updated refs\n> as seeds instead of scanning all refs after every fetch.  To keep\n> this change small, skip the optimization for multi-remote fetches\n> (since that would require propagating the set of refs across process\n> boundaries).\n\nYeah, the way we perform fetches can be a bit annoying at times, as all\nthese subprocesses make it very hard to exchange information.\n\n> Since do_fetch() already knows which refs were updated, collect them\n> into an oidset and then pass them directly to write_commit_graph().\n> In split mode, close_reachable() walks from the updated tips and\n> stops at commits already present in the graph, efficiently adding\n> the newly fetched history.  This reachability closure also covers\n> auto-followed tags, since their targets are reachable from the\n> fetched tips that caused them to be auto-followed.\n\nAha! So I wasn't that far off :) Now there's a follow-up question\nthough: what happens in non-split mode?\n\n> After fetch_one() returns, call prepare_commit_graph() (which is\n> made non-static by this commit) to determine the graph-write mode:\n> \n>  - If no commit-graph exists yet, fall back to the full reachable\n>    scan so the first graph creation covers all refs.\n> \n>  - If a commit-graph exists and the fetch updated at least one ref,\n>    write incrementally using only the new refs as seeds.\n> \n>  - If a commit-graph exists but the fetch is a no-op, skip the\n>    commit-graph write entirely.\n> \n>  - For the multi-remote path (fetch --all), where child processes\n>    do the actual fetching, fall back to the full reachable scan.\n\nAll of these make sense, but the above question is not answered yet.\n\n> Full commit-graph coverage of all refs remains the responsibility\n> of \"git maintenance\", \"git gc\" and \"git commit-graph write\".\n> Regular Git operations may trigger \"git maintenance run --auto\",\n> which periodically rebuilds the commit-graph from all reachable\n> refs.\n\nCuriously, you mention performance as motivating factor for this change\nbut don't provide a benchmark demonstrating the benefit.\n\n> diff --git a/builtin/fetch.c b/builtin/fetch.c\n> index 533fdfe7d8..8ad7331640 100644\n> --- a/builtin/fetch.c\n> +++ b/builtin/fetch.c\n> @@ -1903,10 +1903,30 @@ out:\n>  \treturn retcode;\n>  }\n>  \n> +static void collect_updated_tips(struct oidset *tips, struct ref *ref_map)\n> +{\n> +\tstruct ref *rm;\n> +\tfor (rm = ref_map; rm; rm = rm->next) {\n> +\t\tstruct commit *commit;\n> +\t\tif (rm->status == REF_STATUS_REJECT_SHALLOW)\n> +\t\t\tcontinue;\n\nHm. Shouldn't we also refuse almost all of the other values here? I'd\nexpect that we only want to consider a tip when it has REF_STATUS_OK.\n\n> +\t\tif (is_null_oid(&rm->old_oid))\n> +\t\t\tcontinue;\n> +\t\tif (rm->peer_ref &&\n> +\t\t    oideq(&rm->old_oid, &rm->peer_ref->old_oid))\n> +\t\t\tcontinue;\n> +\t\tcommit = lookup_commit_reference_gently(the_repository,\n> +\t\t\t\t\t\t\t&rm->old_oid, 1);\n> +\t\tif (commit)\n> +\t\t\toidset_insert(tips, &commit->object.oid);\n\nThis is something that always trips me with `struct ref`, that I'm never\nquite sure what's what. So please forgive my ignorance, but why do we\nlook up `rm->old_oid` here?\n\n> @@ -2535,6 +2559,12 @@ int cmd_fetch(int argc,\n>  \tint negotiate_only = 0;\n>  \tint porcelain = 0;\n>  \tint i;\n> +\tenum {\n> +\t\tGRAPH_WRITE_REACHABLE,\n> +\t\tGRAPH_WRITE_TIPS,\n> +\t\tGRAPH_WRITE_SKIP,\n> +\t} graph_write_mode = GRAPH_WRITE_REACHABLE;\n> +\tstruct oidset updated_tips = OIDSET_INIT;\n>  \n>  \tstruct option builtin_fetch_options[] = {\n>  \t\tOPT__VERBOSITY(&verbosity),\n> @@ -2822,7 +2852,13 @@ int cmd_fetch(int argc,\n>  \t\t}\n>  \t\ttrace2_region_enter(\"fetch\", \"fetch-one\", the_repository);\n>  \t\tresult = fetch_one(remote, argc, argv, prune_tags_ok, stdin_refspecs,\n> -\t\t\t\t   &config, &filter_options);\n> +\t\t\t\t   &config, &filter_options, &updated_tips);\n> +\t\tif (prepare_commit_graph(the_repository)) {\n> +\t\t\tif (oidset_size(&updated_tips))\n> +\t\t\t\tgraph_write_mode = GRAPH_WRITE_TIPS;\n> +\t\t\telse\n> +\t\t\t\tgraph_write_mode = GRAPH_WRITE_SKIP;\n> +\t\t}\n>  \t\ttrace2_region_leave(\"fetch\", \"fetch-one\", the_repository);\n>  \t} else {\n>  \t\tint max_children = max_jobs;\n\nIt's a bit curious that we have `GRAPH_WRITE_SKIP` as an explicit value\nhere as it can be trivially derived from `oidset_size()` anyway. But\nother than that this is the safeguard that you were talking about: when\nwe have a commit graph already then we only update with new tips,\notherwise we use a full reachability walk.\n\nThanks!\n\nPatrick\n"},{"id":"553960","messageId":"CAL71e4OcAg1PYaZZ2474Q5ayQgTeJFR2-7J+0ddrCe+rwwj=3w@mail.gmail.com","threadId":"66445","inReplyTo":"ar-T2y54X1uDQ4mX@pks.im","subject":"Re: [PATCH 2/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson","fromEmail":"krka@spotify.com","sentAt":"2026-10-02T12:40:44Z","receivedAt":"2026-10-02T12:40:56Z","isPatch":true,"body":"On Fri, 2 Oct 2026 at 13:22, Patrick Steinhardt <ps@pks.im> wrote:\n>\n> >  1. write_commit_graph() was added, and it accepts an explicit set of\n> >     commits as seeds, enabling more targeted commit-graph updates.\n>\n> Hm. The big question here is whether these additional seeds are additive\n> or exclusive. That is, if I have an existing commit graph already, would\n> it basically just extend the commit graph with the additional object IDs\n> or would it replace the commit graph with a new one that only considers\n> the passe object IDs as input?\n>\n> I would hope that it's additive, because otherwise you may now lose\n> commit graph coverage for stuff that was covered before the patch.\n\nYes, it is additive.  fetch always writes with this flag:\n\n    int commit_graph_flags = COMMIT_GRAPH_WRITE_SPLIT;\n\nso write_commit_graph() only adds the commits that are not already\nin the graph, as a new layer on top of the existing chain.  When\nlayers get merged, the commits of the merged layers are carried over.\n\nYou are right that a non-split write without COMMIT_GRAPH_WRITE_APPEND\nwould replace the graph with just the closure of the seeds, so this\nrelies on fetch using split mode.  I can extend the test to verify\nthat commits which were in the graph before the fetch are still there\nafterwards.\n\n> >  2. The ref-scanning callback add_ref_to_set() became more expensive\n> >     in\n> >         630cd5194e (commit-graph.c: peel refs in 'add_ref_to_set',\n> >                     2020-07-22)\n> >     when it started to validate the refs against the odb\n> >     for correctness.  On a repository with many refs, this makes the\n> >     full reachable scan unnecessarily costly for a targeted fetch.\n>\n> I was wondering whether incremental commit graphs would also be part of\n> the reasoning. Because in theory, now that we have those, we could even\n> extend the commit graph on a fetch by just writing another layer.\n\nYes, that is exactly what happens: since fetch writes in split mode,\nthe newly fetched history ends up in a new layer.  The commit message\nshould say so explicitly, and I will update it in the reroll.\n\n> > Since do_fetch() already knows which refs were updated, collect them\n> > into an oidset and then pass them directly to write_commit_graph().\n> > In split mode, close_reachable() walks from the updated tips and\n> > stops at commits already present in the graph, efficiently adding\n> > the newly fetched history.  This reachability closure also covers\n> > auto-followed tags, since their targets are reachable from the\n> > fetched tips that caused them to be auto-followed.\n>\n> Aha! So I wasn't that far off :) Now there's a follow-up question\n> though: what happens in non-split mode?\n\nThe fetch path never uses non-split mode (see above).  If that ever\nchanges, the incremental path would need COMMIT_GRAPH_WRITE_APPEND,\nor a fallback to the reachable scan, to avoid losing coverage.\n\n> > After fetch_one() returns, call prepare_commit_graph() (which is\n> > made non-static by this commit) to determine the graph-write mode:\n> >\n> >  - If no commit-graph exists yet, fall back to the full reachable\n> >    scan so the first graph creation covers all refs.\n> >\n> >  - If a commit-graph exists and the fetch updated at least one ref,\n> >    write incrementally using only the new refs as seeds.\n> >\n> >  - If a commit-graph exists but the fetch is a no-op, skip the\n> >    commit-graph write entirely.\n> >\n> >  - For the multi-remote path (fetch --all), where child processes\n> >    do the actual fetching, fall back to the full reachable scan.\n>\n> All of these make sense, but the above question is not answered yet.\n\nI hope the answer above covers it. :)\n\n> Curiously, you mention performance as motivating factor for this change\n> but don't provide a benchmark demonstrating the benefit.\n\nI left it out since the change avoids work rather than making existing\nwork faster: the cost of the full scan grows with the number of refs,\nso the improvement depends mostly on the repository.  But I agree that\nsome numbers are useful.  Here is a synthetic setup: git.git with 200K\nextra packed refs (~206K total), a local file:// remote, an existing\nsplit commit-graph (and a warmed up page-cache).  Times are the median\nof 9 runs and I am looking at the trace2 region for\nfetch/write-commit-graph:\n\n    scenario          before    after\n    no-op fetch       380 ms    (skipped)\n    1 ref updated     357 ms    9.3 ms\n    10 refs updated   359 ms    8.9 ms\n\nI will include these numbers in the cover letter of the reroll,\nor do you think it makes more sense to also have them in the commit\nmessage?\n\n> > diff --git a/builtin/fetch.c b/builtin/fetch.c\n> > index 533fdfe7d8..8ad7331640 100644\n> > --- a/builtin/fetch.c\n> > +++ b/builtin/fetch.c\n> > @@ -1903,10 +1903,30 @@ out:\n> >       return retcode;\n> >  }\n> >\n> > +static void collect_updated_tips(struct oidset *tips, struct ref *ref_map)\n> > +{\n> > +     struct ref *rm;\n> > +     for (rm = ref_map; rm; rm = rm->next) {\n> > +             struct commit *commit;\n> > +             if (rm->status == REF_STATUS_REJECT_SHALLOW)\n> > +                     continue;\n>\n> Hm. Shouldn't we also refuse almost all of the other values here? I'd\n> expect that we only want to consider a tip when it has REF_STATUS_OK.\n\nThis confused me at first too.  REF_STATUS_OK and most of the other\nvalues are only used on the push side.  During fetch, the status stays\nat REF_STATUS_NONE, and the only value that fetch-pack sets is\nREF_STATUS_REJECT_SHALLOW, so that is the only one we need to filter.\nRequiring REF_STATUS_OK would skip every ref.\n\nHowever, I could change it to use status != REF_STATUS_NONE --\nthose are the only two statuses we can get so both would work,\nbut I guess which one is best depends on what kind of new statuses\ncould be added in the future.\n\nRefs whose local update gets rejected (e.g. a non-fast-forward without\n--force) are still harmless to include, since their commits are fully\npresent in the object store.\n\n> > +             if (is_null_oid(&rm->old_oid))\n> > +                     continue;\n> > +             if (rm->peer_ref &&\n> > +                 oideq(&rm->old_oid, &rm->peer_ref->old_oid))\n> > +                     continue;\n> > +             commit = lookup_commit_reference_gently(the_repository,\n> > +                                                     &rm->old_oid, 1);\n> > +             if (commit)\n> > +                     oidset_insert(tips, &commit->object.oid);\n>\n> This is something that always trips me with `struct ref`, that I'm never\n> quite sure what's what. So please forgive my ignorance, but why do we\n> look up `rm->old_oid` here?\n\nThis tripped me up as well.  In the fetch ref_map:\n\n    rm->old_oid            the value advertised by the remote, i.e.\n                           the new tip we are fetching\n    rm->peer_ref           the local ref it maps to via the refspec\n                           (e.g. refs/remotes/origin/main), or NULL\n                           if it only goes to FETCH_HEAD\n    rm->peer_ref->old_oid  the current local value, before the update\n\nSo rm->old_oid is the new tip, and the oideq() check skips refs that\ndid not change.  rm->new_oid is not set on the ref_map during fetch;\nstore_updated_refs() copies rm->old_oid into the new_oid of a\nseparate struct ref for the local update.\n\nAs a concrete example, say \"git fetch origin\" with the default\nrefspec sees that the remote's main moved from A to B, a new branch\ntopic appeared at C, and stable is still at D:\n\n    rm->name           old_oid  peer_ref->name             peer old_oid\n    refs/heads/main    B        refs/remotes/origin/main   A\n    refs/heads/topic   C        refs/remotes/origin/topic  (null)\n    refs/heads/stable  D        refs/remotes/origin/stable D\n\nThis collects B and C as tips and skips stable.  When fetching from\na URL without a configured remote, e.g. \"git fetch <url> main\", the\nentry has no peer_ref (it only goes to FETCH_HEAD), so B is\ncollected unconditionally.\n\n> > @@ -2535,6 +2559,12 @@ int cmd_fetch(int argc,\n> >       int negotiate_only = 0;\n> >       int porcelain = 0;\n> >       int i;\n> > +     enum {\n> > +             GRAPH_WRITE_REACHABLE,\n> > +             GRAPH_WRITE_TIPS,\n> > +             GRAPH_WRITE_SKIP,\n> > +     } graph_write_mode = GRAPH_WRITE_REACHABLE;\n> > +     struct oidset updated_tips = OIDSET_INIT;\n> >\n> >       struct option builtin_fetch_options[] = {\n> >               OPT__VERBOSITY(&verbosity),\n> > @@ -2822,7 +2852,13 @@ int cmd_fetch(int argc,\n> >               }\n> >               trace2_region_enter(\"fetch\", \"fetch-one\", the_repository);\n> >               result = fetch_one(remote, argc, argv, prune_tags_ok, stdin_refspecs,\n> > -                                &config, &filter_options);\n> > +                                &config, &filter_options, &updated_tips);\n> > +             if (prepare_commit_graph(the_repository)) {\n> > +                     if (oidset_size(&updated_tips))\n> > +                             graph_write_mode = GRAPH_WRITE_TIPS;\n> > +                     else\n> > +                             graph_write_mode = GRAPH_WRITE_SKIP;\n> > +             }\n> >               trace2_region_leave(\"fetch\", \"fetch-one\", the_repository);\n> >       } else {\n> >               int max_children = max_jobs;\n>\n> It's a bit curious that we have `GRAPH_WRITE_SKIP` as an explicit value\n> here as it can be trivially derived from `oidset_size()` anyway. But\n> other than that this is the safeguard that you were talking about: when\n> we have a commit graph already then we only update with new tips,\n> otherwise we use a full reachability walk.\n\nThe oidset can be empty for two different reasons:\n\n 1. the fetch was a no-op, in which case skipping is correct, or\n\n 2. fetch_one() was never called because we took the multi-remote\n    path, in which case we must fall back to the reachable scan.\n\nDeriving the mode from oidset_size() alone would make \"fetch --all\"\nwith an existing graph skip the write entirely.  Setting the mode right\nwhere the fetch happens seemed like the best way to make this more\nexplicit and easy to reason about.\n\n> Thanks!\n>\n> Patrick\n\nThanks for the careful review!  I will update the commit message to\ncover the points above, extend the test, and send a reroll (next\nweek I suppose, don't want to rush it).\n\nKristofer\n"},{"id":"554146","messageId":"asNDP4_YlCHaWIVO@pks.im","threadId":"66445","inReplyTo":"CAL71e4OcAg1PYaZZ2474Q5ayQgTeJFR2-7J+0ddrCe+rwwj=3w@mail.gmail.com","subject":"Re: [PATCH 2/2] fetch: write commit-graph using updated refs only","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-10-05T06:27:11Z","receivedAt":"2026-10-05T06:27:22Z","isPatch":true,"body":"On Fri, Oct 02, 2026 at 02:40:44PM +0200, Kristofer Karlsson wrote:\n> On Fri, 2 Oct 2026 at 13:22, Patrick Steinhardt <ps@pks.im> wrote:\n> >\n> > >  1. write_commit_graph() was added, and it accepts an explicit set of\n> > >     commits as seeds, enabling more targeted commit-graph updates.\n> >\n> > Hm. The big question here is whether these additional seeds are additive\n> > or exclusive. That is, if I have an existing commit graph already, would\n> > it basically just extend the commit graph with the additional object IDs\n> > or would it replace the commit graph with a new one that only considers\n> > the passe object IDs as input?\n> >\n> > I would hope that it's additive, because otherwise you may now lose\n> > commit graph coverage for stuff that was covered before the patch.\n> \n> Yes, it is additive.  fetch always writes with this flag:\n> \n>     int commit_graph_flags = COMMIT_GRAPH_WRITE_SPLIT;\n> \n> so write_commit_graph() only adds the commits that are not already\n> in the graph, as a new layer on top of the existing chain.  When\n> layers get merged, the commits of the merged layers are carried over.\n> \n> You are right that a non-split write without COMMIT_GRAPH_WRITE_APPEND\n> would replace the graph with just the closure of the seeds, so this\n> relies on fetch using split mode.  I can extend the test to verify\n> that commits which were in the graph before the fetch are still there\n> afterwards.\n\nAwesome :)\n\n[snip]\n> > Curiously, you mention performance as motivating factor for this change\n> > but don't provide a benchmark demonstrating the benefit.\n> \n> I left it out since the change avoids work rather than making existing\n> work faster: the cost of the full scan grows with the number of refs,\n> so the improvement depends mostly on the repository.  But I agree that\n> some numbers are useful.  Here is a synthetic setup: git.git with 200K\n> extra packed refs (~206K total), a local file:// remote, an existing\n> split commit-graph (and a warmed up page-cache).  Times are the median\n> of 9 runs and I am looking at the trace2 region for\n> fetch/write-commit-graph:\n> \n>     scenario          before    after\n>     no-op fetch       380 ms    (skipped)\n>     1 ref updated     357 ms    9.3 ms\n>     10 refs updated   359 ms    8.9 ms\n> \n> I will include these numbers in the cover letter of the reroll,\n> or do you think it makes more sense to also have them in the commit\n> message?\n\nI think it makes sense to have it as part of the commit message.\n\n> > > diff --git a/builtin/fetch.c b/builtin/fetch.c\n> > > index 533fdfe7d8..8ad7331640 100644\n> > > --- a/builtin/fetch.c\n> > > +++ b/builtin/fetch.c\n> > > @@ -1903,10 +1903,30 @@ out:\n> > >       return retcode;\n> > >  }\n> > >\n> > > +static void collect_updated_tips(struct oidset *tips, struct ref *ref_map)\n> > > +{\n> > > +     struct ref *rm;\n> > > +     for (rm = ref_map; rm; rm = rm->next) {\n> > > +             struct commit *commit;\n> > > +             if (rm->status == REF_STATUS_REJECT_SHALLOW)\n> > > +                     continue;\n> >\n> > Hm. Shouldn't we also refuse almost all of the other values here? I'd\n> > expect that we only want to consider a tip when it has REF_STATUS_OK.\n> \n> This confused me at first too.  REF_STATUS_OK and most of the other\n> values are only used on the push side.  During fetch, the status stays\n> at REF_STATUS_NONE, and the only value that fetch-pack sets is\n> REF_STATUS_REJECT_SHALLOW, so that is the only one we need to filter.\n> Requiring REF_STATUS_OK would skip every ref.\n> \n> However, I could change it to use status != REF_STATUS_NONE --\n> those are the only two statuses we can get so both would work,\n> but I guess which one is best depends on what kind of new statuses\n> could be added in the future.\n\nOkay, makes sense. I'd aim to be as defensive as possible, and defensive\nhere probably means that we should err on the side of covering too many\ncommits rather than covering not enough. And that's basically what\nyou're already doing anyway.\n\nI think having a short comment that explains this would help though.\n\n> Refs whose local update gets rejected (e.g. a non-fast-forward without\n> --force) are still harmless to include, since their commits are fully\n> present in the object store.\n\nYup.\n\n> > > +             if (is_null_oid(&rm->old_oid))\n> > > +                     continue;\n> > > +             if (rm->peer_ref &&\n> > > +                 oideq(&rm->old_oid, &rm->peer_ref->old_oid))\n> > > +                     continue;\n> > > +             commit = lookup_commit_reference_gently(the_repository,\n> > > +                                                     &rm->old_oid, 1);\n> > > +             if (commit)\n> > > +                     oidset_insert(tips, &commit->object.oid);\n> >\n> > This is something that always trips me with `struct ref`, that I'm never\n> > quite sure what's what. So please forgive my ignorance, but why do we\n> > look up `rm->old_oid` here?\n> \n> This tripped me up as well.  In the fetch ref_map:\n> \n>     rm->old_oid            the value advertised by the remote, i.e.\n>                            the new tip we are fetching\n>     rm->peer_ref           the local ref it maps to via the refspec\n>                            (e.g. refs/remotes/origin/main), or NULL\n>                            if it only goes to FETCH_HEAD\n>     rm->peer_ref->old_oid  the current local value, before the update\n> \n> So rm->old_oid is the new tip, and the oideq() check skips refs that\n> did not change.  rm->new_oid is not set on the ref_map during fetch;\n> store_updated_refs() copies rm->old_oid into the new_oid of a\n> separate struct ref for the local update.\n> \n> As a concrete example, say \"git fetch origin\" with the default\n> refspec sees that the remote's main moved from A to B, a new branch\n> topic appeared at C, and stable is still at D:\n> \n>     rm->name           old_oid  peer_ref->name             peer old_oid\n>     refs/heads/main    B        refs/remotes/origin/main   A\n>     refs/heads/topic   C        refs/remotes/origin/topic  (null)\n>     refs/heads/stable  D        refs/remotes/origin/stable D\n> \n> This collects B and C as tips and skips stable.  When fetching from\n> a URL without a configured remote, e.g. \"git fetch <url> main\", the\n> entry has no peer_ref (it only goes to FETCH_HEAD), so B is\n> collected unconditionally.\n\nThat part really is quite confusing. Thanks for explaining!\n\n> > > @@ -2822,7 +2852,13 @@ int cmd_fetch(int argc,\n> > >               }\n> > >               trace2_region_enter(\"fetch\", \"fetch-one\", the_repository);\n> > >               result = fetch_one(remote, argc, argv, prune_tags_ok, stdin_refspecs,\n> > > -                                &config, &filter_options);\n> > > +                                &config, &filter_options, &updated_tips);\n> > > +             if (prepare_commit_graph(the_repository)) {\n> > > +                     if (oidset_size(&updated_tips))\n> > > +                             graph_write_mode = GRAPH_WRITE_TIPS;\n> > > +                     else\n> > > +                             graph_write_mode = GRAPH_WRITE_SKIP;\n> > > +             }\n> > >               trace2_region_leave(\"fetch\", \"fetch-one\", the_repository);\n> > >       } else {\n> > >               int max_children = max_jobs;\n> >\n> > It's a bit curious that we have `GRAPH_WRITE_SKIP` as an explicit value\n> > here as it can be trivially derived from `oidset_size()` anyway. But\n> > other than that this is the safeguard that you were talking about: when\n> > we have a commit graph already then we only update with new tips,\n> > otherwise we use a full reachability walk.\n> \n> The oidset can be empty for two different reasons:\n> \n>  1. the fetch was a no-op, in which case skipping is correct, or\n> \n>  2. fetch_one() was never called because we took the multi-remote\n>     path, in which case we must fall back to the reachable scan.\n> \n> Deriving the mode from oidset_size() alone would make \"fetch --all\"\n> with an existing graph skip the write entirely.  Setting the mode right\n> where the fetch happens seemed like the best way to make this more\n> explicit and easy to reason about.\n\nAh, right, the second condition is what I forgot about.\n\nPatrick\n"},{"id":"554181","messageId":"CAL71e4MNEF-c_ErSXVyfe6qPv_3r2ECyu-_J2p4T1oF3mqEdxA@mail.gmail.com","threadId":"66445","inReplyTo":"asNDP4_YlCHaWIVO@pks.im","subject":"Re: [PATCH 2/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson","fromEmail":"krka@spotify.com","sentAt":"2026-10-05T14:47:34Z","receivedAt":"2026-10-05T14:47:34Z","isPatch":true,"body":"On Mon, 5 Oct 2026 at 08:27, Patrick Steinhardt <ps@pks.im> wrote:\n> > I will include these numbers in the cover letter of the reroll,\n> > or do you think it makes more sense to also have them in the commit\n> > message?\n>\n> I think it makes sense to have it as part of the commit message.\n\nWill do!\n\n> > However, I could change it to use status != REF_STATUS_NONE --\n> > those are the only two statuses we can get so both would work,\n> > but I guess which one is best depends on what kind of new statuses\n> > could be added in the future.\n>\n> Okay, makes sense. I'd aim to be as defensive as possible, and defensive\n> here probably means that we should err on the side of covering too many\n> commits rather than covering not enough. And that's basically what\n> you're already doing anyway.\n>\n> I think having a short comment that explains this would help though.\n\nAgreed, will add a code comment + keep using REF_STATUS_REJECT_SHALLOW\nAs you say, it makes sense to skip as few refs as possible here.\nI will also add a test case for shallow to prove that the check is important.\n\nThanks,\nKristofer\n\n"},{"id":"554255","messageId":"pull.2239.v2.git.1791279992.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":"pull.2239.git.1790930019.gitgitgadget@gmail.com","subject":"[PATCH v2 0/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-06T09:46:30Z","receivedAt":"2026-10-06T09:46:30Z","isPatch":true,"body":"When fetch.writeCommitGraph is enabled, the commit-graph is rebuilt from all\nreachable refs after every fetch. This is unnecessarily expensive on\nrepositories with many refs, since add_ref_to_set() validates each ref\nagainst the odb.\n\nThis series optimizes the commit-graph write by using only the newly updated\nrefs as seeds instead of scanning all refs. Since fetch writes the\ncommit-graph in split mode, the newly fetched history is added as a new\nlayer on top of the existing chain. A three-mode enum (REACHABLE / TIPS /\nSKIP) makes the policy explicit:\n\n * No-op fetch: skip the commit-graph write entirely\n * Updated refs + existing graph: write incrementally from updated tips only\n * No existing graph or multi-remote fetch: fall back to full reachable scan\n\nPatch 1 adds a commit-info subcommand to test-tool read-graph for verifying\ngraph contents in tests.\n\nPatch 2 implements the optimization in builtin/fetch.c with tests covering\nthe incremental, unrelated-commit, no-op, fallback, and shallow-rejected\ncases.\n\nBenchmark on a synthetic setup: git.git with 200K extra packed refs (~206K\ntotal), a local file:// remote, an existing split commit-graph and a warm\npage cache. Times are the median of 9 runs of the trace2 region\nfetch/write-commit-graph:\n\nscenario          before    after\nno-op fetch       380 ms    (skipped)\n1 ref updated     357 ms    9.3 ms\n10 refs updated   359 ms    8.9 ms\n\n\nChanges since v1:\n\n * Explain in the commit message that fetch writes in split mode, so the new\n   tips are added as a new layer on top of the existing chain, and that the\n   incremental path relies on this (a non-split write would replace the\n   graph with only the closure of the seeds).\n * Extend the incremental test to check that a local-only commit, which was\n   in the graph before the fetch but is not reachable from the fetched tips,\n   is still in the graph afterwards.\n * Add benchmark numbers to the commit message.\n * Add a comment explaining why shallow-rejected refs are skipped when\n   collecting the updated tips (like store_updated_refs(), since their\n   history is incomplete), and mention it in the commit message.\n * Add a test in t5537 for a fetch with fetch.writeCommitGraph where a ref\n   is rejected because it would require changes to .git/shallow. Without the\n   check, the commit-graph write dies on the missing parent.\n\nKristofer Karlsson (2):\n  test-tool read-graph: add commit-info subcommand\n  fetch: write commit-graph using updated refs only\n\n builtin/fetch.c            | 69 +++++++++++++++++++++++++++++++++-----\n commit-graph.c             |  2 +-\n commit-graph.h             |  1 +\n t/helper/test-read-graph.c | 23 ++++++++++++-\n t/t5510-fetch.sh           | 59 ++++++++++++++++++++++++++++++++\n t/t5537-fetch-shallow.sh   | 28 ++++++++++++++++\n 6 files changed, 171 insertions(+), 11 deletions(-)\n\n\nbase-commit: 0f8e75abebff0877cae681a3d5ff31ac47f54220\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2239%2Fspkrka%2Fkrka%2Fincremental-commit-graph-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2239/spkrka/krka/incremental-commit-graph-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2239\n\nRange-diff vs v1:\n\n 1:  36cc3ff3b3 = 1:  36cc3ff3b3 test-tool read-graph: add commit-info subcommand\n 2:  fee92f3c20 ! 2:  7507354cc9 fetch: write commit-graph using updated refs only\n     @@ Commit message\n      \n          Since do_fetch() already knows which refs were updated, collect them\n          into an oidset and then pass them directly to write_commit_graph().\n     -    In split mode, close_reachable() walks from the updated tips and\n     -    stops at commits already present in the graph, efficiently adding\n     -    the newly fetched history.  This reachability closure also covers\n     -    auto-followed tags, since their targets are reachable from the\n     -    fetched tips that caused them to be auto-followed.\n     +    fetch always writes the commit-graph in split mode, so this adds a\n     +    new layer on top of the existing chain rather than replacing it:\n     +    close_reachable() walks from the updated tips and stops at commits\n     +    already present in the graph, so the new layer only contains the\n     +    newly fetched history, and commits covered by the existing layers\n     +    remain covered.  This relies on split mode; a non-split write would\n     +    replace the graph with just the closure of the seeds.\n     +\n     +    The reachability closure also covers auto-followed tags, since their\n     +    targets are reachable from the fetched tips that caused them to be\n     +    auto-followed.\n     +\n     +    Refs that are rejected because they would require changes to\n     +    .git/shallow are skipped, just like store_updated_refs() does.  Their\n     +    objects are received but their history is incomplete, so walking from\n     +    them would make the commit-graph write fail.\n      \n          After fetch_one() returns, call prepare_commit_graph() (which is\n          made non-static by this commit) to determine the graph-write mode:\n     @@ Commit message\n          which periodically rebuilds the commit-graph from all reachable\n          refs.\n      \n     +    The effect was measured on a synthetic setup: git.git with 200K\n     +    extra packed refs (~206K total), a local file:// remote, an existing\n     +    split commit-graph and a warm page cache.  The times below are the\n     +    median of 9 runs of the trace2 region fetch/write-commit-graph:\n     +\n     +        scenario          before    after\n     +        no-op fetch       380 ms    (skipped)\n     +        1 ref updated     357 ms    9.3 ms\n     +        10 refs updated   359 ms    8.9 ms\n     +\n          Signed-off-by: Kristofer Karlsson <krka@spotify.com>\n      \n       ## builtin/fetch.c ##\n     @@ builtin/fetch.c: out:\n      +\tstruct ref *rm;\n      +\tfor (rm = ref_map; rm; rm = rm->next) {\n      +\t\tstruct commit *commit;\n     ++\t\t/*\n     ++\t\t * Like store_updated_refs(), skip shallow-rejected refs:\n     ++\t\t * they are not stored, and their history is incomplete.\n     ++\t\t */\n      +\t\tif (rm->status == REF_STATUS_REJECT_SHALLOW)\n      +\t\t\tcontinue;\n      +\t\tif (is_null_oid(&rm->old_oid))\n     @@ t/t5510-fetch.sh: test_expect_success 'fetch.writeCommitGraph' '\n      +\tgit init incremental-source &&\n      +\ttest_commit -C incremental-source one &&\n      +\tgit clone incremental-source incremental-dest &&\n     ++\ttest_commit -C incremental-dest local &&\n      +\tgit -C incremental-dest commit-graph write --reachable --split &&\n      +\ttest_commit -C incremental-source two &&\n      +\ttest_commit -C incremental-source three &&\n      +\t(\n      +\t\tcd incremental-dest &&\n      +\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n     -+\t\ttest-tool read-graph commit-info three two\n     ++\t\ttest-tool read-graph commit-info three two local\n      +\t)\n      +'\n      +\n     @@ t/t5510-fetch.sh: test_expect_success 'fetch.writeCommitGraph' '\n       test_expect_success 'fetch.writeCommitGraph with submodules' '\n       \ttest_config_global protocol.file.allow always &&\n       \tgit clone dups super &&\n     +\n     + ## t/t5537-fetch-shallow.sh ##\n     +@@ t/t5537-fetch-shallow.sh: test_expect_success 'fetch that requires changes in .git/shallow is filtered' '\n     + \t)\n     + '\n     + \n     ++test_expect_success 'fetch.writeCommitGraph skips refs that require changes in .git/shallow' '\n     ++\tgit clone --no-local --depth=2 .git shallow-graph &&\n     ++\t(\n     ++\t\tcd shallow-graph &&\n     ++\t\tgit checkout --orphan no-shallow &&\n     ++\t\tcommit no-shallow\n     ++\t) &&\n     ++\tgit init notshallow-graph &&\n     ++\tgit -C notshallow-graph -c fetch.writeCommitGraph=true \\\n     ++\t\tfetch ../shallow-graph/.git \"refs/heads/*:refs/remotes/shallow/*\" &&\n     ++\t(\n     ++\t\tcd shallow-graph &&\n     ++\t\tcommit no-shallow-2\n     ++\t) &&\n     ++\trejected=$(git -C shallow-graph rev-parse main) &&\n     ++\t(\n     ++\t\tcd notshallow-graph &&\n     ++\t\tgit -c fetch.writeCommitGraph=true \\\n     ++\t\t\tfetch ../shallow-graph/.git \"refs/heads/*:refs/remotes/shallow/*\" &&\n     ++\t\tgit for-each-ref --format=\"%(refname)\" >actual.refs &&\n     ++\t\techo refs/remotes/shallow/no-shallow >expect.refs &&\n     ++\t\ttest_cmp expect.refs actual.refs &&\n     ++\t\ttest-tool read-graph commit-info shallow/no-shallow &&\n     ++\t\ttest_expect_code 1 \\\n     ++\t\t\ttest-tool read-graph commit-info $rejected 2>/dev/null\n     ++\t)\n     ++'\n     ++\n     + test_expect_success 'fetch --update-shallow' '\n     + \t(\n     + \tcd shallow &&\n\n-- \ngitgitgadget\n\n"},{"id":"554256","messageId":"36cc3ff3b329375d9d87d7ffbaf33b019f91b4b0.1791279992.git.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":"pull.2239.v2.git.1791279992.gitgitgadget@gmail.com","subject":"[PATCH v2 1/2] test-tool read-graph: add commit-info subcommand","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-06T09:46:31Z","receivedAt":"2026-10-06T09:46:31Z","isPatch":true,"body":"From: Kristofer Karlsson <krka@spotify.com>\n\nThe test infrastructure has no way to check whether a specific commit\nis present in the commit-graph, making it hard to verify graph state\nafter operations like fetch.\n\nAdd a \"commit-info\" subcommand to test-tool read-graph that queries\nwhether specific commits are present in the commit-graph and prints\ntheir generation numbers.  Returns 1 if any commit is not found.\n\nSigned-off-by: Kristofer Karlsson <krka@spotify.com>\n---\n t/helper/test-read-graph.c | 23 ++++++++++++++++++++++-\n 1 file changed, 22 insertions(+), 1 deletion(-)\n\ndiff --git a/t/helper/test-read-graph.c b/t/helper/test-read-graph.c\nindex 9f07b9c25a..0ab9cf8f2b 100644\n--- a/t/helper/test-read-graph.c\n+++ b/t/helper/test-read-graph.c\n@@ -2,6 +2,9 @@\n \n #include \"test-tool.h\"\n #include \"commit-graph.h\"\n+#include \"commit.h\"\n+#include \"hex.h\"\n+#include \"object-name.h\"\n #include \"repository.h\"\n #include \"odb.h\"\n #include \"bloom.h\"\n@@ -91,7 +94,25 @@ int cmd__read_graph(int argc, const char **argv)\n \t\tdump_graph_info(graph);\n \telse if (!strcmp(argv[1], \"bloom-filters\"))\n \t\tdump_graph_bloom_filters(graph);\n-\telse {\n+\telse if (!strcmp(argv[1], \"commit-info\")) {\n+\t\tint i;\n+\t\tfor (i = 2; i < argc; i++) {\n+\t\t\tstruct object_id oid;\n+\t\t\tstruct commit *c;\n+\n+\t\t\tif (repo_get_oid(the_repository, argv[i], &oid))\n+\t\t\t\tdie(\"not a valid object name: '%s'\", argv[i]);\n+\t\t\tc = lookup_commit_in_graph(the_repository, &oid);\n+\t\t\tif (!c) {\n+\t\t\t\tfprintf(stderr, \"%s: not in graph\\n\", argv[i]);\n+\t\t\t\tret = 1;\n+\t\t\t\tcontinue;\n+\t\t\t}\n+\t\t\tprintf(\"%s generation %\"PRIuMAX\"\\n\",\n+\t\t\t       oid_to_hex(&oid),\n+\t\t\t       (uintmax_t)commit_graph_generation(c));\n+\t\t}\n+\t} else {\n \t\tfprintf(stderr, \"unknown sub-command: '%s'\\n\", argv[1]);\n \t\tret = 1;\n \t}\n-- \ngitgitgadget\n\n\n"},{"id":"554257","messageId":"7507354cc97bb63b3bcdc4a089b5387da28500a0.1791279992.git.gitgitgadget@gmail.com","threadId":"66445","inReplyTo":"pull.2239.v2.git.1791279992.gitgitgadget@gmail.com","subject":"[PATCH v2 2/2] fetch: write commit-graph using updated refs only","fromName":"Kristofer Karlsson via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-06T09:46:32Z","receivedAt":"2026-10-06T09:46:32Z","isPatch":true,"body":"From: Kristofer Karlsson <krka@spotify.com>\n\nWhen fetch.writeCommitGraph was introduced in\n\n    50f26bd035 (fetch: add fetch.writeCommitGraph config\n                setting, 2019-09-02),\n\nthe stated goal was to stay updated with the latest commits after\nfetching new objects.  The implementation used\nwrite_commit_graph_reachable() because it was the only API available,\nbut two things have changed since then:\n\n 1. write_commit_graph() was added, and it accepts an explicit set of\n    commits as seeds, enabling more targeted commit-graph updates.\n\n 2. The ref-scanning callback add_ref_to_set() became more expensive\n    in\n        630cd5194e (commit-graph.c: peel refs in 'add_ref_to_set',\n                    2020-07-22)\n    when it started to validate the refs against the odb\n    for correctness.  On a repository with many refs, this makes the\n    full reachable scan unnecessarily costly for a targeted fetch.\n\nOptimize the commit-graph write by using only the newly updated refs\nas seeds instead of scanning all refs after every fetch.  To keep\nthis change small, skip the optimization for multi-remote fetches\n(since that would require propagating the set of refs across process\nboundaries).\n\nSince do_fetch() already knows which refs were updated, collect them\ninto an oidset and then pass them directly to write_commit_graph().\nfetch always writes the commit-graph in split mode, so this adds a\nnew layer on top of the existing chain rather than replacing it:\nclose_reachable() walks from the updated tips and stops at commits\nalready present in the graph, so the new layer only contains the\nnewly fetched history, and commits covered by the existing layers\nremain covered.  This relies on split mode; a non-split write would\nreplace the graph with just the closure of the seeds.\n\nThe reachability closure also covers auto-followed tags, since their\ntargets are reachable from the fetched tips that caused them to be\nauto-followed.\n\nRefs that are rejected because they would require changes to\n.git/shallow are skipped, just like store_updated_refs() does.  Their\nobjects are received but their history is incomplete, so walking from\nthem would make the commit-graph write fail.\n\nAfter fetch_one() returns, call prepare_commit_graph() (which is\nmade non-static by this commit) to determine the graph-write mode:\n\n - If no commit-graph exists yet, fall back to the full reachable\n   scan so the first graph creation covers all refs.\n\n - If a commit-graph exists and the fetch updated at least one ref,\n   write incrementally using only the new refs as seeds.\n\n - If a commit-graph exists but the fetch is a no-op, skip the\n   commit-graph write entirely.\n\n - For the multi-remote path (fetch --all), where child processes\n   do the actual fetching, fall back to the full reachable scan.\n\nFull commit-graph coverage of all refs remains the responsibility\nof \"git maintenance\", \"git gc\" and \"git commit-graph write\".\nRegular Git operations may trigger \"git maintenance run --auto\",\nwhich periodically rebuilds the commit-graph from all reachable\nrefs.\n\nThe effect was measured on a synthetic setup: git.git with 200K\nextra packed refs (~206K total), a local file:// remote, an existing\nsplit commit-graph and a warm page cache.  The times below are the\nmedian of 9 runs of the trace2 region fetch/write-commit-graph:\n\n    scenario          before    after\n    no-op fetch       380 ms    (skipped)\n    1 ref updated     357 ms    9.3 ms\n    10 refs updated   359 ms    8.9 ms\n\nSigned-off-by: Kristofer Karlsson <krka@spotify.com>\n---\n builtin/fetch.c          | 69 ++++++++++++++++++++++++++++++++++------\n commit-graph.c           |  2 +-\n commit-graph.h           |  1 +\n t/t5510-fetch.sh         | 59 ++++++++++++++++++++++++++++++++++\n t/t5537-fetch-shallow.sh | 28 ++++++++++++++++\n 5 files changed, 149 insertions(+), 10 deletions(-)\n\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex 533fdfe7d8..574c361530 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -1903,10 +1903,34 @@ out:\n \treturn retcode;\n }\n \n+static void collect_updated_tips(struct oidset *tips, struct ref *ref_map)\n+{\n+\tstruct ref *rm;\n+\tfor (rm = ref_map; rm; rm = rm->next) {\n+\t\tstruct commit *commit;\n+\t\t/*\n+\t\t * Like store_updated_refs(), skip shallow-rejected refs:\n+\t\t * they are not stored, and their history is incomplete.\n+\t\t */\n+\t\tif (rm->status == REF_STATUS_REJECT_SHALLOW)\n+\t\t\tcontinue;\n+\t\tif (is_null_oid(&rm->old_oid))\n+\t\t\tcontinue;\n+\t\tif (rm->peer_ref &&\n+\t\t    oideq(&rm->old_oid, &rm->peer_ref->old_oid))\n+\t\t\tcontinue;\n+\t\tcommit = lookup_commit_reference_gently(the_repository,\n+\t\t\t\t\t\t\t&rm->old_oid, 1);\n+\t\tif (commit)\n+\t\t\toidset_insert(tips, &commit->object.oid);\n+\t}\n+}\n+\n static int do_fetch(struct transport *transport,\n \t\t    struct refspec *rs,\n \t\t    const struct fetch_config *config,\n-\t\t    struct list_objects_filter_options *filter_options)\n+\t\t    struct list_objects_filter_options *filter_options,\n+\t\t    struct oidset *updated_tips)\n {\n \tstruct ref_transaction *transaction = NULL;\n \tstruct ref *ref_map = NULL;\n@@ -2111,6 +2135,8 @@ static int do_fetch(struct transport *transport,\n \n \tcommit_fetch_head(&fetch_head);\n \n+\tcollect_updated_tips(updated_tips, ref_map);\n+\n \tif (set_upstream) {\n \t\tstruct branch *branch = branch_get(\"HEAD\");\n \t\tstruct ref *rm;\n@@ -2427,7 +2453,8 @@ static inline void fetch_one_setup_partial(struct remote *remote,\n static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t     int prune_tags_ok, int use_stdin_refspecs,\n \t\t     const struct fetch_config *config,\n-\t\t     struct list_objects_filter_options *filter_options)\n+\t\t     struct list_objects_filter_options *filter_options,\n+\t\t     struct oidset *updated_tips)\n {\n \tstruct refspec rs = REFSPEC_INIT_FETCH(the_hash_algo);\n \tint i;\n@@ -2494,7 +2521,8 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \tsigchain_push_common(unlock_pack_on_signal);\n \tatexit(unlock_pack_atexit);\n \tsigchain_push(SIGPIPE, SIG_IGN);\n-\texit_code = do_fetch(gtransport, &rs, config, filter_options);\n+\texit_code = do_fetch(gtransport, &rs, config, filter_options,\n+\t\t\t     updated_tips);\n \tsigchain_pop(SIGPIPE);\n \trefspec_clear(&rs);\n \ttransport_disconnect(gtransport);\n@@ -2535,6 +2563,12 @@ int cmd_fetch(int argc,\n \tint negotiate_only = 0;\n \tint porcelain = 0;\n \tint i;\n+\tenum {\n+\t\tGRAPH_WRITE_REACHABLE,\n+\t\tGRAPH_WRITE_TIPS,\n+\t\tGRAPH_WRITE_SKIP,\n+\t} graph_write_mode = GRAPH_WRITE_REACHABLE;\n+\tstruct oidset updated_tips = OIDSET_INIT;\n \n \tstruct option builtin_fetch_options[] = {\n \t\tOPT__VERBOSITY(&verbosity),\n@@ -2822,7 +2856,13 @@ int cmd_fetch(int argc,\n \t\t}\n \t\ttrace2_region_enter(\"fetch\", \"fetch-one\", the_repository);\n \t\tresult = fetch_one(remote, argc, argv, prune_tags_ok, stdin_refspecs,\n-\t\t\t\t   &config, &filter_options);\n+\t\t\t\t   &config, &filter_options, &updated_tips);\n+\t\tif (prepare_commit_graph(the_repository)) {\n+\t\t\tif (oidset_size(&updated_tips))\n+\t\t\t\tgraph_write_mode = GRAPH_WRITE_TIPS;\n+\t\t\telse\n+\t\t\t\tgraph_write_mode = GRAPH_WRITE_SKIP;\n+\t\t}\n \t\ttrace2_region_leave(\"fetch\", \"fetch-one\", the_repository);\n \t} else {\n \t\tint max_children = max_jobs;\n@@ -2899,11 +2939,21 @@ int cmd_fetch(int argc,\n \t\tif (progress)\n \t\t\tcommit_graph_flags |= COMMIT_GRAPH_WRITE_PROGRESS;\n \n-\t\ttrace2_region_enter(\"fetch\", \"write-commit-graph\", the_repository);\n-\t\twrite_commit_graph_reachable(the_repository->objects->sources,\n-\t\t\t\t\t     commit_graph_flags,\n-\t\t\t\t\t     NULL);\n-\t\ttrace2_region_leave(\"fetch\", \"write-commit-graph\", the_repository);\n+\t\tif (graph_write_mode != GRAPH_WRITE_SKIP) {\n+\t\t\ttrace2_region_enter(\"fetch\", \"write-commit-graph\",\n+\t\t\t\t\t    the_repository);\n+\t\t\tif (graph_write_mode == GRAPH_WRITE_TIPS)\n+\t\t\t\twrite_commit_graph(\n+\t\t\t\t\tthe_repository->objects->sources,\n+\t\t\t\t\tNULL, &updated_tips,\n+\t\t\t\t\tcommit_graph_flags, NULL);\n+\t\t\telse\n+\t\t\t\twrite_commit_graph_reachable(\n+\t\t\t\t\tthe_repository->objects->sources,\n+\t\t\t\t\tcommit_graph_flags, NULL);\n+\t\t\ttrace2_region_leave(\"fetch\", \"write-commit-graph\",\n+\t\t\t\t\t    the_repository);\n+\t\t}\n \t}\n \n \tif (enable_auto_gc) {\n@@ -2927,6 +2977,7 @@ int cmd_fetch(int argc,\n \t}\n \n  cleanup:\n+\toidset_clear(&updated_tips);\n \tstring_list_clear(&list, 0);\n \tlist_objects_filter_release(&filter_options);\n \treturn result;\ndiff --git a/commit-graph.c b/commit-graph.c\nindex 983c11ce85..d042752ff4 100644\n--- a/commit-graph.c\n+++ b/commit-graph.c\n@@ -733,7 +733,7 @@ struct commit_graph *read_commit_graph_one(struct odb_source *source)\n  * On the first invocation, this function attempts to load the commit\n  * graph if the repository is configured to have one.\n  */\n-static struct commit_graph *prepare_commit_graph(struct repository *r)\n+struct commit_graph *prepare_commit_graph(struct repository *r)\n {\n \tstruct odb_source *source;\n \ndiff --git a/commit-graph.h b/commit-graph.h\nindex 13ca4ff010..7e48b0ccc0 100644\n--- a/commit-graph.h\n+++ b/commit-graph.h\n@@ -31,6 +31,7 @@ struct string_list;\n \n char *get_commit_graph_filename(struct odb_source *source);\n char *get_commit_graph_chain_filename(struct odb_source *source);\n+struct commit_graph *prepare_commit_graph(struct repository *r);\n int open_commit_graph(const char *graph_file, int *fd, struct stat *st);\n int open_commit_graph_chain(const char *chain_file, int *fd, struct stat *st,\n \t\t\t    const struct git_hash_algo *hash_algo);\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex a8d38d9176..72dcb7fd43 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -1087,6 +1087,65 @@ test_expect_success 'fetch.writeCommitGraph' '\n \t)\n '\n \n+test_expect_success 'fetch.writeCommitGraph adds fetched commits incrementally' '\n+\tgit init incremental-source &&\n+\ttest_commit -C incremental-source one &&\n+\tgit clone incremental-source incremental-dest &&\n+\ttest_commit -C incremental-dest local &&\n+\tgit -C incremental-dest commit-graph write --reachable --split &&\n+\ttest_commit -C incremental-source two &&\n+\ttest_commit -C incremental-source three &&\n+\t(\n+\t\tcd incremental-dest &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info three two local\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph does not add unrelated commits' '\n+\tgit init unrelated-source &&\n+\ttest_commit -C unrelated-source initial &&\n+\tgit clone unrelated-source unrelated-dest &&\n+\tgit -C unrelated-dest commit-graph write --reachable --split &&\n+\ttest_commit -C unrelated-source fetched &&\n+\t(\n+\t\tcd unrelated-dest &&\n+\t\ttest_env GIT_TEST_COMMIT_GRAPH=0 test_commit local-only &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info fetched &&\n+\t\ttest_expect_code 1 \\\n+\t\t\ttest-tool read-graph commit-info local-only 2>/dev/null\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph skips write on no-op fetch' '\n+\tgit init noop-source &&\n+\ttest_commit -C noop-source one &&\n+\tgit clone noop-source noop-dest &&\n+\tgit -C noop-dest commit-graph write --reachable --split &&\n+\t(\n+\t\tcd noop-dest &&\n+\t\tGIT_TRACE2_EVENT=\"$(pwd)/trace2.txt\" \\\n+\t\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest_region ! fetch write-commit-graph trace2.txt\n+\t)\n+'\n+\n+test_expect_success 'fetch.writeCommitGraph falls back to reachable scan without existing graph' '\n+\tgit init first-graph-source &&\n+\ttest_commit -C first-graph-source base &&\n+\tgit clone first-graph-source first-graph-dest &&\n+\ttest_commit -C first-graph-source fetched &&\n+\t(\n+\t\tcd first-graph-dest &&\n+\t\ttest_commit local &&\n+\t\trm -rf .git/objects/info/commit-graphs &&\n+\t\trm -f .git/objects/info/commit-graph &&\n+\t\tgit -c fetch.writeCommitGraph=true fetch origin &&\n+\t\ttest-tool read-graph commit-info fetched local base\n+\t)\n+'\n+\n test_expect_success 'fetch.writeCommitGraph with submodules' '\n \ttest_config_global protocol.file.allow always &&\n \tgit clone dups super &&\ndiff --git a/t/t5537-fetch-shallow.sh b/t/t5537-fetch-shallow.sh\nindex f323ceebd2..624bd124be 100755\n--- a/t/t5537-fetch-shallow.sh\n+++ b/t/t5537-fetch-shallow.sh\n@@ -135,6 +135,34 @@ test_expect_success 'fetch that requires changes in .git/shallow is filtered' '\n \t)\n '\n \n+test_expect_success 'fetch.writeCommitGraph skips refs that require changes in .git/shallow' '\n+\tgit clone --no-local --depth=2 .git shallow-graph &&\n+\t(\n+\t\tcd shallow-graph &&\n+\t\tgit checkout --orphan no-shallow &&\n+\t\tcommit no-shallow\n+\t) &&\n+\tgit init notshallow-graph &&\n+\tgit -C notshallow-graph -c fetch.writeCommitGraph=true \\\n+\t\tfetch ../shallow-graph/.git \"refs/heads/*:refs/remotes/shallow/*\" &&\n+\t(\n+\t\tcd shallow-graph &&\n+\t\tcommit no-shallow-2\n+\t) &&\n+\trejected=$(git -C shallow-graph rev-parse main) &&\n+\t(\n+\t\tcd notshallow-graph &&\n+\t\tgit -c fetch.writeCommitGraph=true \\\n+\t\t\tfetch ../shallow-graph/.git \"refs/heads/*:refs/remotes/shallow/*\" &&\n+\t\tgit for-each-ref --format=\"%(refname)\" >actual.refs &&\n+\t\techo refs/remotes/shallow/no-shallow >expect.refs &&\n+\t\ttest_cmp expect.refs actual.refs &&\n+\t\ttest-tool read-graph commit-info shallow/no-shallow &&\n+\t\ttest_expect_code 1 \\\n+\t\t\ttest-tool read-graph commit-info $rejected 2>/dev/null\n+\t)\n+'\n+\n test_expect_success 'fetch --update-shallow' '\n \t(\n \tcd shallow &&\n-- \ngitgitgadget\n\n"}]}