Skip to content

pull --rebase redundantly hydrates packed commits after odb_close loses MIDX state #994

Description

  • I searched open and closed issues in microsoft/git and git-for-windows/git and did not find a confirmed match for this cache-state failure. Related reports are listed below.

Note: AI generated bug.
This happens quite frequently on offices' main repo (using scalar partial enlistments) I'm happy to capture dumps. Just reach out over teams.
Not versed enough with this repo to determine the quality of this bug filing.

Summary

git pull --rebase spent tens of minutes requesting individual commits through git-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 == NULL with initialized == 1, a partial loaded-pack inventory, and commit_graph == NULL with commit_graph_attempted == 1. Matching source suggests odb_close() before fetch leaves these caches unable to reload normally. Successful helper downloads then bypass the OBJECT_INFO_SECOND_READ fallback 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.

git version 2.55.0.vfs.0.8
cpu: x86_64
built from commit: de48cba8ff530db25f8130308e8292798a7b019a
sizeof-long: 4
sizeof-size_t: 8
rust: disabled
feature: fsmonitor--daemon
gettext: enabled
libcurl: 8.21.0
OpenSSL: OpenSSL 3.5.7 9 Jun 2026
zlib: 1.3.2
SHA-1: SHA1_DC
SHA-256: SHA256_BLK
default-ref-format: files
default-hash: sha1

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.10 was the latest published release when checked. Its packfile_store_close() and close_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

git pull --rebase

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)

Observation Result
Approximately 38-minute watcher window 17,789 distinct finalized loose-object IDs
Object types 17,550 commits, 214 trees, 25 blobs
Downloaded objects also named as parents of downloaded commits 17,208
Repeated finalized paths from earlier sample intervals 0
Recently downloaded commits checked against old local pack indexes 20/20 already present
Actual bodies read by fresh Git with pack-access tracing 3/3 read from the pre-existing pack
Parent shared-cache pack store midx=NULL, initialized=1
Parent loaded packs versus indexes on disk 15 versus 36
Pack containing all 20 sampled commits Absent from parent's loaded list; index contains 2,690,785 objects
Parent graph state commit_graph=NULL, commit_graph_attempted=1, core_commit_graph=1

The 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, and retry_after_sec values. 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:

cmd_pull
  repo_is_descendant_of
    repo_in_merge_bases_many
      paint_down_to_common
        repo_parse_commit_internal
          odb_read_object_info_extended
            gh_client__get_immediate
              gh_client__objects__receive_response
                packet_read_line_gently
                  ReadFile

Helper stack:

do_server_subprocess__objects
  do__http_get__fetch_oidset
    do_req__with_fallback
      do_req__with_robust_retry
        run_active_slot
          WS2_32!select
            mswsock!WSPSelect
              NtWaitForSingleObject

The retry function's presence alone does not indicate a retry.

Fresh-process read-only ancestry comparisons, with lazy fetch disabled and core.gvfs=0 process-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

  1. run_fetch() sets cmd.odb_to_close. start_command() closes that ODB before starting fetch.
  2. odb_close() closes ODB sources and the commit graph.
  3. packfile_store_close() frees the MIDX and sets its pointer to NULL without resetting initialized.
  4. packfile_store_prepare() returns early when initialized is true. Initial pack enumeration omits indexes covered by the MIDX, so never-materialized MIDX packs are absent from the retained list after close.
  5. Important nuance: close_midx() does clear multi_pack_index flags on already-loaded packs. The suspected defect is loss of previously unloaded MIDX-covered packs, not stale flags on loaded packs.
  6. close_commit_graph() clears the graph pointer but not commit_graph_attempted; prepare_commit_graph() then returns NULL instead of reopening it.
  7. do_oid_object_info_extended() tries the GVFS helper before OBJECT_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.
  8. If a helper response yields no object, the second-read fallback can run. packfile_store_read_object_info() then calls packfile_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.ps1 in a new scratch directory and run:

powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\repro.ps1

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:

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 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.8 produced:

Case Pull exit Helper HTTP requests New loose commits Parent MIDX loads
Plain Git, MIDX on 0 0 0 2
Helper HTTP200, MIDX on, core.gvfs=0 128 1 1 1
Helper enabled, MIDX off 0 0 0 0
Helper HTTP404, MIDX on 0 1 0 2
Helper HTTP200, MIDX on, core.gvfs=4 0 5 5 1

All commits were verified in the original packs before and after each pull; fsck --full succeeded, 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 reports Could not read <packed-parent> / could not parse commit <packed-parent> and exits 128. repo_parse_commit_internal() normally sets OBJECT_INFO_SKIP_FETCH_OBJECT; the helper branch in ODB returns before SECOND_READ on that path. core.gvfs=4 enables GVFS_MISSING_OK and 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 without GVFS_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

  • microsoft/git#547: older repeated per-object rebase downloads; maintainer attributed those to gaps after cache replacement, rather than objects demonstrably present in local packs.
  • microsoft/git#552: reset re-fetching after rebuilding a cache; maintainer explained expected blob downloads because prefetch restored commits/trees, not blobs.
  • microsoft/git#837: corrupt/empty cache files causing retry loops; unlike the HTTP200/attempt0 snapshots here.
  • microsoft/git#482: ADO prefetch rate limits.
  • git-for-windows/git#6210: fetch auto-maintenance index unlink problem.
  • git-for-windows/git#5956: maintenance commit-graph writer stall.

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)
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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions