From 76524c2604bceee48481909e7824494498c39a34 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 28 Sep 2026 00:01:49 -0400 Subject: [PATCH 1/9] http: handle curl stripping creds from effective url When we detect that curl performed a redirect of a URL we requested, we update our base URL to match the new location and flush the http_auth credentials. This goes back to c93c92f309 (http: update base URLs when we see redirects, 2013-09-28). We detect the redirect by comparing the requested URL to the response from CURLINFO_EFFECTIVE_URL, using a simple string comparison. This has worked fine for years, but a change in the upcoming curl 8.23.0 adds a complication. If our URL directly contains credentials (like "https://user:pass@example.com/foo.git"), then as of 7a6bd027d0 (getinfo: make sure CURLINFO_EFFECTIVE_URL does not contain creds, 2026-09-21), curl will strip the credentials from what it returns (so just "https://example.com/foo.git" in this case). This breaks our direct string comparison, and we believe that we've been redirected. We flush our http_auth credentials, and now subsequent requests will use the reduced URL, causing us to re-request credentials from the user. Notably this causes t5550.15 (among others) to complain; it tries a clone with credentials in the URL, and fails if the user is prompted at all. We can handle this new behavior by doing a more careful comparison: if the direct string comparison fails, we'll strip out the credentials ourselves and compare. This is a little extra work, but in practice it should only happen once per process. I've used curl's curl_url() interface to do the stripping here, mostly because its behavior should match the stripping it does internally. And also, though we have code to parse a URL, we don't have any to reconstruct it, making a single string comparison hard. One alternative would be to parse with url_parse() or similar, and compare the individual fields (skipping username/password). I think that would probably also work in practice, but it seemed to me that the simplest change would be sticking with string comparisons. The curl_url() interface appeared in 7.62.0. We document that 7.61.0 is still supported, so I've made it conditional here. Only new versions strip the result from CURLINFO_EFFECTIVE_URL, so it's OK for very old versions to skip the extra comparison. Likewise if we encounter any errors, we just quietly skip the comparison. That's fine if you don't have creds in your URLs, and if you do, you'll get end up in the existing error path (a redirect warning, and eventually an auth failure). Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- git-curl-compat.h | 7 +++++++ http.c | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/git-curl-compat.h b/git-curl-compat.h index 032aaf7126c977..25678b5dbd2c80 100644 --- a/git-curl-compat.h +++ b/git-curl-compat.h @@ -28,6 +28,13 @@ * introduced, oldest first, in the official version of cURL library. */ +/** + * curl_url() interface added in 7.62.0 (October 2018) + */ +#if LIBCURL_VERSION_NUM >= 0x073e00 +#define GIT_CURL_HAVE_CURL_URL +#endif + /** * Versions before curl 7.66.0 (September 2019) required manually setting the * transfer-encoding for a streaming POST; after that this is handled diff --git a/http.c b/http.c index c8fcfd7693e897..4af3c29c769559 100644 --- a/http.c +++ b/http.c @@ -2315,6 +2315,45 @@ static int http_request(const char *url, return ret; } +#ifndef GIT_CURL_HAVE_CURL_URL +#define strip_url_credential(in) NULL +#else +static char *strip_url_credential(const char *in) +{ + char *ret = NULL; + CURLU *url; + + url = curl_url(); + if (!url) + goto out; + + if (curl_url_set(url, CURLUPART_URL, in, 0)) + goto out; + + curl_url_set(url, CURLUPART_USER, NULL, 0); + curl_url_set(url, CURLUPART_PASSWORD, NULL, 0); + curl_url_get(url, CURLUPART_URL, &ret, 0); + +out: + curl_url_cleanup(url); + return ret; +} +#endif + +static int match_effective_url(const char *asked, const char *got) +{ + char *stripped; + int ret; + + if (!strcmp(asked, got)) + return 1; + + stripped = strip_url_credential(asked); + ret = stripped && !strcmp(stripped, got); + curl_free(stripped); + return ret; +} + /* * Update the "base" url to a more appropriate value, as deduced by * redirects seen when requesting a URL starting with "url". @@ -2347,7 +2386,7 @@ static int update_url_from_redirect(struct strbuf *base, const char *tail; size_t new_len; - if (!strcmp(asked, got->buf)) + if (match_effective_url(asked, got->buf)) return 0; if (!skip_prefix(asked, base->buf, &tail)) From 199bd10b4238f061261eaff907429985780b8d9d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 12 Oct 2026 14:20:48 -0700 Subject: [PATCH 2/9] odb: run read-object hook during existence rechecks In a mounted VFS for Git enlistment, `git fetch` fails when a fetched ref tip is not yet in the local object store: remote: This repository requires the GVFS protocol (use "gvfs clone" or "scalar clone"). fatal: protocol error: bad pack header The same fetch succeeds with v2.55.0.vfs.0.10. This happens on branches that move faster than the cache server publishes prefetch packs, so the new tip is in no prefetch pack yet. `check_exist_and_connected()` in builtin/fetch.c calls `odb_has_object()` with `ODB_HAS_OBJECT_RECHECK_PACKED` for each ref tip. That call does not pass `ODB_HAS_OBJECT_FETCH_PROMISOR`, so `odb_has_object()` sets `OBJECT_INFO_SKIP_FETCH_OBJECT`. During the 2.56 rebase, 07a540e7882 ("gvfs-helper: create tool to fetch objects using the GVFS Protocol") added `!(flags & OBJECT_INFO_SKIP_FETCH_OBJECT)` to the read-object hook gate in `do_oid_object_info_extended()`. That condition is correct for the gvfs-helper call, which is a remote fetch. It is wrong for the hook call. The read-object hook is the local virtualization layer, not a promisor-remote fetch, so `OBJECT_INFO_SKIP_FETCH_OBJECT` must not suppress it. With the condition, the hook does not run for the ref tip, `odb_has_object()` returns false, and git runs fetch-pack against the server, which rejects the request. Without it, the hook downloads the tip and `check_connected()` returns early because of `GVFS_FETCH_SKIP_REACHABILITY_AND_UPLOADPACK`. Trace2 for the same fetch in the same enlistment confirms this. v2.55 runs `subprocess/read_object` and then `fetch/consume_refs`. v2.56 runs `fetch/fetch_refs` and then `git fetch-pack` with no `read_object` event. Simply removing the condition is not correct. `rev-list --missing` also sets `OBJECT_INFO_SKIP_FETCH_OBJECT` and must not run the hook, because it enumerates missing objects instead of materializing them. The fetch existence check and the `rev-list --missing` parse reach the hook gate with identical flags, so they cannot be told apart by the existing flags alone. The two paths differ in intent. The fetch path is an existence recheck through `odb_has_object(ODB_HAS_OBJECT_RECHECK_PACKED)`. The `rev-list --missing` path parses a commit and never sets `RECHECK_PACKED`. Propagate that signal: `odb_has_object()` now sets a new `OBJECT_INFO_RECHECK_PACKED` flag when the caller passes `ODB_HAS_OBJECT_RECHECK_PACKED`, and the hook gate runs when the caller is rechecking existence even if `OBJECT_INFO_SKIP_FETCH_OBJECT` is set. This restores the v2.55 behavior for existence rechecks and keeps the enumerate-only behavior for `rev-list --missing`. Add a regression test to t0499 that fetches a ref whose tip is not local, with a read-object hook installed. The hook must run and supply the tip, and git must not download a pack. The test uses a new type-preserving read-object hook helper, t/t0410/read-object-all, because the existing helper only serves blobs. Fixes https://github.com/microsoft/git/issues/1004 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Tyrie Vella --- odb.c | 5 +- odb.h | 10 ++++ t/t0410/read-object-all | 111 ++++++++++++++++++++++++++++++++++++++++ t/t0499-read-object.sh | 38 ++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100755 t/t0410/read-object-all diff --git a/odb.c b/odb.c index 95adfdb9f07db8..bc449913300ece 100644 --- a/odb.c +++ b/odb.c @@ -852,7 +852,8 @@ static enum odb_read_status do_oid_object_info_extended(struct object_database * if (ret != ODB_READ_NOT_FOUND) corrupt = true; if (gvfs_virtualize_objects(odb->repo) && !tried_hook && - !(flags & OBJECT_INFO_SKIP_FETCH_OBJECT)) { + (!(flags & OBJECT_INFO_SKIP_FETCH_OBJECT) || + (flags & OBJECT_INFO_RECHECK_PACKED))) { // TODO Assert or at least trace2 if gvfs-helper // TODO was tried and failed and then read-object-hook // TODO is successful at getting this object. @@ -1096,6 +1097,8 @@ int odb_has_object(struct object_database *odb, const struct object_id *oid, return 0; if (!(flags & ODB_HAS_OBJECT_RECHECK_PACKED)) object_info_flags |= OBJECT_INFO_QUICK; + else + object_info_flags |= OBJECT_INFO_RECHECK_PACKED; if (!(flags & ODB_HAS_OBJECT_FETCH_PROMISOR)) object_info_flags |= OBJECT_INFO_SKIP_FETCH_OBJECT; diff --git a/odb.h b/odb.h index f6deef02f6b2cf..2a3eca97a98808 100644 --- a/odb.h +++ b/odb.h @@ -457,6 +457,16 @@ enum object_info_flags { OBJECT_INFO_SKIP_LOOSE = (1 << 5), OBJECT_INFO_SKIP_PACKED = (1 << 6), + /* + * The caller is rechecking object existence (for example via + * `odb_has_object()` with `ODB_HAS_OBJECT_RECHECK_PACKED`), not + * enumerating missing objects. In a virtualized object store this + * allows the read-object hook to materialize the object even when + * `OBJECT_INFO_SKIP_FETCH_OBJECT` is set, because the hook is the + * local virtualization layer rather than a promisor-remote fetch. + */ + OBJECT_INFO_RECHECK_PACKED = (1 << 7), + /* * This is meant for bulk prefetching of missing blobs in a partial * clone. Implies OBJECT_INFO_SKIP_FETCH_OBJECT and OBJECT_INFO_QUICK. diff --git a/t/t0410/read-object-all b/t/t0410/read-object-all new file mode 100755 index 00000000000000..2d10a5f38cd49b --- /dev/null +++ b/t/t0410/read-object-all @@ -0,0 +1,111 @@ +#!/usr/bin/perl +# +# Example implementation of the Git read-object protocol version 1 that +# serves objects of any type (not just blobs) from a host repository. +# See Documentation/technical/read-object-protocol.txt +# +# The host repository is taken from the READ_OBJECT_DIR environment +# variable so the same hook can be reused by different tests. It must +# point at the ".git" directory (or bare repository) that holds the +# objects to be served. +# + +use strict; +use warnings; + +my $DIR = $ENV{"READ_OBJECT_DIR"} || "../.git/"; + +sub packet_bin_read { + my $buffer; + my $bytes_read = read STDIN, $buffer, 4; + if ( $bytes_read == 0 ) { + + # EOF - Git stopped talking to us! + exit(); + } + elsif ( $bytes_read != 4 ) { + die "invalid packet: '$buffer'"; + } + my $pkt_size = hex($buffer); + if ( $pkt_size == 0 ) { + return ( 1, "" ); + } + elsif ( $pkt_size > 4 ) { + my $content_size = $pkt_size - 4; + $bytes_read = read STDIN, $buffer, $content_size; + if ( $bytes_read != $content_size ) { + die "invalid packet ($content_size bytes expected; $bytes_read bytes read)"; + } + return ( 0, $buffer ); + } + else { + die "invalid packet size: $pkt_size"; + } +} + +sub packet_txt_read { + my ( $res, $buf ) = packet_bin_read(); + unless ( $buf =~ s/\n$// ) { + die "A non-binary line MUST be terminated by an LF."; + } + return ( $res, $buf ); +} + +sub packet_bin_write { + my $buf = shift; + print STDOUT sprintf( "%04x", length($buf) + 4 ); + print STDOUT $buf; + STDOUT->flush(); +} + +sub packet_txt_write { + packet_bin_write( $_[0] . "\n" ); +} + +sub packet_flush { + print STDOUT sprintf( "%04x", 0 ); + STDOUT->flush(); +} + +( packet_txt_read() eq ( 0, "git-read-object-client" ) ) || die "bad initialize"; +( packet_txt_read() eq ( 0, "version=1" ) ) || die "bad version"; +( packet_bin_read() eq ( 1, "" ) ) || die "bad version end"; + +packet_txt_write("git-read-object-server"); +packet_txt_write("version=1"); +packet_flush(); + +( packet_txt_read() eq ( 0, "capability=get" ) ) || die "bad capability"; +( packet_bin_read() eq ( 1, "" ) ) || die "bad capability end"; + +packet_txt_write("capability=get"); +packet_flush(); + +while (1) { + my ($command) = packet_txt_read() =~ /^command=([^=]+)$/; + + if ( $command eq "get" ) { + my ($sha1) = packet_txt_read() =~ /^sha1=([0-9a-f]{40,64})$/; + packet_bin_read(); + + my $type = `git --git-dir="$DIR" cat-file -t $sha1 2>/dev/null`; + chomp $type; + if ( $type eq "" ) { + packet_txt_write("status=error"); + packet_flush(); + next; + } + + system ('git --git-dir="' . $DIR . '" cat-file ' . $type . ' ' . $sha1 . + ' | git -c core.virtualizeobjects=false hash-object -t ' . $type . + ' -w --stdin >/dev/null 2>&1'); + packet_txt_write(($?) ? "status=error" : "status=success"); + packet_flush(); + + open my $log, '>>.git/read-object-hook.log'; + print $log "Read object $sha1, exit code $?\n"; + close $log; + } else { + die "bad command '$command'"; + } +} diff --git a/t/t0499-read-object.sh b/t/t0499-read-object.sh index 5ae8ca2676f3b4..a5c2dd1b0558ab 100755 --- a/t/t0499-read-object.sh +++ b/t/t0499-read-object.sh @@ -85,4 +85,42 @@ test_expect_success 'no-fetch commit lookups still report corruption' ' done ' +test_expect_success PERL_TEST_HELPERS 'setup fetch with a virtualized ref tip' ' + git init -b main fetch-server && + git -C fetch-server commit --allow-empty -m only && + server_tip=$(git -C fetch-server rev-parse HEAD) && + server_odb="$(pwd)/fetch-server/.git" && + + git init -b main fetch-client && + mkdir -p fetch-client/.git/hooks && + sed "1s|/usr/bin/perl|$PERL_PATH|" \ + <$TEST_DIRECTORY/t0410/read-object-all \ + >fetch-client/.git/hooks/read-object && + chmod +x fetch-client/.git/hooks/read-object && + git -C fetch-client config core.virtualizeobjects true +' + +# The ref tip is not in the client object store and is newer than any +# pack a GVFS cache server has published. check_exist_and_connected() +# calls odb_has_object(ODB_HAS_OBJECT_RECHECK_PACKED), which sets +# OBJECT_INFO_SKIP_FETCH_OBJECT. The read-object hook must still run and +# supply the tip, so that git skips the fetch-pack exchange. +test_expect_success PERL_TEST_HELPERS 'read-object hook runs for missing tip' ' + test_path_is_missing fetch-client/.git/read-object-hook.log && + + READ_OBJECT_DIR="$server_odb" \ + git -C fetch-client fetch "$(pwd)/fetch-server" \ + +refs/heads/main:refs/remotes/server/main && + + echo "$server_tip" >expect && + git -C fetch-client rev-parse refs/remotes/server/main >actual && + test_cmp expect actual && + + find fetch-client/.git/objects/pack -name "*.pack" >packs && + test_must_be_empty packs && + + test_path_is_file fetch-client/.git/read-object-hook.log && + test_grep "$server_tip" fetch-client/.git/read-object-hook.log +' + test_done From 0dc2ba2d4f0b5b94371d39ef69fea614debb71d5 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 14 Oct 2026 14:26:00 +0200 Subject: [PATCH 3/9] fixup! odb: run read-object hook during existence rechecks The new read-object test helper's CR/LF shebang prevents Linux from resolving its interpreter. Make the missing-tip fetch test runnable without changing the helper's command or protocol behavior. Assisted-by: GPT-6.1 Sol Signed-off-by: Johannes Schindelin --- t/t0410/read-object-all | 225 ++++++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 111 deletions(-) diff --git a/t/t0410/read-object-all b/t/t0410/read-object-all index 2d10a5f38cd49b..2b18f467dfc3c3 100755 --- a/t/t0410/read-object-all +++ b/t/t0410/read-object-all @@ -1,111 +1,114 @@ -#!/usr/bin/perl -# -# Example implementation of the Git read-object protocol version 1 that -# serves objects of any type (not just blobs) from a host repository. -# See Documentation/technical/read-object-protocol.txt -# -# The host repository is taken from the READ_OBJECT_DIR environment -# variable so the same hook can be reused by different tests. It must -# point at the ".git" directory (or bare repository) that holds the -# objects to be served. -# - -use strict; -use warnings; - -my $DIR = $ENV{"READ_OBJECT_DIR"} || "../.git/"; - -sub packet_bin_read { - my $buffer; - my $bytes_read = read STDIN, $buffer, 4; - if ( $bytes_read == 0 ) { - - # EOF - Git stopped talking to us! - exit(); - } - elsif ( $bytes_read != 4 ) { - die "invalid packet: '$buffer'"; - } - my $pkt_size = hex($buffer); - if ( $pkt_size == 0 ) { - return ( 1, "" ); - } - elsif ( $pkt_size > 4 ) { - my $content_size = $pkt_size - 4; - $bytes_read = read STDIN, $buffer, $content_size; - if ( $bytes_read != $content_size ) { - die "invalid packet ($content_size bytes expected; $bytes_read bytes read)"; - } - return ( 0, $buffer ); - } - else { - die "invalid packet size: $pkt_size"; - } -} - -sub packet_txt_read { - my ( $res, $buf ) = packet_bin_read(); - unless ( $buf =~ s/\n$// ) { - die "A non-binary line MUST be terminated by an LF."; - } - return ( $res, $buf ); -} - -sub packet_bin_write { - my $buf = shift; - print STDOUT sprintf( "%04x", length($buf) + 4 ); - print STDOUT $buf; - STDOUT->flush(); -} - -sub packet_txt_write { - packet_bin_write( $_[0] . "\n" ); -} - -sub packet_flush { - print STDOUT sprintf( "%04x", 0 ); - STDOUT->flush(); -} - -( packet_txt_read() eq ( 0, "git-read-object-client" ) ) || die "bad initialize"; -( packet_txt_read() eq ( 0, "version=1" ) ) || die "bad version"; -( packet_bin_read() eq ( 1, "" ) ) || die "bad version end"; - -packet_txt_write("git-read-object-server"); -packet_txt_write("version=1"); -packet_flush(); - -( packet_txt_read() eq ( 0, "capability=get" ) ) || die "bad capability"; -( packet_bin_read() eq ( 1, "" ) ) || die "bad capability end"; - -packet_txt_write("capability=get"); -packet_flush(); - -while (1) { - my ($command) = packet_txt_read() =~ /^command=([^=]+)$/; - - if ( $command eq "get" ) { - my ($sha1) = packet_txt_read() =~ /^sha1=([0-9a-f]{40,64})$/; - packet_bin_read(); - - my $type = `git --git-dir="$DIR" cat-file -t $sha1 2>/dev/null`; - chomp $type; - if ( $type eq "" ) { - packet_txt_write("status=error"); - packet_flush(); - next; - } - - system ('git --git-dir="' . $DIR . '" cat-file ' . $type . ' ' . $sha1 . - ' | git -c core.virtualizeobjects=false hash-object -t ' . $type . - ' -w --stdin >/dev/null 2>&1'); - packet_txt_write(($?) ? "status=error" : "status=success"); - packet_flush(); - - open my $log, '>>.git/read-object-hook.log'; - print $log "Read object $sha1, exit code $?\n"; - close $log; - } else { - die "bad command '$command'"; - } -} +#!/usr/bin/perl +# +# Example implementation of the Git read-object protocol version 1 that +# serves objects of any type (not just blobs) from a host repository. +# See Documentation/technical/read-object-protocol.txt +# +# The host repository is taken from the READ_OBJECT_DIR environment +# variable so the same hook can be reused by different tests. It must +# point at the ".git" directory (or bare repository) that holds the +# objects to be served. +# + +use strict; +use warnings; + +my $DIR = $ENV{"READ_OBJECT_DIR"} || "../.git/"; + +sub packet_bin_read { + my $buffer; + my $bytes_read = read STDIN, $buffer, 4; + if ( $bytes_read == 0 ) { + + # EOF - Git stopped talking to us! + exit(); + } + elsif ( $bytes_read != 4 ) { + die "invalid packet: '$buffer'"; + } + my $pkt_size = hex($buffer); + if ( $pkt_size == 0 ) { + return ( 1, "" ); + } + elsif ( $pkt_size > 4 ) { + my $content_size = $pkt_size - 4; + $bytes_read = read STDIN, $buffer, $content_size; + if ( $bytes_read != $content_size ) { + die "invalid packet ($content_size bytes expected; " . + "$bytes_read bytes read)"; + } + return ( 0, $buffer ); + } + else { + die "invalid packet size: $pkt_size"; + } +} + +sub packet_txt_read { + my ( $res, $buf ) = packet_bin_read(); + unless ( $buf =~ s/\n$// ) { + die "A non-binary line MUST be terminated by an LF."; + } + return ( $res, $buf ); +} + +sub packet_bin_write { + my $buf = shift; + print STDOUT sprintf( "%04x", length($buf) + 4 ); + print STDOUT $buf; + STDOUT->flush(); +} + +sub packet_txt_write { + packet_bin_write( $_[0] . "\n" ); +} + +sub packet_flush { + print STDOUT sprintf( "%04x", 0 ); + STDOUT->flush(); +} + +( packet_txt_read() eq ( 0, "git-read-object-client" ) ) || + die "bad initialize"; +( packet_txt_read() eq ( 0, "version=1" ) ) || die "bad version"; +( packet_bin_read() eq ( 1, "" ) ) || die "bad version end"; + +packet_txt_write("git-read-object-server"); +packet_txt_write("version=1"); +packet_flush(); + +( packet_txt_read() eq ( 0, "capability=get" ) ) || die "bad capability"; +( packet_bin_read() eq ( 1, "" ) ) || die "bad capability end"; + +packet_txt_write("capability=get"); +packet_flush(); + +while (1) { + my ($command) = packet_txt_read() =~ /^command=([^=]+)$/; + + if ( $command eq "get" ) { + my ($sha1) = packet_txt_read() =~ /^sha1=([0-9a-f]{40,64})$/; + packet_bin_read(); + + my $type = `git --git-dir="$DIR" cat-file -t $sha1 2>/dev/null`; + chomp $type; + if ( $type eq "" ) { + packet_txt_write("status=error"); + packet_flush(); + next; + } + + system ('git --git-dir="' . $DIR . '" cat-file ' . + $type . ' ' . $sha1 . + ' | git -c core.virtualizeobjects=false hash-object ' . + '-t ' . $type . ' -w --stdin >/dev/null 2>&1'); + packet_txt_write(($?) ? "status=error" : "status=success"); + packet_flush(); + + open my $log, '>>.git/read-object-hook.log'; + print $log "Read object $sha1, exit code $?\n"; + close $log; + } else { + die "bad command '$command'"; + } +} From 6e7ca6100751bce39e89f785b8fed7a2156f52a4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 14 Oct 2026 14:59:15 +0200 Subject: [PATCH 4/9] fixup! odb: run read-object hook during existence rechecks An explicit virtualized fetch should be allowed to acquire missing ref tips through its object providers, rather than rely on `fetch-pack`, which a GVFS-only server rejects. This matches the permission already used by `update_local_ref()` and leaves non-virtualized fetches unchanged. Assisted-by: GPT-6.1 Sol Signed-off-by: Johannes Schindelin --- builtin/fetch.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/builtin/fetch.c b/builtin/fetch.c index 1e0854c2dbe549..23b1ed623b7532 100644 --- a/builtin/fetch.c +++ b/builtin/fetch.c @@ -1401,6 +1401,7 @@ static int check_exist_and_connected(struct ref *ref_map) { struct ref *rm = ref_map; struct check_connected_options opt = CHECK_CONNECTED_INIT; + enum odb_has_object_flags flags = ODB_HAS_OBJECT_RECHECK_PACKED; struct ref *r; /* @@ -1423,11 +1424,15 @@ static int check_exist_and_connected(struct ref *ref_map) /* * check_connected() allows objects to merely be promised, but - * we need all direct targets to exist. + * we need all direct targets to exist. A virtualized object store + * may need to fetch those targets through its object providers. */ + if (gvfs_virtualize_objects(the_repository)) + flags |= ODB_HAS_OBJECT_FETCH_PROMISOR; + for (r = rm; r; r = r->next) { if (!odb_has_object(the_repository->objects, &r->old_oid, - ODB_HAS_OBJECT_RECHECK_PACKED)) + flags)) return -1; } From 9de0a3314e91ccc15751d4b3ca803c32cd93a7a4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 14 Oct 2026 15:03:46 +0200 Subject: [PATCH 5/9] fixup! odb: run read-object hook during existence rechecks Rechecking local packs should not fetch promised objects by default. Virtualized fetches now ask for missing objects explicitly, so other no-fetch checks can keep working as before. They should still retry local packs and report corrupt objects. Assisted-by: GPT-6.1 Sol Signed-off-by: Johannes Schindelin --- odb.c | 5 +---- odb.h | 10 ---------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/odb.c b/odb.c index bc449913300ece..95adfdb9f07db8 100644 --- a/odb.c +++ b/odb.c @@ -852,8 +852,7 @@ static enum odb_read_status do_oid_object_info_extended(struct object_database * if (ret != ODB_READ_NOT_FOUND) corrupt = true; if (gvfs_virtualize_objects(odb->repo) && !tried_hook && - (!(flags & OBJECT_INFO_SKIP_FETCH_OBJECT) || - (flags & OBJECT_INFO_RECHECK_PACKED))) { + !(flags & OBJECT_INFO_SKIP_FETCH_OBJECT)) { // TODO Assert or at least trace2 if gvfs-helper // TODO was tried and failed and then read-object-hook // TODO is successful at getting this object. @@ -1097,8 +1096,6 @@ int odb_has_object(struct object_database *odb, const struct object_id *oid, return 0; if (!(flags & ODB_HAS_OBJECT_RECHECK_PACKED)) object_info_flags |= OBJECT_INFO_QUICK; - else - object_info_flags |= OBJECT_INFO_RECHECK_PACKED; if (!(flags & ODB_HAS_OBJECT_FETCH_PROMISOR)) object_info_flags |= OBJECT_INFO_SKIP_FETCH_OBJECT; diff --git a/odb.h b/odb.h index 2a3eca97a98808..f6deef02f6b2cf 100644 --- a/odb.h +++ b/odb.h @@ -457,16 +457,6 @@ enum object_info_flags { OBJECT_INFO_SKIP_LOOSE = (1 << 5), OBJECT_INFO_SKIP_PACKED = (1 << 6), - /* - * The caller is rechecking object existence (for example via - * `odb_has_object()` with `ODB_HAS_OBJECT_RECHECK_PACKED`), not - * enumerating missing objects. In a virtualized object store this - * allows the read-object hook to materialize the object even when - * `OBJECT_INFO_SKIP_FETCH_OBJECT` is set, because the hook is the - * local virtualization layer rather than a promisor-remote fetch. - */ - OBJECT_INFO_RECHECK_PACKED = (1 << 7), - /* * This is meant for bulk prefetching of missing blobs in a partial * clone. Implies OBJECT_INFO_SKIP_FETCH_OBJECT and OBJECT_INFO_QUICK. From 25d7c753618cb0e0b49d431888836958d25171ef Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 14 Oct 2026 15:22:48 +0200 Subject: [PATCH 6/9] fixup! odb: run read-object hook during existence rechecks Small fetches can be unpacked as loose objects, so the no-pack assertion alone does not prove that regular object transfer was avoided. The regression test must also rule out the `fetch_refs` path, while allowing `upload-pack` for local ref advertisement. Assisted-by: GPT-6.1 Sol Signed-off-by: Johannes Schindelin --- t/t0499-read-object.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/t/t0499-read-object.sh b/t/t0499-read-object.sh index a5c2dd1b0558ab..6c7c56b83c2fed 100755 --- a/t/t0499-read-object.sh +++ b/t/t0499-read-object.sh @@ -101,14 +101,14 @@ test_expect_success PERL_TEST_HELPERS 'setup fetch with a virtualized ref tip' ' ' # The ref tip is not in the client object store and is newer than any -# pack a GVFS cache server has published. check_exist_and_connected() -# calls odb_has_object(ODB_HAS_OBJECT_RECHECK_PACKED), which sets -# OBJECT_INFO_SKIP_FETCH_OBJECT. The read-object hook must still run and -# supply the tip, so that git skips the fetch-pack exchange. +# pack a GVFS cache server has published. The fetch existence check must +# allow object acquisition in a virtualized repository so that the +# read-object hook supplies the tip without a fetch-pack exchange. test_expect_success PERL_TEST_HELPERS 'read-object hook runs for missing tip' ' test_path_is_missing fetch-client/.git/read-object-hook.log && READ_OBJECT_DIR="$server_odb" \ + GIT_TRACE2_EVENT="$PWD/fetch.trace" \ git -C fetch-client fetch "$(pwd)/fetch-server" \ +refs/heads/main:refs/remotes/server/main && @@ -116,6 +116,8 @@ test_expect_success PERL_TEST_HELPERS 'read-object hook runs for missing tip' ' git -C fetch-client rev-parse refs/remotes/server/main >actual && test_cmp expect actual && + test_grep ! "\"label\":\"fetch_refs\"" fetch.trace && + find fetch-client/.git/objects/pack -name "*.pack" >packs && test_must_be_empty packs && From a7b07b3db84676f1f8d87c2cf821a6860034bbcd Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Wed, 14 Oct 2026 21:40:40 +0200 Subject: [PATCH 7/9] amend! odb: run read-object hook during existence rechecks amend! gvfs-helper: create tool to fetch objects using the GVFS Protocol gvfs-helper: create tool to fetch objects using the GVFS Protocol Create gvfs-helper to download objects and configuration data from a GVFS cache server or Git server through the GVFS Protocol REST API. It uses libcurl and writes loose objects or packfiles. Git talks to it through gvfs-helper-client, a long-running subprocess. When Git is asked to check for an object without downloading it, the helper and read-object hook must not download it. Git should still retry local packs and report corrupt objects. This lets it find an object in another local pack when a stale multi-pack-index points to a removed pack. During `git fetch`, however, a virtualized repository may need to download the objects its remote refs point to. Let that check use the helper or read-object hook instead of falling back to a regular fetch, which a GVFS-only server does not support. Assisted-by: GPT-6.1 Sol Signed-off-by: Jeff Hostetler Signed-off-by: Derrick Stolee Signed-off-by: Tyrie Vella Signed-off-by: Johannes Schindelin From 1082a7fa5f44a3b6bb39e6398a0d499af56f14cd Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 15 Oct 2026 08:23:20 +0200 Subject: [PATCH 8/9] fetch: delete extraneous empty line This doubled empty line was introduced by 3c7bab06e12 (fetch: add --refetch option, 2022-03-28). Assisted-by: GPT-6.1 Sol Signed-off-by: Johannes Schindelin --- builtin/fetch.c | 1 - 1 file changed, 1 deletion(-) diff --git a/builtin/fetch.c b/builtin/fetch.c index 23b1ed623b7532..46151a8edb1e25 100644 --- a/builtin/fetch.c +++ b/builtin/fetch.c @@ -1421,7 +1421,6 @@ static int check_exist_and_connected(struct ref *ref_map) if (refetch) return -1; - /* * check_connected() allows objects to merely be promised, but * we need all direct targets to exist. A virtualized object store From 3b832f0ff18080edb67556ff085826f3b65b1c5d Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 15 Oct 2026 13:40:30 +0200 Subject: [PATCH 9/9] fixup! http: optionally load libcurl lazily The `jk/http-curl-strip-creds` topic introduces callers to yet-uncovered cURL library functions, which we hereby cover in the lazyload-curl shim. Signed-off-by: Johannes Schindelin --- compat/lazyload-curl.c | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/compat/lazyload-curl.c b/compat/lazyload-curl.c index a6a3f7e3a7aeaa..c361eab85cd1fb 100644 --- a/compat/lazyload-curl.c +++ b/compat/lazyload-curl.c @@ -175,6 +175,20 @@ static curl_easy_setopt_pointer_type curl_easy_setopt_pointer_func; typedef CURLcode (*curl_easy_setopt_off_t_type)(CURL *curl, CURLoption opt, curl_off_t value); static curl_easy_setopt_off_t_type curl_easy_setopt_off_t_func; +typedef CURLU *(*curl_url_type)(void); +static curl_url_type curl_url_func; + +typedef void (*curl_url_cleanup_type)(CURLU *u); +static curl_url_cleanup_type curl_url_cleanup_func; + +typedef CURLUcode (*curl_url_get_type)(const CURLU *u, CURLUPart what, + char **part, unsigned int flags); +static curl_url_get_type curl_url_get_func; + +typedef CURLUcode (*curl_url_set_type)(CURLU *u, CURLUPart what, + const char *part, unsigned int flags); +static curl_url_set_type curl_url_set_func; + static char ssl_backend[64]; static void lazy_load_curl(void) @@ -231,6 +245,11 @@ static void lazy_load_curl(void) curl_easy_setopt_long_func = (curl_easy_setopt_long_type)curl_easy_setopt_func; curl_easy_setopt_pointer_func = (curl_easy_setopt_pointer_type)curl_easy_setopt_func; curl_easy_setopt_off_t_func = (curl_easy_setopt_off_t_type)curl_easy_setopt_func; + + curl_url_func = (curl_url_type)load_function(libcurl, "curl_url"); + curl_url_cleanup_func = (curl_url_cleanup_type)load_function(libcurl, "curl_url_cleanup"); + curl_url_get_func = (curl_url_get_type)load_function(libcurl, "curl_url_get"); + curl_url_set_func = (curl_url_set_type)load_function(libcurl, "curl_url_set"); } struct curl_version_info_data *curl_version_info(CURLversion version) @@ -426,3 +445,25 @@ CURLcode curl_easy_setopt(CURL *curl, CURLoption opt, ...) va_end(ap); return res; } + +CURLU *curl_url(void) +{ + return curl_url_func(); +} + +void curl_url_cleanup(CURLU *u) +{ + curl_url_cleanup_func(u); +} + +CURLUcode curl_url_get(const CURLU *u, CURLUPart what, + char **part, unsigned int flags) +{ + return curl_url_get_func(u, what, part, flags); +} + +CURLUcode curl_url_set(CURLU *u, CURLUPart what, + const char *part, unsigned int flags) +{ + return curl_url_set_func(u, what, part, flags); +}