param(
[string]$Git = 'C:\Program Files\Git\mingw64\bin\git.exe'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$root = $PSScriptRoot
Set-Location -LiteralPath $root
$runName = 'run-' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 6)
$run = Join-Path $root $runName
$null = New-Item -ItemType Directory -Path $run
foreach ($name in @('home', 'scratch', 'empty-hooks', 'empty-template', 'payloads')) {
$null = New-Item -ItemType Directory -Path (Join-Path $run $name)
}
[IO.File]::WriteAllText((Join-Path $run 'empty.config'), '')
$utf8 = New-Object Text.UTF8Encoding($false)
$commands = Join-Path $run 'commands.txt'
$script:sequence = 0
[IO.File]::WriteAllText((Join-Path $root 'repro-results.txt'),
"INCOMPLETE: run $runName started. If interrupted, inspect its commands.txt.`r`n", $utf8)
function Public-Text([string]$Text) {
return $Text.Replace($root, '<REPRO>').Replace($root.Replace('\', '/'), '<REPRO>')
}
function Quote-Argument([string]$Text) {
if ($Text -notmatch '[\s"]' -and $Text.Length -gt 0) { return $Text }
return '"' + ([regex]::Replace(
[regex]::Replace($Text, '(\\*)"', '$1$1\"'), '(\\+)$', '$1$1')) + '"'
}
function Invoke-Git {
param(
[string]$Directory,
[string[]]$Arguments,
[string]$InputText = '',
[string]$Trace = '',
[switch]$AllowFailure
)
$script:sequence++
$common = @(
'-c', ('core.hooksPath=' + (Join-Path $run 'empty-hooks')),
'-c', ('init.templateDir=' + (Join-Path $run 'empty-template')),
'-c', 'user.name=Synthetic Repro',
'-c', 'user.email=synthetic@example.invalid',
'-c', 'commit.gpgSign=false',
'-c', 'core.fsmonitor=false',
'-c', 'core.commitGraph=false',
'-c', 'core.useGvfsHelper=false',
'-c', 'core.gvfs=0',
'-c', 'gc.auto=0',
'-c', 'maintenance.auto=false',
'-c', 'protocol.allow=never',
'-c', 'protocol.file.allow=always',
'-c', 'credential.helper=',
'-c', 'http.proxy=',
'-c', 'http.followRedirects=false',
'-c', 'http.lowSpeedLimit=1',
'-c', 'http.lowSpeedTime=5'
)
$psi = New-Object Diagnostics.ProcessStartInfo
$psi.FileName = $Git
$psi.WorkingDirectory = $Directory
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.Arguments = (($common + $Arguments | ForEach-Object { Quote-Argument $_ }) -join ' ')
foreach ($key in @($psi.EnvironmentVariables.Keys)) {
if ($key -match '^(GIT_|GCM_|SSH_|CURL_|HTTP_PROXY$|HTTPS_PROXY$|ALL_PROXY$|NO_PROXY$)') {
$psi.EnvironmentVariables.Remove($key)
}
}
foreach ($key in @('HOME', 'USERPROFILE', 'XDG_CONFIG_HOME')) {
$psi.EnvironmentVariables[$key] = Join-Path $run 'home'
}
foreach ($key in @('TMP', 'TEMP', 'TMPDIR')) {
$psi.EnvironmentVariables[$key] = Join-Path $run 'scratch'
}
$psi.EnvironmentVariables['GIT_CONFIG_NOSYSTEM'] = '1'
$psi.EnvironmentVariables['GIT_CONFIG_SYSTEM'] = Join-Path $run 'empty.config'
$psi.EnvironmentVariables['GIT_CONFIG_GLOBAL'] = Join-Path $run 'empty.config'
$psi.EnvironmentVariables['GIT_ATTR_NOSYSTEM'] = '1'
$psi.EnvironmentVariables['GIT_CEILING_DIRECTORIES'] = $root
$psi.EnvironmentVariables['GIT_TERMINAL_PROMPT'] = '0'
$psi.EnvironmentVariables['GCM_INTERACTIVE'] = 'never'
$psi.EnvironmentVariables['GIT_AUTHOR_DATE'] = '2001-01-01T00:00:00+0000'
$psi.EnvironmentVariables['GIT_COMMITTER_DATE'] = '2001-01-01T00:00:00+0000'
$psi.EnvironmentVariables['NO_PROXY'] = '127.0.0.1,localhost'
$psi.EnvironmentVariables['LC_ALL'] = 'C'
$psi.EnvironmentVariables['PATH'] = (Split-Path -Parent $Git) + ';' +
[IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $Git) '..\..\usr\bin')) +
";$env:SystemRoot\System32;$env:SystemRoot"
if ($Trace) { $psi.EnvironmentVariables['GIT_TRACE2_EVENT'] = $Trace }
[IO.File]::AppendAllText($commands,
("[{0}] cwd={1}`r`n& {2} {3}`r`nstdin={4}`r`n" -f
$script:sequence, (Public-Text $Directory), (Quote-Argument $Git),
(Public-Text $psi.Arguments), $InputText.Replace("`n", '\n')), $utf8)
$process = New-Object Diagnostics.Process
$process.StartInfo = $psi
$null = $process.Start()
$outTask = $process.StandardOutput.ReadToEndAsync()
$errTask = $process.StandardError.ReadToEndAsync()
$process.StandardInput.Write($InputText)
$process.StandardInput.Close()
if (-not $process.WaitForExit(60000)) {
# Only this explicitly created synthetic Git process is owned.
$process.Kill()
throw 'Owned Git command exceeded 60 seconds; reproduction is inconclusive.'
}
$stdout = $outTask.Result
$stderr = $errTask.Result
$code = $process.ExitCode
$process.Dispose()
[IO.File]::AppendAllText($commands,
("exit={0}`r`nstdout:`r`n{1}stderr:`r`n{2}`r`n" -f
$code, (Public-Text $stdout), (Public-Text $stderr)), $utf8)
if ($Trace -and (Test-Path -LiteralPath $Trace)) {
# Trace2 emits both JSON-escaped Windows paths and forward-slash paths.
$lines = [IO.File]::ReadAllLines($Trace) | Where-Object {
$_ -notmatch '"event":"cmd_ancestry"|"category":"process"'
}
$text = $lines -join "`n"
$text = $text.Replace($root.Replace('\', '\\'), '<REPRO>')
$text = [regex]::Replace($text, '-H[0-9a-fA-F]{8}-', '-H00000000-')
[IO.File]::WriteAllText($Trace, (Public-Text $text), $utf8)
}
if ($code -ne 0 -and -not $AllowFailure) {
throw "Synthetic Git command failed ($code): $($Arguments -join ' ')`n$(Public-Text $stderr)"
}
return [pscustomobject]@{ ExitCode = $code; Out = $stdout.Trim(); Err = (Public-Text $stderr.Trim()) }
}
function Get-PackSnapshot([string]$Directory) {
return ((Get-ChildItem -LiteralPath (Join-Path $Directory '.git\objects\pack') -File |
Sort-Object Name | ForEach-Object {
$_.Name + ' ' + (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}) -join "`n")
}
function Assert-PackedCommits([string]$Directory, [string[]]$Options) {
$inputOids = ($packs.Commit -join "`n") + "`n"
$objects = Invoke-Git $Directory ($Options + @('cat-file', '--batch-check')) $inputOids
$indexes = @($packs | ForEach-Object { '.git\objects\pack\' + $_.Pack.Replace('.pack', '.idx') })
$verified = Invoke-Git $Directory (@('verify-pack', '-v') + $indexes)
foreach ($entry in $packs) {
if ($objects.Out -notmatch ("(?m)^" + $entry.Commit + ' commit \d+\r?$')) {
throw "Fresh read failed for $($entry.Commit)."
}
if ($verified.Out -notmatch ("(?m)^" + $entry.Commit + ' commit ')) {
throw "Commit absent from the original packs: $($entry.Commit)."
}
}
}
$version = (Invoke-Git $run @('--version')).Out
$seed = Join-Path $run 'seed'
$remote = Join-Path $run 'remote'
$null = Invoke-Git $run @('init', '--initial-branch=main', $seed)
$tree = (Invoke-Git $seed @('mktree')).Out
$oids = @()
$packs = @()
for ($n = 1; $n -le 6; $n++) {
$args = @('commit-tree', $tree)
if ($oids.Count) { $args += @('-p', $oids[-1]) }
$args += @('-m', "Synthetic commit $n", '-m', 'Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>')
$oid = (Invoke-Git $seed $args).Out
$oids += $oid
$loose = Join-Path $seed ('.git\objects\' + $oid.Substring(0, 2) + '\' + $oid.Substring(2))
Copy-Item -LiteralPath $loose -Destination (Join-Path $run "payloads\$oid")
$inputOids = "$oid`n"
if ($n -eq 1) {
$inputOids += "$tree`n"
Copy-Item -LiteralPath (Join-Path $seed ('.git\objects\' + $tree.Substring(0, 2) + '\' + $tree.Substring(2))) -Destination (Join-Path $run "payloads\$tree")
}
$hash = (Invoke-Git $seed @('pack-objects', '--window=0', '.git\objects\pack\pack') $inputOids).Out
$packs += [pscustomobject]@{ Commit = $oid; Pack = "pack-$hash.pack" }
}
$null = Invoke-Git $seed @('update-ref', 'refs/heads/main', $oids[0])
$null = Invoke-Git $seed @('update-ref', 'refs/heads/available', $oids[-1])
$null = Invoke-Git $seed @('prune-packed')
$null = Invoke-Git $seed @('multi-pack-index', 'write')
$null = Invoke-Git $seed @('multi-pack-index', 'verify')
$null = Invoke-Git $seed @('checkout', 'main')
$null = Invoke-Git $seed @('fsck', '--full')
$null = Invoke-Git $run @('init', '--bare', '--initial-branch=main', $remote)
Copy-Item -Path (Join-Path $seed '.git\objects\pack\*') -Destination (Join-Path $remote 'objects\pack')
$null = Invoke-Git $remote @('update-ref', 'refs/heads/main', $oids[-1])
$null = Invoke-Git $seed @('remote', 'add', 'origin', '..\remote')
$packs | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $run 'fixture.json') -Encoding UTF8
$results = @()
foreach ($case in @('plain-midx', 'helper-midx', 'helper-no-midx', 'helper-404-midx', 'helper-midx-missing-ok')) {
$dir = Join-Path $run $case
Copy-Item -LiteralPath $seed -Destination $dir -Recurse
$requests = Join-Path $run "$case-requests.txt"
[IO.File]::WriteAllText($requests, '', $utf8)
$job = $null
$listener = $null
try {
$options = @()
if ($case -ne 'plain-midx') {
# Bind to loopback port 0 in this process, then share the listener
# with an in-process PowerShell runspace. No server subprocess exists.
$listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = $listener.LocalEndpoint.Port
$server = {
param($Listener, $Payloads, $Log, $Return404)
$encoding = New-Object Text.UTF8Encoding($false)
while ($true) {
$client = $Listener.AcceptTcpClient()
try {
$client.ReceiveTimeout = 10000
$client.SendTimeout = 10000
$stream = $client.GetStream()
$reader = New-Object IO.StreamReader($stream, [Text.Encoding]::ASCII, $false, 1024, $true)
$request = $reader.ReadLine()
do { $line = $reader.ReadLine() } while ($null -ne $line -and $line -ne '')
$status = 404
$body = [byte[]]@()
if ($request -match '^GET /gvfs/objects/([0-9a-f]{40}) HTTP/') {
$objectId = $Matches[1]
$file = Join-Path $Payloads $objectId
if (-not $Return404 -and (Test-Path -LiteralPath $file)) {
$body = [IO.File]::ReadAllBytes($file)
$status = 200
}
}
[IO.File]::AppendAllText($Log, "$request status=$status bytes=$($body.Length)`r`n", $encoding)
$reason = if ($status -eq 200) { 'OK' } else { 'Not Found' }
$header = [Text.Encoding]::ASCII.GetBytes("HTTP/1.1 $status $reason`r`nContent-Type: application/x-git-loose-object`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n")
$stream.Write($header, 0, $header.Length)
$stream.Write($body, 0, $body.Length)
$stream.Flush()
} finally {
$client.Dispose()
}
}
}
$job = [PowerShell]::Create()
$null = $job.AddScript($server.ToString()).AddArgument($listener).AddArgument((Join-Path $run 'payloads')).AddArgument($requests).AddArgument(($case -eq 'helper-404-midx'))
$async = $job.BeginInvoke()
# remote.*.url is multi-valued: -c would append, not replace,
# the seed's local URL. Fetch still uses the explicit local path.
# GVFS-helper asks for credentials before HTTP. This constant-only
# helper replaces all credential managers with deliberately fake data.
$null = Invoke-Git $dir @('config', 'remote.origin.url', "http://127.0.0.1:$port/")
$options += @('-c', 'core.useGvfsHelper=true', '-c', "gvfs.cache-server=http://127.0.0.1:$port", '-c', 'gvfs.fallback=false',
'-c', 'credential.helper=!f() { echo username=synthetic; echo password=synthetic; }; f')
}
if ($case -eq 'helper-no-midx') { $options += @('-c', 'core.multiPackIndex=false') }
if ($case -eq 'helper-midx-missing-ok') { $options += @('-c', 'core.gvfs=4') }
foreach ($oid in $oids) {
if (Test-Path -LiteralPath (Join-Path $dir ('.git\objects\' + $oid.Substring(0, 2) + '\' + $oid.Substring(2)))) {
throw "Invalid baseline: $oid also exists loose."
}
}
Assert-PackedCommits $dir $options
if ([IO.File]::ReadAllText($requests).Length) {
throw "Invalid baseline in $case`: fresh Git invoked the helper."
}
$snapshot = Get-PackSnapshot $dir
$trace = Join-Path $run "$case-trace.json"
$pull = Invoke-Git -Directory $dir -Arguments ($options + @('pull', '--rebase', '..\remote', 'main')) -Trace $trace -AllowFailure
$head = (Invoke-Git $dir @('rev-parse', 'HEAD')).Out
$packUnchanged = $snapshot -eq (Get-PackSnapshot $dir)
if (-not $packUnchanged) { throw "Pack/MIDX files unexpectedly changed in $case." }
Assert-PackedCommits $dir @()
$null = Invoke-Git $dir @('fsck', '--full')
# Get-Content strings carry provider metadata that Windows PowerShell
# can serialize into JSON, including absolute PSPath values.
$requestLines = @([IO.File]::ReadAllLines($requests))
$events = @(Get-Content -LiteralPath $trace | ForEach-Object { $_ | ConvertFrom-Json })
$parentSid = $events[0].sid
$parentEvents = @($events | Where-Object { $_.sid -eq $parentSid })
$midxLoads = @($parentEvents | Where-Object {
$_.event -eq 'data' -and $_.category -eq 'midx' -and $_.key -eq 'load/num_packs'
}).Count
$gets = @($parentEvents | Where-Object {
$_.event -eq 'region_enter' -and $_.category -eq 'gh-client' -and $_.label -eq 'objects/get'
}).Count
$evidence = @($parentEvents | Where-Object {
$_.event -in @('child_start', 'child_exit', 'error', 'exit') -or
($_.event -eq 'data' -and $_.category -eq 'midx') -or
($_.event -in @('region_enter', 'region_leave') -and $_.category -eq 'gh-client')
})
[IO.File]::WriteAllText((Join-Path $run "$case-parent-evidence.json"),
($evidence | ConvertTo-Json -Depth 8), $utf8)
$downloaded = @($oids | Where-Object {
Test-Path -LiteralPath (Join-Path $dir ('.git\objects\' + $_.Substring(0, 2) + '\' + $_.Substring(2)))
})
$results += [pscustomobject]@{
Case = $case
ExitCode = $pull.ExitCode
HeadIsExpected = ($head -eq $oids[-1])
HeadIsOriginal = ($head -eq $oids[0])
AllCommitsVerifiedBeforeAndAfter = $true
PacksAndMidxUnchanged = $packUnchanged
ParentMidxLoads = $midxLoads
ParentHelperGets = $gets
NewLooseCommits = $downloaded
Requests = $requestLines
Stdout = $pull.Out
Stderr = $pull.Err
}
} finally {
if ($listener) { $listener.Stop() }
if ($job) { $job.Stop(); $job.Dispose() }
}
}
$results | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $run 'results.json') -Encoding UTF8
foreach ($file in Get-ChildItem -LiteralPath $run -File) {
if ($file.Extension -notin @('.txt', '.json')) { continue }
$text = [IO.File]::ReadAllText($file.FullName)
foreach ($privateRoot in @($root, $root.Replace('\', '\\'), $root.Replace('\', '/'))) {
if ($text.IndexOf($privateRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
throw "Unsanitized reproduction path in $($file.Name); do not share this run."
}
}
}
$plain, $bad, $noMidx, $notFound, $missingOk = $results
$allPacked = @($results | Where-Object { -not $_.AllCommitsVerifiedBeforeAndAfter -or -not $_.PacksAndMidxUnchanged }).Count -eq 0
$controlsPass = $plain.ExitCode -eq 0 -and $plain.HeadIsExpected -and
$plain.Requests.Count -eq 0 -and $plain.ParentMidxLoads -eq 2 -and
$noMidx.ExitCode -eq 0 -and $noMidx.HeadIsExpected -and
$noMidx.Requests.Count -eq 0 -and $noMidx.ParentMidxLoads -eq 0 -and
$notFound.ExitCode -eq 0 -and $notFound.HeadIsExpected -and
$notFound.Requests.Count -eq 1 -and $notFound.ParentMidxLoads -eq 2 -and
$notFound.NewLooseCommits.Count -eq 0 -and
$notFound.Requests[0] -match ("/" + $oids[-1] + ' HTTP/1.1 status=404 bytes=0$')
$falseMissing = $bad.ExitCode -eq 128 -and $bad.HeadIsOriginal -and
$bad.Requests.Count -eq 1 -and $bad.ParentHelperGets -eq 1 -and
$bad.ParentMidxLoads -eq 1 -and $bad.NewLooseCommits.Count -eq 1 -and
$bad.Requests[0] -match ("/" + $oids[-1] + ' HTTP/1.1 status=200 bytes=') -and
$bad.Stderr.Contains("Could not read $($oids[-2])") -and
$bad.Stderr.Contains("could not parse commit $($oids[-2])")
$downloadedAll = $missingOk.ExitCode -eq 0 -and $missingOk.HeadIsExpected -and
$missingOk.ParentMidxLoads -eq 1 -and $missingOk.ParentHelperGets -eq 5 -and
$missingOk.Requests.Count -eq 5 -and $missingOk.NewLooseCommits.Count -eq 5
foreach ($oid in $oids[1..5]) {
$downloadedAll = $downloadedAll -and @($missingOk.Requests | Where-Object {
$_ -match ("/" + $oid + ' HTTP/1.1 status=200 bytes=')
}).Count -eq 1
}
$reproduced = $allPacked -and $controlsPass -and $falseMissing -and $downloadedAll
$verdict = if ($reproduced) { 'REPRODUCED: false missing-object failure AND redundant downloads.' } else { 'NOT REPRODUCED / INCONCLUSIVE: one or more strict checks did not match.' }
$summary = @(
$verdict,
"Version: $version",
"PowerShell: $($PSVersionTable.PSVersion)",
"Run directory: $runName",
'Run from this directory:',
' powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\repro.ps1',
'Harness exit: 0 means all bug/control assertions matched; 1 means incomplete or not reproduced.',
'Fixture: six synthetic commits, six separate packs, one MIDX, empty tree.',
"Initial HEAD: $($oids[0])",
"Remote tip: $($oids[-1])",
'Commit-graph disabled to isolate the pack/MIDX cache.',
'Expected Git behavior: every pull succeeds without downloading any already-local object.',
'Core trigger, after fixture creation (full isolated command lines in commands.txt):',
' git -c core.useGvfsHelper=true -c gvfs.cache-server=http://127.0.0.1:<port>',
' -c gvfs.fallback=false -c core.commitGraph=false pull --rebase ..\remote main',
'The script also sets fake-only credentials and a loopback origin URL for the real helper.',
'All commits: cat-file and verify-pack succeeded before and after; fsck --full succeeded.',
'No commits were loose before pull. Pack, index and MIDX SHA256 snapshots stayed identical.',
'Files in the run directory: commands.txt (exact argv, cwd, stdin, exit, stdout, stderr),',
'fixture.json (commit-to-pack mapping), results.json, *-requests.txt,',
'*-trace.json (Trace2 JSON lines), *-parent-evidence.json (parent-only timeline).',
'Logs replace the reproduction root with <REPRO>, remove process ancestry/memory,',
'and zero the machine-hash component of Trace2 session IDs.',
''
)
foreach ($result in $results) {
$summary += "$($result.Case): exit=$($result.ExitCode), expected HEAD=$($result.HeadIsExpected), HTTP requests=$($result.Requests.Count), new loose commits=$($result.NewLooseCommits.Count), parent MIDX loads=$($result.ParentMidxLoads)"
$summary += $result.Requests
if ($result.ExitCode -ne 0) {
$summary += "stderr: $($result.Stderr)"
}
}
$summary += @(
'',
"Assertions: local packed objects=$allPacked; controls=$controlsPass; false-missing=$falseMissing; repeated-downloads=$downloadedAll",
'SIGNATURE (established only when all assertions above are True):',
'Plain Git succeeds and loads MIDX twice in the parent. No plain-Git failure.',
'HTTP200 + helper + MIDX: redundant tip download, then unreadable packed parent, exit 128.',
'MIDX disabled: exit 0, zero helper requests. HTTP404: one request, MIDX reload, exit 0.',
'Adding core.gvfs=4 (GVFS_MISSING_OK): exit 0 but downloads all five already-packed commits.',
'',
'SOURCE-BASED EXPLANATION (not a debugger observation of in-memory fields):',
'pull run_fetch -> start_command -> odb_close -> packfile_store_close drops MIDX but',
'retains initialized=true; prepare skips reloading it, hiding not-yet-loaded packs.',
'odb.c tries GVFS-helper before SECOND_READ. Loose-object HTTP200 avoids reprepare.',
'With core.gvfs=0, commit.c sets SKIP_FETCH_OBJECT; the next missing packed ancestor',
'returns early in odb.c before SECOND_READ, causing the observed parse failure.',
'With core.gvfs=4 that flag is cleared, and each hidden commit is downloaded instead.',
'A 404 creates nothing, allowing SECOND_READ and MIDX reload (checked with Trace2).',
'This supports the recovery mechanism, but does NOT demonstrate recovery by killing a helper.',
'',
'Public source tag: https://github.com/microsoft/git/tree/v2.55.0.vfs.0.8',
'Relevant files/functions: builtin/pull.c run_fetch; run-command.c start_command;',
'packfile.c packfile_store_prepare/reprepare/close, find_pack_entry;',
'odb.c do_oid_object_info_extended; commit.c repo_parse_commit_internal;',
'gvfs-helper-client.c gh_client__objects__receive_response.',
'',
'SELF-CONTAINED / LIMITS:',
'Uses only installed Git and Windows PowerShell/.NET; no installs, Git rebuild, debugger,',
'worktree, corporate data, external service, or existing repository/process inspection.',
'Only the in-process 127.0.0.1 listener is used; proxies, redirects and fallback are disabled.',
'The listener serves original Git-generated zlib loose-object bytes from synthetic payloads.',
'Git configuration, hooks, templates, author/committer, home and scratch are isolated.',
'All synthetic commit messages include the Copilot Co-authored-by trailer.',
'Servers stop in finally blocks. Each invocation creates a new owned run directory.',
'Keep the latest run for evidence, or rerun the script to reconstruct everything.',
'The commits are empty and pull fast-forwards; divergent replay and commit-graph loss',
'are not tested. No pack-store fields were inspected, and no kill recovery was attempted.'
)
[IO.File]::WriteAllLines((Join-Path $root 'repro-results.txt'), $summary, $utf8)
$summary | Write-Output
if (-not $reproduced) { exit 1 }
exit 0
Summary
git pull --rebasespent tens of minutes requesting individual commits throughgit-gvfs-helper, even though sampled requested commits were already in local packs. The helper was active, not stuck on one repeated object.The parent Git process had
packfile_store.midx == NULLwithinitialized == 1, a partial loaded-pack inventory, andcommit_graph == NULLwithcommit_graph_attempted == 1. Matching source suggestsodb_close()before fetch leaves these caches unable to reload normally. Successful helper downloads then bypass theOBJECT_INFO_SECOND_READfallback that would reprepare the local pack store.Killing just the helper reportedly makes the operation recover very quickly. We did not repeat that experiment on the affected repository; a helper response ending without an object can reach the local-refresh fallback, providing a source-supported explanation.
A six-commit, fully local reproduction now demonstrates five redundant commit downloads with
GVFS_MISSING_OK, and a false missing-commit failure without that flag. A helper returning HTTP404 instead of the object allows local MIDX reloading and successful completion. No ADO connection or large repository is needed.Setup
Scalar / shared object cache, GVFS-protocol object helper, Windows x64.
The build-system shell path is omitted. Scalar reports the same Git version; this is not a separate VFSForGit service-version report. Diagnostics used PowerShell. The original launching shell was not established.
Original repository: private; no original repository data, object IDs, URLs, memory dumps, or paths are included here.
v2.55.0.vfs.0.10was the latest published release when checked. Itspackfile_store_close()andclose_commit_graph()source retain the same cleanup/flag behavior. That newer binary has not been tested. The first affected version has not been determined.Trigger / expected versus actual
Expected: ancestry queries read already-present objects from local packs, retain or reload usable graph/index state, and avoid remote hydration for those objects.
Actual: the long-lived parent performed serial helper object requests during
paint_down_to_common(). A fresh process could read the same sampled objects locally. The parent remained running more than 52 minutes after startup when observation stopped.Original-run evidence (sanitized)
midx=NULL,initialized=1commit_graph=NULL,commit_graph_attempted=1,core_commit_graph=1The matching pack/index predated the incident by weeks. This was established using creation/index timestamps, not just pack modification time, which Git can freshen.
The watcher coalesces events within each interval and watches a shared cache. These counts are not a complete per-process HTTP trace and cannot rule out within-interval or unsuccessful duplicate requests. We only proved pack membership for the 20 sampled commits, not every arriving object. Early interpretation that distinct downloads meant useful progress was corrected after the pack comparison.
Captured helper state was HTTP 200, one object/request, attempt 0, transient delay 0. Both main/cache endpoint throttle structures had zero
tstu_limit,tstu_remaining,reset_sec, andretry_after_secvalues. There was no captured active retry/backoff or throttle hint; these snapshots do not prove absence of historical throttling.Typical observed filesystem arrival rates were approximately 7 objects/sec initially and 13/sec later. A ~160-second pause was caused by diagnostic debugger suspension, disclosed and repaired; it is excluded as evidence of a Git/ADO stall. Activity resumed afterward.
Parent stack, symbols only:
Helper stack:
The retry function's presence alone does not indicate a retry.
Fresh-process read-only ancestry comparisons, with lazy fetch disabled and
core.gvfs=0process-locally, took approximately 213-245 ms with the commit graph versus 21,359 ms for a comparable graph-disabled query. Both directions returned exit 1 ("not an ancestor"). These are not timings of successful pull/rebase runs.Suspected mechanism in exact-version source
run_fetch()setscmd.odb_to_close.start_command()closes that ODB before starting fetch.odb_close()closes ODB sources and the commit graph.packfile_store_close()frees the MIDX and sets its pointer to NULL without resettinginitialized.packfile_store_prepare()returns early wheninitializedis true. Initial pack enumeration omits indexes covered by the MIDX, so never-materialized MIDX packs are absent from the retained list after close.close_midx()does clearmulti_pack_indexflags on already-loaded packs. The suspected defect is loss of previously unloaded MIDX-covered packs, not stale flags on loaded packs.close_commit_graph()clears the graph pointer but notcommit_graph_attempted;prepare_commit_graph()then returns NULL instead of reopening it.do_oid_object_info_extended()tries the GVFS helper beforeOBJECT_INFO_SECOND_READ. Successful object creation retries lookup, which finds the new loose object, avoiding the refresh. This repeats for later commits in the inaccessible local packs.packfile_store_read_object_info()then callspackfile_store_reprepare(), clearing initialization and reloading indexes.Step 8 plausibly explains reported recovery when only the helper is killed.
gh_client__objects__receive_response()can exit on EOF without announcing an object. This does not guarantee immediate same-request helper respawn, nor does restarting the helper itself reload the parent's graph. Killing the helper is not offered as a generally safe workaround.Isolated reproduction
The self-contained PowerShell script included at the end of this submission creates six synthetic empty commits in six separate packs, writes a MIDX, removes the loose originals, and puts the local branch at commit 1 and the remote at commit 6. Each case uses an independent copy of the fixture.
It uses installed Git and Windows PowerShell/.NET only. Fetch is from an explicit local path. The real GVFS helper contacts an in-process 127.0.0.1-only HTTP listener serving the synthetic loose-object bytes saved before packing. Configuration, hooks, templates, author/committer identity, home and temporary directories are isolated. Proxy, redirect and server fallback are disabled; credentials are constant fake values.
Save the script as
repro.ps1in a new scratch directory and run:It creates a new owned run directory, retains commands/Trace2/request logs and a summary, and stops its listener before exiting. Harness exit 0 means all bug/control signatures matched; nonzero means failure or an inconclusive/different result.
The relevant command inside the fixture is:
The above is a command synopsis with a placeholder port. Use the script for all required setup and exact arguments. The five-download case additionally passes
-c core.gvfs=4.Two independent final harness runs on
2.55.0.vfs.0.8produced:core.gvfs=0core.gvfs=4All commits were verified in the original packs before and after each pull;
fsck --fullsucceeded, and pack/index/MIDX hash snapshots were unchanged. No commit existed loose before the pull. The five downloaded commit IDs exactly match the already-packed five commits beyond the initial HEAD. Parent-only Trace2 events distinguish parent MIDX reloading from child-process loading.With the helper enabled but
core.gvfs=0, Git unnecessarily downloads the tip, then reportsCould not read <packed-parent>/could not parse commit <packed-parent>and exits 128.repo_parse_commit_internal()normally setsOBJECT_INFO_SKIP_FETCH_OBJECT; the helper branch in ODB returns before SECOND_READ on that path.core.gvfs=4enablesGVFS_MISSING_OKand clears the skip-fetch flag, exposing the repeated-download variant instead.The HTTP404 control demonstrates recovery through MIDX refresh when no object is supplied. It is not a helper-kill experiment. These fixtures deliberately disable commit graphs to isolate MIDX/pack behavior; they fast-forward empty commits, not divergent rebase replay. The original graph-loss diagnosis remains separately supported by live-state/source evidence. No raw pack-store fields were inspected in the synthetic runs.
Suggested regression coverage / fix direction
Preserve the invariant that closing a reusable ODB cannot leave it "already initialized" while the index needed to discover its packs has been freed. Review the equivalent commit-graph attempted/load invariant.
A regression test should initialize a MIDX over several packs, materialize only a subset, close the ODB, then read an object from a previously unloaded pack. Check that it remains local, with no helper hydration. Cover graph reloading, successful helper responses, helper failure,
OBJECT_INFO_SECOND_READ, and commit parsing with and withoutGVFS_MISSING_OK. Refreshing local indexes before remote fetch is another design point to evaluate.This is a suggested direction, not a validated patch.
Related reports reviewed
None was established as the same stale MIDX/initialized state. The private run's raw logs are intentionally not attached.
Self-contained reproduction script
repro.ps1 (Windows PowerShell 5.1 or later)