From 6210c403b32147c4d5301cef4c3594a28160aee3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:45:37 +0200 Subject: [PATCH 1/5] fix: stop background status fetch from converting repos into partial clones GIT_FETCH_SCRIPT fetched with --filter=blob:none, which registers remote.origin.promisor + partialclonefilter on first use and leaves every subsequently fetched commit without blobs. git worktree add (workspace creation) then lazy-fetches blobs mid-checkout and any transient network drop aborts with 'could not fetch from promisor remote'. - GIT_FETCH_SCRIPT: --filter=blob:none -> --no-filter (also overrides the persisted filter in already-converted repos so they heal going forward) - SSH warm path: strip promisor keys in the fused preamble (parity with ensureBaseRepo hygiene) - classify promisor lazy-fetch failures as repairable missing-object checkout failures (warm-path case + isMissingObjectCheckoutFailure) - extend warm-heal integration test to assert promisor keys are stripped --- src/common/utils/git/gitStatus.ts | 13 ++++++++++++- src/node/runtime/SSHRuntime.ts | 20 ++++++++++++++++++-- tests/runtime/runtime.test.ts | 20 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index 0d00b18d18d..48e54d6c56e 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -222,6 +222,17 @@ if [ "$LOCAL_SHA" = "$REMOTE_SHA" ]; then fi # Remote has new commits or ref moved - fetch updates +# +# --no-filter (NOT --filter=blob:none): a filtered fetch permanently converts +# the repo into a promisor/partial clone (git writes remote.origin.promisor + +# remote.origin.partialclonefilter on the first filtered fetch, and the +# configured filter then applies to every subsequent plain fetch). That leaves +# every commit fetched by this background loop without its blobs, so a later +# "git worktree add" (workspace creation) must lazy-fetch blobs from the +# remote mid-checkout and any transient network failure aborts it with +# "fatal: could not fetch from promisor remote". --no-filter both avoids +# poisoning healthy repos and overrides the persisted filter config in repos +# that were already converted, so they heal going forward. git -c protocol.version=2 \\ -c fetch.negotiationAlgorithm=skipping \\ fetch origin \\ @@ -229,6 +240,6 @@ git -c protocol.version=2 \\ --no-tags \\ --no-recurse-submodules \\ --no-write-fetch-head \\ - --filter=blob:none \\ + --no-filter \\ 2>&1 `; diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index cd966b32fde..63bf1fd371e 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -152,7 +152,11 @@ function isUnresolvedDeltaPushFailure(errorMsg: string): boolean { } function isMissingObjectCheckoutFailure(message: string): boolean { - return /unable to read sha1 file|Could not reset index file|missing (blob|tree|commit)|bad object|unable to read tree|object file .* is empty|loose object .* is corrupt/i.test( + // "could not fetch ... from promisor remote": the checkout needed objects the + // repo does not have and a lazy fetch from upstream failed (e.g. transient + // network drop). The objects are still missing locally, so the same + // repair-from-local path applies. + return /unable to read sha1 file|Could not reset index file|missing (blob|tree|commit)|bad object|unable to read tree|object file .* is empty|loose object .* is corrupt|could not fetch .* from promisor remote/i.test( message ); } @@ -2657,6 +2661,18 @@ export class SSHRuntime extends RemoteRuntime { // path where ensureBaseRepo() has retry/error handling instead of risking // materializing a worktree from still-poisoned shared config. `git --git-dir=${baseRepoPathArg} symbolic-ref HEAD ${baseRepoUnbornHeadArg} 2>/dev/null || { echo WARM_MISS:base-head-normalization-failed; exit 0; }`, + // Best-effort promisor strip, mirroring ensureBaseRepo()'s epilogue. The + // warm path skips ensureBaseRepo(), and background status fetches that + // ran `git fetch --filter=blob:none` inside sibling worktrees register + // the shared base repo as a promisor remote (remote.origin.promisor + + // partialclonefilter). Left in place, `git worktree add` below would + // lazy-fetch missing blobs from upstream mid-checkout, so a transient + // network drop aborts workspace creation with "could not fetch + // from promisor remote" instead of the repairable missing-objects path. + ...BASE_REPO_PROMISOR_CONFIG_KEYS.map( + (key) => + `git --git-dir=${baseRepoPathArg} config --local --unset-all ${shescape.quote(key)} 2>/dev/null || true` + ), ]; const originPreamble = originUrlArg @@ -2706,7 +2722,7 @@ export class SSHRuntime extends RemoteRuntime { "wt_status=$?", 'if [ "$wt_status" -ne 0 ]; then', ' case "$wt_output" in', - ' *"unable to read sha1 file"*|*"Could not reset index file"*|*"missing blob"*|*"missing tree"*|*"missing commit"*|*"bad object"*|*"unable to read tree"*) wt_reason=missing-objects ;;', + ' *"unable to read sha1 file"*|*"Could not reset index file"*|*"missing blob"*|*"missing tree"*|*"missing commit"*|*"bad object"*|*"unable to read tree"*|*"from promisor remote"*) wt_reason=missing-objects ;;', " *) wt_reason=worktree-add-failed ;;", " esac", ` git -C ${baseRepoPathArg} worktree remove --force ${workspacePathArg} >/dev/null 2>&1 || rm -rf ${workspacePathArg}`, diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 4463676865f..4be06680274 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -2206,6 +2206,12 @@ describeIntegration("Runtime integration tests", () => { `git --git-dir="${baseRepoPath}" config --local core.bare true`, `git --git-dir="${baseRepoPath}" config --local core.worktree "${bogusWorktreePath}"`, `git --git-dir="${baseRepoPath}" symbolic-ref HEAD refs/heads/main`, + // Simulate what a background `git fetch --filter=blob:none` from a + // sibling worktree registers in the shared gitdir. A promisor base + // repo lazy-fetches missing blobs over the network mid-checkout, so + // the warm path must strip these before `git worktree add`. + `git --git-dir="${baseRepoPath}" config --local remote.origin.promisor true`, + `git --git-dir="${baseRepoPath}" config --local remote.origin.partialclonefilter blob:none`, ].join(" && ") ); expect(poisonResult.exitCode).toBe(0); @@ -2243,6 +2249,20 @@ describeIntegration("Runtime integration tests", () => { ); expect(baseRepoCoreWorktreeCheck.exitCode).toBe(1); + // Promisor/partial-clone registration must be stripped so worktree + // materialization never lazy-fetches blobs over the network. + const baseRepoPromisorCheck = await execSSH( + runtime, + `git --git-dir="${baseRepoPath}" config --get remote.origin.promisor` + ); + expect(baseRepoPromisorCheck.exitCode).toBe(1); + + const baseRepoFilterCheck = await execSSH( + runtime, + `git --git-dir="${baseRepoPath}" config --get remote.origin.partialclonefilter` + ); + expect(baseRepoFilterCheck.exitCode).toBe(1); + const baseHeadSymbolicCheck = await execSSH( runtime, `git --git-dir="${baseRepoPath}" symbolic-ref -q HEAD` From 71eeb3929325ff83456f3ffbd2e76c30b1e884b7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:35:21 +0200 Subject: [PATCH 2/5] review: add explicit one-time promisor heal + raise background fetch timeout Codex P1: --no-filter alone neither removes persisted promisor config nor backfills blobs omitted by earlier filtered fetches, and up-to-date repos early-exit before fetching. GIT_FETCH_SCRIPT now heals poisoned repos (partialclonefilter=blob:none) before the early exit: git fetch --refetch backfills, then promisor keys are unset only after success. mkdir lock with 1h staleness prevents concurrent sibling refetches and throttles retries. Codex P2: raise GitStatusStore background fetch timeout 30s -> 300s for the larger unfiltered transfers and the one-time refetch. Share PROMISOR_CONFIG_KEYS between the script and SSHRuntime; add behavioral heal test covering the early-exit path. --- src/browser/stores/GitStatusStore.ts | 9 ++- src/common/utils/git/gitStatus.fetch.test.ts | 81 ++++++++++++++++++++ src/common/utils/git/gitStatus.ts | 60 ++++++++++++++- src/node/runtime/SSHRuntime.ts | 7 +- 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/browser/stores/GitStatusStore.ts b/src/browser/stores/GitStatusStore.ts index 8dda5530bbd..0b0edf8e01e 100644 --- a/src/browser/stores/GitStatusStore.ts +++ b/src/browser/stores/GitStatusStore.ts @@ -48,6 +48,13 @@ const MAX_CONCURRENT_GIT_OPS = 5; // Fetch configuration - aggressive intervals for fresh data const FETCH_BASE_INTERVAL_MS = 3 * 1000; // 3 seconds const FETCH_MAX_INTERVAL_MS = 60 * 1000; // 60 seconds +// Background fetches are unfiltered (see GIT_FETCH_SCRIPT) and may run a +// one-time full --refetch to heal repos poisoned into promisor/partial +// clones, so transfers can be much larger than the old blob-filtered ones. +// Killing a slow-but-progressing fetch wastes the entire transfer and leaves +// ahead/behind state permanently stale behind retry backoff, so budget for a +// full-object transfer instead. +const FETCH_TIMEOUT_SECS = 300; // 5 minutes interface FetchState { lastFetch: number; @@ -925,7 +932,7 @@ export class GitStatusStore { // Passive fetches use the runtime path because git fetch / git ls-remote // may need remote credentials that only exist inside the runtime. These // background fetches are only scheduled when that runtime is already running. - options: repoRootBashOptions(30, repoRootProjectPath), + options: repoRootBashOptions(FETCH_TIMEOUT_SECS, repoRootProjectPath), }); if (!result.success) { diff --git a/src/common/utils/git/gitStatus.fetch.test.ts b/src/common/utils/git/gitStatus.fetch.test.ts index 3f53c1d4b44..7e5114511da 100644 --- a/src/common/utils/git/gitStatus.fetch.test.ts +++ b/src/common/utils/git/gitStatus.fetch.test.ts @@ -59,4 +59,85 @@ describe("GIT_FETCH_SCRIPT", () => { await rm(tempDir, { recursive: true, force: true }); } }, 20000); + + test("heals a repo poisoned into a promisor partial clone even when up to date", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "mux-git-heal-")); + const originDir = path.join(tempDir, "origin.git"); + const seedDir = path.join(tempDir, "seed"); + const workspaceDir = path.join(tempDir, "workspace"); + + const run = (cmd: string, cwd?: string) => + execSync(cmd, { cwd, stdio: "pipe" }).toString().trim(); + const configureIdentity = (cwd: string) => { + run('git config user.email "test@example.com"', cwd); + run('git config user.name "Test User"', cwd); + run("git config commit.gpgsign false", cwd); + }; + + try { + run(`git init --bare ${originDir}`); + // Local-path remotes reject --filter unless the server side opts in. + run(`git -C ${originDir} config uploadpack.allowFilter true`); + + // Seed main via a separate clone so the workspace clone below stays + // unaware of later blobs. + run(`git clone ${originDir} ${seedDir}`); + configureIdentity(seedDir); + await writeFile(path.join(seedDir, "README.md"), "init\n"); + run("git add README.md", seedDir); + run('git commit -m "init"', seedDir); + run("git branch -M main", seedDir); + run("git push -u origin main", seedDir); + run("git symbolic-ref HEAD refs/heads/main", originDir); + + // Full (healthy) clone of the workspace. + run(`git clone ${originDir} ${workspaceDir}`); + configureIdentity(workspaceDir); + + // Advance origin/main with a commit whose blob the workspace lacks. + await writeFile(path.join(seedDir, "data.txt"), "poisoned blob content\n"); + run("git add data.txt", seedDir); + run('git commit -m "add data"', seedDir); + run("git push origin main", seedDir); + + // Reproduce the poisoning done by previous versions of the script: a + // single filtered fetch persists promisor config and skips the new blob. + run("git fetch origin --filter=blob:none", workspaceDir); + expect(run("git config --local --get remote.origin.partialclonefilter", workspaceDir)).toBe( + "blob:none" + ); + // rev-list reports missing objects without lazy-fetching them. + const missingBefore = run( + "git rev-list --objects --missing=print origin/main | grep -c '^?' || true", + workspaceDir + ); + expect(Number(missingBefore)).toBeGreaterThan(0); + + // The filtered fetch already updated the tracking ref, so the script's + // LOCAL_SHA/REMOTE_SHA early-exit is hit: the heal must run before it. + const script = GIT_FETCH_SCRIPT; + const output = run(script, workspaceDir); + expect(output).toContain("HEAL: backfilling promisor partial clone"); + + // Promisor config removed and previously missing blobs backfilled. + expect( + run("git config --local --get remote.origin.partialclonefilter || echo GONE", workspaceDir) + ).toBe("GONE"); + expect( + run("git config --local --get remote.origin.promisor || echo GONE", workspaceDir) + ).toBe("GONE"); + const missingAfter = run( + "git rev-list --objects --missing=print origin/main | grep -c '^?' || true", + workspaceDir + ); + expect(Number(missingAfter)).toBe(0); + + // Heal is one-shot: a second run must skip without re-fetching. + const secondOutput = run(script, workspaceDir); + expect(secondOutput).not.toContain("HEAL:"); + expect(secondOutput).toContain("SKIP: Remote SHA already fetched"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, 20000); }); diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index 48e54d6c56e..c9e36f33414 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -176,6 +176,19 @@ export function parseGitStatusScriptOutput(output: string): ParsedGitStatusOutpu }; } +/** + * Git config keys that mark a repo as a promisor/partial clone. Previous + * versions of GIT_FETCH_SCRIPT fetched with --filter=blob:none, which made + * git persist this state (poisoning the repo: every later fetch stayed + * filtered and checkouts lazy-fetched blobs from the network). The fetch + * script's heal block and SSHRuntime's base-repo hygiene both unset these. + */ +export const PROMISOR_CONFIG_KEYS = [ + "remote.origin.promisor", + "remote.origin.partialclonefilter", + "extensions.partialclone", +] as const; + /** * Smart git fetch script that minimizes lock contention. * @@ -212,6 +225,46 @@ if [ -z "$REMOTE_SHA" ]; then exit 0 fi +# One-time heal for repos that previous versions of this script converted +# into promisor/partial clones. --no-filter (used below) stops the damage but +# does not remove the persisted promisor config, nor backfill the blobs that +# earlier filtered fetches omitted, and up-to-date repos would skip the fetch +# entirely via the early-exit below. Left unhealed, "git worktree add" +# (workspace creation) lazy-fetches those old blobs mid-checkout and fails on +# transient network errors. Only repos whose filter is exactly the +# "blob:none" this script used to write are healed. +if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" = "blob:none" ]; then + # Lock in the shared git dir: sibling worktrees share repo config, so this + # keeps them from starting concurrent full refetches. A stale lock (killed + # heal) expires after an hour, which also rate-limits retries when the + # refetch keeps failing (e.g. huge repo on a slow link). + COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) + HEAL_LOCK="$COMMON_DIR/xum-promisor-heal.lock" + if [ -n "$COMMON_DIR" ] && [ -n "$(find "$HEAL_LOCK" -maxdepth 0 -type d -mmin +60 2>/dev/null)" ]; then + rmdir "$HEAL_LOCK" 2>/dev/null + fi + if [ -n "$COMMON_DIR" ] && mkdir "$HEAL_LOCK" 2>/dev/null; then + echo "HEAL: backfilling promisor partial clone" + # --refetch (git >= 2.36) negotiates as if the repo had nothing, so the + # server re-sends every object including previously filtered-out blobs. + # Unset the promisor config only after a successful refetch: stripping + # first would leave missing blobs with no lazy-fetch fallback, breaking + # checkouts outright instead of healing them. + if git -c protocol.version=2 \\ + fetch origin \\ + --refetch \\ + --no-filter \\ + --prune \\ + --no-tags \\ + --no-recurse-submodules \\ + --no-write-fetch-head \\ + 2>&1; then +${PROMISOR_CONFIG_KEYS.map((key) => ` git config --local --unset-all ${key} 2>/dev/null`).join("\n")} + fi + rmdir "$HEAL_LOCK" 2>/dev/null + fi +fi + # Check current local remote-tracking ref (no lock) LOCAL_SHA=$(git rev-parse --verify "refs/remotes/origin/$PRIMARY_BRANCH" 2>/dev/null || echo "") @@ -230,9 +283,10 @@ fi # every commit fetched by this background loop without its blobs, so a later # "git worktree add" (workspace creation) must lazy-fetch blobs from the # remote mid-checkout and any transient network failure aborts it with -# "fatal: could not fetch from promisor remote". --no-filter both avoids -# poisoning healthy repos and overrides the persisted filter config in repos -# that were already converted, so they heal going forward. +# "fatal: could not fetch from promisor remote". --no-filter avoids +# poisoning healthy repos and keeps this fetch unfiltered even in a repo that +# is still poisoned (already-converted repos are backfilled and cleaned up by +# the one-time heal block above). git -c protocol.version=2 \\ -c fetch.negotiationAlgorithm=skipping \\ fetch origin \\ diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 63bf1fd371e..a4b7fed9533 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -39,6 +39,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; import { sleepWithAbort } from "@/node/utils/abort"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; +import { PROMISOR_CONFIG_KEYS } from "@/common/utils/git/gitStatus"; import { type SSHRuntimeConfig, getControlPath, @@ -106,11 +107,7 @@ const BASE_REPO_MAINTENANCE_WAIT_TIMEOUT_SECONDS = 30 * 60; * `repo_has_promisor_remote()`. Unsetting all three is what makes * receive-pack's `check_connected()` skip the buggy partial-clone fast * path on subsequent pushes (see `stripBaseRepoPromisorConfig`). */ -const BASE_REPO_PROMISOR_CONFIG_KEYS = [ - "remote.origin.promisor", - "remote.origin.partialclonefilter", - "extensions.partialclone", -] as const; +const BASE_REPO_PROMISOR_CONFIG_KEYS = PROMISOR_CONFIG_KEYS; const BASE_REPO_FRAGMENTED_PACK_THRESHOLD = 25; const PROJECT_SYNC_MAX_ATTEMPTS = 3; const PROJECT_SYNC_RETRYABLE_ERRORS = [ From d452138228eb1d1fe96e07b4894da80f5d28230d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:57:32 +0200 Subject: [PATCH 3/5] review round 2: heal placement, completeness check, portable lock staleness Codex P1: --refetch only re-sends objects reachable from the remote's current refs; blobs referenced solely by local refs into upstream-deleted branches can remain missing. Verify completeness via rev-list --missing=print --all before unsetting promisor config; when incomplete, keep the config (lazy-fetch fallback preserved) and throttle refetch retries to daily via xum.promisorHealIncompleteAt. Codex P2: run the heal before ls-remote/primary-branch gating so a stale origin/HEAD (renamed/deleted upstream default branch) cannot skip it forever. Codex P3: replace find -maxdepth/-mmin staleness probing with a timestamp file inside the mkdir lock; failed refetches keep the lock so retries wait out the 1h window. Add behavioral test for the incomplete-heal path. --- src/common/utils/git/gitStatus.fetch.test.ts | 67 ++++++++++++ src/common/utils/git/gitStatus.ts | 109 ++++++++++++------- 2 files changed, 136 insertions(+), 40 deletions(-) diff --git a/src/common/utils/git/gitStatus.fetch.test.ts b/src/common/utils/git/gitStatus.fetch.test.ts index 7e5114511da..236730212d1 100644 --- a/src/common/utils/git/gitStatus.fetch.test.ts +++ b/src/common/utils/git/gitStatus.fetch.test.ts @@ -140,4 +140,71 @@ describe("GIT_FETCH_SCRIPT", () => { await rm(tempDir, { recursive: true, force: true }); } }, 20000); + + test("keeps promisor config when refetch cannot restore locally referenced blobs", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "mux-git-heal-incomplete-")); + const originDir = path.join(tempDir, "origin.git"); + const seedDir = path.join(tempDir, "seed"); + const workspaceDir = path.join(tempDir, "workspace"); + + const run = (cmd: string, cwd?: string) => + execSync(cmd, { cwd, stdio: "pipe" }).toString().trim(); + const configureIdentity = (cwd: string) => { + run('git config user.email "test@example.com"', cwd); + run('git config user.name "Test User"', cwd); + run("git config commit.gpgsign false", cwd); + }; + + try { + run(`git init --bare ${originDir}`); + run(`git -C ${originDir} config uploadpack.allowFilter true`); + + run(`git clone ${originDir} ${seedDir}`); + configureIdentity(seedDir); + await writeFile(path.join(seedDir, "README.md"), "init\n"); + run("git add README.md", seedDir); + run('git commit -m "init"', seedDir); + run("git branch -M main", seedDir); + run("git push -u origin main", seedDir); + run("git symbolic-ref HEAD refs/heads/main", originDir); + + run(`git clone ${originDir} ${workspaceDir}`); + configureIdentity(workspaceDir); + + // Push a feature branch whose blob the workspace will only ever see + // through a filtered fetch. + run("git checkout -b feature", seedDir); + await writeFile(path.join(seedDir, "orphan.txt"), "blob that will be orphaned upstream\n"); + run("git add orphan.txt", seedDir); + run('git commit -m "orphan"', seedDir); + run("git push origin feature", seedDir); + + // Poison the workspace and pin the blobless commit with a local branch. + run("git fetch origin --filter=blob:none", workspaceDir); + run("git branch keep origin/feature", workspaceDir); + + // Delete the branch upstream: --refetch can no longer re-send its blob. + run("git push origin :feature", seedDir); + + const output = run(GIT_FETCH_SCRIPT, workspaceDir); + expect(output).toContain( + "HEAL: objects still missing after refetch; keeping promisor config" + ); + + // Promisor config retained so the lazy-fetch fallback keeps working. + expect(run("git config --local --get remote.origin.partialclonefilter", workspaceDir)).toBe( + "blob:none" + ); + // Incomplete-heal marker set: retries are throttled to daily. + expect( + Number(run("git config --local --get xum.promisorHealIncompleteAt", workspaceDir)) + ).toBeGreaterThan(0); + + // Within the daily window a second run must not attempt another refetch. + const secondOutput = run(GIT_FETCH_SCRIPT, workspaceDir); + expect(secondOutput).not.toContain("HEAL:"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, 20000); }); diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index c9e36f33414..18c8c7766f2 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -209,6 +209,75 @@ export GIT_ASKPASS=echo export SSH_ASKPASS=echo export GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes -o StrictHostKeyChecking=accept-new" +# One-time heal for repos that previous versions of this script converted +# into promisor/partial clones. --no-filter (used below) stops the damage but +# does not remove the persisted promisor config, nor backfill the blobs that +# earlier filtered fetches omitted. Left unhealed, "git worktree add" +# (workspace creation) lazy-fetches those old blobs mid-checkout and fails on +# transient network errors. Only repos whose filter is exactly the +# "blob:none" this script used to write are healed. This block runs before +# any ls-remote/primary-branch gating on purpose: a stale origin/HEAD (e.g. +# default branch renamed upstream) makes the checks below exit early, which +# must not leave the repo poisoned forever. +if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" = "blob:none" ]; then + COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) + NOW=$(date +%s) + # A refetch can succeed while objects stay missing (see the completeness + # check below); that outcome may never improve, so retry it at most daily + # instead of hammering the network with a full refetch every poll. + LAST_INCOMPLETE=$(git config --local --get xum.promisorHealIncompleteAt 2>/dev/null || echo 0) + [ -n "$LAST_INCOMPLETE" ] || LAST_INCOMPLETE=0 + if [ -n "$COMMON_DIR" ] && [ $((NOW - LAST_INCOMPLETE)) -ge 86400 ]; then + # mkdir is the atomic claim: sibling worktrees share repo config, so this + # keeps them from starting concurrent full refetches. Staleness comes + # from a timestamp file inside the lock (portable, unlike find/stat + # mtime probing): a lock left behind by a killed heal expires after an + # hour, which also rate-limits retries after a failed refetch (the + # failure path below keeps the lock in place for that reason). + HEAL_LOCK="$COMMON_DIR/xum-promisor-heal.lock" + LOCK_TS=$(cat "$HEAL_LOCK/started" 2>/dev/null || echo 0) + [ -n "$LOCK_TS" ] || LOCK_TS=0 + if [ -d "$HEAL_LOCK" ] && [ $((NOW - LOCK_TS)) -gt 3600 ]; then + rm -rf "$HEAL_LOCK" + fi + if mkdir "$HEAL_LOCK" 2>/dev/null; then + echo "$NOW" > "$HEAL_LOCK/started" + echo "HEAL: backfilling promisor partial clone" + # --refetch (git >= 2.36) negotiates as if the repo had nothing, so the + # server re-sends every object reachable from the fetch refspec, + # including previously filtered-out blobs. + if git -c protocol.version=2 \\ + fetch origin \\ + --refetch \\ + --no-filter \\ + --prune \\ + --no-tags \\ + --no-recurse-submodules \\ + --no-write-fetch-head \\ + 2>&1; then + # A successful refetch is not proof of completeness: it only re-sends + # objects reachable from the *remote's current refs*. Blobs referenced + # only by local refs into upstream-deleted branches can remain + # missing, and unsetting the promisor config then would turn a + # recoverable partial clone into a repo whose checkouts hard-fail + # ("unable to read sha1 file") with no lazy-fetch fallback. Only + # unset once every locally reachable object is actually present. + if git rev-list --objects --missing=print --all 2>/dev/null | grep -q '^?'; then + echo "HEAL: objects still missing after refetch; keeping promisor config" + git config --local xum.promisorHealIncompleteAt "$NOW" 2>/dev/null + else +${PROMISOR_CONFIG_KEYS.map((key) => ` git config --local --unset-all ${key} 2>/dev/null`).join("\n")} + git config --local --unset-all xum.promisorHealIncompleteAt 2>/dev/null + fi + rm -rf "$HEAL_LOCK" + fi + # On refetch failure the lock (with its timestamp) stays in place so the + # next attempt waits out the 1h staleness window instead of re-running a + # full refetch on every status poll. + fi + fi +fi + # Get primary branch name PRIMARY_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') if [ -z "$PRIMARY_BRANCH" ]; then @@ -225,46 +294,6 @@ if [ -z "$REMOTE_SHA" ]; then exit 0 fi -# One-time heal for repos that previous versions of this script converted -# into promisor/partial clones. --no-filter (used below) stops the damage but -# does not remove the persisted promisor config, nor backfill the blobs that -# earlier filtered fetches omitted, and up-to-date repos would skip the fetch -# entirely via the early-exit below. Left unhealed, "git worktree add" -# (workspace creation) lazy-fetches those old blobs mid-checkout and fails on -# transient network errors. Only repos whose filter is exactly the -# "blob:none" this script used to write are healed. -if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" = "blob:none" ]; then - # Lock in the shared git dir: sibling worktrees share repo config, so this - # keeps them from starting concurrent full refetches. A stale lock (killed - # heal) expires after an hour, which also rate-limits retries when the - # refetch keeps failing (e.g. huge repo on a slow link). - COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) - HEAL_LOCK="$COMMON_DIR/xum-promisor-heal.lock" - if [ -n "$COMMON_DIR" ] && [ -n "$(find "$HEAL_LOCK" -maxdepth 0 -type d -mmin +60 2>/dev/null)" ]; then - rmdir "$HEAL_LOCK" 2>/dev/null - fi - if [ -n "$COMMON_DIR" ] && mkdir "$HEAL_LOCK" 2>/dev/null; then - echo "HEAL: backfilling promisor partial clone" - # --refetch (git >= 2.36) negotiates as if the repo had nothing, so the - # server re-sends every object including previously filtered-out blobs. - # Unset the promisor config only after a successful refetch: stripping - # first would leave missing blobs with no lazy-fetch fallback, breaking - # checkouts outright instead of healing them. - if git -c protocol.version=2 \\ - fetch origin \\ - --refetch \\ - --no-filter \\ - --prune \\ - --no-tags \\ - --no-recurse-submodules \\ - --no-write-fetch-head \\ - 2>&1; then -${PROMISOR_CONFIG_KEYS.map((key) => ` git config --local --unset-all ${key} 2>/dev/null`).join("\n")} - fi - rmdir "$HEAL_LOCK" 2>/dev/null - fi -fi - # Check current local remote-tracking ref (no lock) LOCAL_SHA=$(git rev-parse --verify "refs/remotes/origin/$PRIMARY_BRANCH" 2>/dev/null || echo "") From 6216de39de7016c37984f25237fb24de800e1c77 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:13:05 +0200 Subject: [PATCH 4/5] review round 3: staged backfill for old git + reflog-aware completeness Codex P2a: git 2.31-2.35 (e.g. Ubuntu 22.04) lacks --refetch, so healing would fail forever there. The heal now backfills in two stages: (1) batch fetch of exactly the missing OIDs via git fetch --stdin (git >= 2.30, only downloads the gaps, same protocol-v2 OID-want capability lazy fetch uses), then (2) feature-detected --refetch fallback for servers that refuse OID wants. Codex P2b: completeness enumeration now includes --reflog so commits displaced by upstream force-pushes (reachable only via the remote-tracking reflog) keep the lazy-fetch fallback instead of being stranded by an unsafe config unset. Retry semantics: incomplete-but-reachable -> daily marker; all fetches failed (offline/auth) -> lock kept, 1h staleness window paces retries. Tests: server-side gc added to the incomplete case (so stage 1 cannot resurrect orphaned blobs); new force-push/reflog behavioral test. --- src/common/utils/git/gitStatus.fetch.test.ts | 75 +++++++++++++++- src/common/utils/git/gitStatus.ts | 94 ++++++++++++++------ 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/src/common/utils/git/gitStatus.fetch.test.ts b/src/common/utils/git/gitStatus.fetch.test.ts index 236730212d1..4e6dd43410c 100644 --- a/src/common/utils/git/gitStatus.fetch.test.ts +++ b/src/common/utils/git/gitStatus.fetch.test.ts @@ -183,12 +183,14 @@ describe("GIT_FETCH_SCRIPT", () => { run("git fetch origin --filter=blob:none", workspaceDir); run("git branch keep origin/feature", workspaceDir); - // Delete the branch upstream: --refetch can no longer re-send its blob. + // Delete the branch upstream and GC so neither the OID backfill nor a + // --refetch can re-send its blob: the server no longer has it at all. run("git push origin :feature", seedDir); + run(`git -C ${originDir} gc --prune=now`); const output = run(GIT_FETCH_SCRIPT, workspaceDir); expect(output).toContain( - "HEAL: objects still missing after refetch; keeping promisor config" + "HEAL: objects still missing after backfill; keeping promisor config" ); // Promisor config retained so the lazy-fetch fallback keeps working. @@ -207,4 +209,73 @@ describe("GIT_FETCH_SCRIPT", () => { await rm(tempDir, { recursive: true, force: true }); } }, 20000); + + test("keeps promisor config when a force-push strands blobless commits in the reflog", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "mux-git-heal-reflog-")); + const originDir = path.join(tempDir, "origin.git"); + const seedDir = path.join(tempDir, "seed"); + const workspaceDir = path.join(tempDir, "workspace"); + + const run = (cmd: string, cwd?: string) => + execSync(cmd, { cwd, stdio: "pipe" }).toString().trim(); + const configureIdentity = (cwd: string) => { + run('git config user.email "test@example.com"', cwd); + run('git config user.name "Test User"', cwd); + run("git config commit.gpgsign false", cwd); + }; + + try { + run(`git init --bare ${originDir}`); + run(`git -C ${originDir} config uploadpack.allowFilter true`); + + run(`git clone ${originDir} ${seedDir}`); + configureIdentity(seedDir); + await writeFile(path.join(seedDir, "README.md"), "init\n"); + run("git add README.md", seedDir); + run('git commit -m "init"', seedDir); + run("git branch -M main", seedDir); + run("git push -u origin main", seedDir); + run("git symbolic-ref HEAD refs/heads/main", originDir); + + run(`git clone ${originDir} ${workspaceDir}`); + configureIdentity(workspaceDir); + + // Advance main with a commit whose blob the workspace only ever sees + // through a filtered fetch, then poison the workspace. + await writeFile(path.join(seedDir, "displaced.txt"), "blob displaced by force-push\n"); + run("git add displaced.txt", seedDir); + run('git commit -m "displaced"', seedDir); + run("git push origin main", seedDir); + run("git fetch origin --filter=blob:none", workspaceDir); + + // Force-push main back and forward so the blobless commit survives only + // in the workspace's remote-tracking reflog, then GC it away upstream. + run("git reset --hard HEAD~1", seedDir); + await writeFile(path.join(seedDir, "replacement.txt"), "replacement history\n"); + run("git add replacement.txt", seedDir); + run('git commit -m "replacement"', seedDir); + run("git push --force origin main", seedDir); + run(`git -C ${originDir} gc --prune=now`); + const replacementSha = run("git rev-parse main", seedDir); + + const output = run(GIT_FETCH_SCRIPT, workspaceDir); + expect(output).toContain( + "HEAL: objects still missing after backfill; keeping promisor config" + ); + + // The heal itself moved origin/main to the replacement history, which is + // exactly what strands the displaced commit in the reflog: without + // --reflog in the completeness check the config would now be unset and + // "git reset --hard origin/main@{1}" could never lazy-fetch its blobs. + expect(run("git rev-parse origin/main", workspaceDir)).toBe(replacementSha); + expect(run("git config --local --get remote.origin.partialclonefilter", workspaceDir)).toBe( + "blob:none" + ); + expect( + Number(run("git config --local --get xum.promisorHealIncompleteAt", workspaceDir)) + ).toBeGreaterThan(0); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, 20000); }); diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index 18c8c7766f2..a66d07e9fba 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -222,7 +222,7 @@ export GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes -o StrictHostK if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" = "blob:none" ]; then COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) NOW=$(date +%s) - # A refetch can succeed while objects stay missing (see the completeness + # A backfill can succeed while objects stay missing (see the completeness # check below); that outcome may never improve, so retry it at most daily # instead of hammering the network with a full refetch every poll. LAST_INCOMPLETE=$(git config --local --get xum.promisorHealIncompleteAt 2>/dev/null || echo 0) @@ -243,37 +243,73 @@ if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" if mkdir "$HEAL_LOCK" 2>/dev/null; then echo "$NOW" > "$HEAL_LOCK/started" echo "HEAL: backfilling promisor partial clone" - # --refetch (git >= 2.36) negotiates as if the repo had nothing, so the - # server re-sends every object reachable from the fetch refspec, - # including previously filtered-out blobs. - if git -c protocol.version=2 \\ - fetch origin \\ - --refetch \\ - --no-filter \\ - --prune \\ - --no-tags \\ - --no-recurse-submodules \\ - --no-write-fetch-head \\ - 2>&1; then - # A successful refetch is not proof of completeness: it only re-sends - # objects reachable from the *remote's current refs*. Blobs referenced - # only by local refs into upstream-deleted branches can remain - # missing, and unsetting the promisor config then would turn a - # recoverable partial clone into a repo whose checkouts hard-fail - # ("unable to read sha1 file") with no lazy-fetch fallback. Only - # unset once every locally reachable object is actually present. - if git rev-list --objects --missing=print --all 2>/dev/null | grep -q '^?'; then - echo "HEAL: objects still missing after refetch; keeping promisor config" - git config --local xum.promisorHealIncompleteAt "$NOW" 2>/dev/null - else -${PROMISOR_CONFIG_KEYS.map((key) => ` git config --local --unset-all ${key} 2>/dev/null`).join("\n")} - git config --local --unset-all xum.promisorHealIncompleteAt 2>/dev/null + # Enumerate locally reachable objects the repo does not have. --reflog + # matters: an upstream force-push strands the displaced commit in the + # remote-tracking reflog, and recovery (e.g. git reset --hard + # origin/main@{1}) must keep lazy-fetching after an unsafe unset would + # have broken it, so reflog-only gaps count as missing too. + xum_missing_objects() { + git rev-list --objects --missing=print --all --reflog 2>/dev/null | sed -n 's/^?//p' + } + HEAL_FETCHED="" + MISSING=$(xum_missing_objects) + # Stage 1: batch-fetch exactly the missing objects by OID. Downloads + # only the gaps (a --refetch re-sends the whole repo) and works on + # hosts whose git predates --refetch (2.36), e.g. Ubuntu 22.04's 2.34. + # OID wants ride the same protocol-v2 server capability the repo's + # lazy fetch already depends on (this is a batched lazy fetch), and + # explicit wants bypass the persisted partial-clone filter. + if [ -n "$MISSING" ]; then + if printf '%s\\n' "$MISSING" | git -c protocol.version=2 \\ + fetch origin \\ + --stdin \\ + --no-tags \\ + --no-recurse-submodules \\ + --no-write-fetch-head \\ + 2>&1; then + HEAL_FETCHED=1 + fi + MISSING=$(xum_missing_objects) + fi + # Stage 2: full --refetch (git >= 2.36, hence feature-detected) for + # servers that refuse OID wants: it negotiates as if the repo had + # nothing, so the server re-sends every object reachable from its + # current refs, including previously filtered-out blobs. + if [ -n "$MISSING" ] && git fetch -h 2>&1 | grep -q refetch; then + if git -c protocol.version=2 \\ + fetch origin \\ + --refetch \\ + --no-filter \\ + --prune \\ + --no-tags \\ + --no-recurse-submodules \\ + --no-write-fetch-head \\ + 2>&1; then + HEAL_FETCHED=1 fi + MISSING=$(xum_missing_objects) + fi + if [ -z "$MISSING" ]; then + # Every locally reachable object is present, so dropping the promisor + # config is safe. Doing it with objects still missing would turn a + # recoverable partial clone into a repo whose checkouts hard-fail + # ("unable to read sha1 file") with no lazy-fetch fallback. +${PROMISOR_CONFIG_KEYS.map((key) => ` git config --local --unset-all ${key} 2>/dev/null`).join("\n")} + git config --local --unset-all xum.promisorHealIncompleteAt 2>/dev/null + echo "HEAL: promisor config removed" + rm -rf "$HEAL_LOCK" + elif [ -n "$HEAL_FETCHED" ]; then + # The server was reachable yet objects are still missing (e.g. blobs + # only ever fetched bloblessly whose refs were deleted and GC'd + # upstream). That may never improve, so keep the lazy-fetch fallback + # and record the attempt; the marker throttles retries to daily. + echo "HEAL: objects still missing after backfill; keeping promisor config" + git config --local xum.promisorHealIncompleteAt "$NOW" 2>/dev/null rm -rf "$HEAL_LOCK" fi - # On refetch failure the lock (with its timestamp) stays in place so the - # next attempt waits out the 1h staleness window instead of re-running a - # full refetch on every status poll. + # When every fetch attempt failed (offline, auth): transient, so the + # lock (with its timestamp) stays in place and the 1h staleness window + # paces the retries. fi fi fi From 79b6abd364a14e4d47eb7514a608f2c622162c7b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:32:26 +0200 Subject: [PATCH 5/5] review round 4: enumeration-failure sentinel + warm-path backfill before strip Codex P2: rev-list can exit 128 without output when a local ref names a missing commit; that must read as 'not proven complete', never 'nothing missing'. xum_missing_objects now captures rev-list status and prints an enumeration-failed sentinel; stage 1 skips it, stage 2 still runs (a refetch can restore a missing commit), and the unset never fires on it. Codex P2: the SSH warm path stripped promisor keys unconditionally, which could strand a poisoned base repo without its lazy-fetch fallback and let worktree add silently fall back to the stale bundle ref. It now mirrors the fetch script's heal: gated on partialclonefilter=blob:none, batch-fetches exactly the missing OIDs (after originPreamble sets the URL), and strips only when enumeration proves the object store complete. Add enumeration-failure behavioral test. --- src/common/utils/git/gitStatus.fetch.test.ts | 55 ++++++++++++++++++++ src/common/utils/git/gitStatus.ts | 28 +++++++--- src/node/runtime/SSHRuntime.ts | 46 ++++++++++++---- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/common/utils/git/gitStatus.fetch.test.ts b/src/common/utils/git/gitStatus.fetch.test.ts index 4e6dd43410c..53452f584c5 100644 --- a/src/common/utils/git/gitStatus.fetch.test.ts +++ b/src/common/utils/git/gitStatus.fetch.test.ts @@ -210,6 +210,61 @@ describe("GIT_FETCH_SCRIPT", () => { } }, 20000); + test("keeps promisor config when object enumeration fails", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "mux-git-heal-enum-")); + const originDir = path.join(tempDir, "origin.git"); + const seedDir = path.join(tempDir, "seed"); + const workspaceDir = path.join(tempDir, "workspace"); + + const run = (cmd: string, cwd?: string) => + execSync(cmd, { cwd, stdio: "pipe" }).toString().trim(); + const configureIdentity = (cwd: string) => { + run('git config user.email "test@example.com"', cwd); + run('git config user.name "Test User"', cwd); + run("git config commit.gpgsign false", cwd); + }; + + try { + run(`git init --bare ${originDir}`); + run(`git -C ${originDir} config uploadpack.allowFilter true`); + + run(`git clone ${originDir} ${seedDir}`); + configureIdentity(seedDir); + await writeFile(path.join(seedDir, "README.md"), "init\n"); + run("git add README.md", seedDir); + run('git commit -m "init"', seedDir); + run("git branch -M main", seedDir); + run("git push -u origin main", seedDir); + run("git symbolic-ref HEAD refs/heads/main", originDir); + + run(`git clone ${originDir} ${workspaceDir}`); + configureIdentity(workspaceDir); + + await writeFile(path.join(seedDir, "data.txt"), "poisoned blob content\n"); + run("git add data.txt", seedDir); + run('git commit -m "add data"', seedDir); + run("git push origin main", seedDir); + run("git fetch origin --filter=blob:none", workspaceDir); + + // Point a local ref at an object the repo does not have at all: + // rev-list then exits 128 without reporting anything, which must read + // as "not proven complete" — never as "nothing missing". + await writeFile( + path.join(workspaceDir, ".git", "refs", "heads", "broken"), + "0123456789abcdef0123456789abcdef01234567\n" + ); + + const output = run(GIT_FETCH_SCRIPT, workspaceDir); + expect(output).toContain("HEAL: backfilling promisor partial clone"); + expect(output).not.toContain("HEAL: promisor config removed"); + expect(run("git config --local --get remote.origin.partialclonefilter", workspaceDir)).toBe( + "blob:none" + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, 20000); + test("keeps promisor config when a force-push strands blobless commits in the reflog", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "mux-git-heal-reflog-")); const originDir = path.join(tempDir, "origin.git"); diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index a66d07e9fba..e1b46488d75 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -243,13 +243,23 @@ if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" if mkdir "$HEAL_LOCK" 2>/dev/null; then echo "$NOW" > "$HEAL_LOCK/started" echo "HEAL: backfilling promisor partial clone" - # Enumerate locally reachable objects the repo does not have. --reflog - # matters: an upstream force-push strands the displaced commit in the - # remote-tracking reflog, and recovery (e.g. git reset --hard - # origin/main@{1}) must keep lazy-fetching after an unsafe unset would - # have broken it, so reflog-only gaps count as missing too. + # Enumerate locally reachable objects the repo does not have (one OID + # per line). --reflog matters: an upstream force-push strands the + # displaced commit in the remote-tracking reflog, and recovery (e.g. + # git reset --hard origin/main@{1}) must keep lazy-fetching after an + # unsafe unset would have broken it, so reflog-only gaps count too. + # When rev-list itself fails (e.g. a local ref names a commit object + # the repo does not have at all, which exits 128 before reporting + # anything) a sentinel is printed instead: enumeration failure must + # read as "not proven complete", never as "nothing missing", or the + # unset below would strip the lazy-fetch fallback from a repo that + # still needs it. xum_missing_objects() { - git rev-list --objects --missing=print --all --reflog 2>/dev/null | sed -n 's/^?//p' + if xum_rl_out=$(git rev-list --objects --missing=print --all --reflog 2>/dev/null); then + printf '%s\\n' "$xum_rl_out" | sed -n 's/^?//p' + else + echo "enumeration-failed" + fi } HEAL_FETCHED="" MISSING=$(xum_missing_objects) @@ -258,8 +268,10 @@ if [ "$(git config --local --get remote.origin.partialclonefilter 2>/dev/null)" # hosts whose git predates --refetch (2.36), e.g. Ubuntu 22.04's 2.34. # OID wants ride the same protocol-v2 server capability the repo's # lazy fetch already depends on (this is a batched lazy fetch), and - # explicit wants bypass the persisted partial-clone filter. - if [ -n "$MISSING" ]; then + # explicit wants bypass the persisted partial-clone filter. Skipped on + # enumeration failure (no OID list to fetch); stage 2 still runs then, + # since a full refetch can restore a missing commit object itself. + if [ -n "$MISSING" ] && [ "$MISSING" != "enumeration-failed" ]; then if printf '%s\\n' "$MISSING" | git -c protocol.version=2 \\ fetch origin \\ --stdin \\ diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index a4b7fed9533..0a3b0ba1e25 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -2658,19 +2658,43 @@ export class SSHRuntime extends RemoteRuntime { // path where ensureBaseRepo() has retry/error handling instead of risking // materializing a worktree from still-poisoned shared config. `git --git-dir=${baseRepoPathArg} symbolic-ref HEAD ${baseRepoUnbornHeadArg} 2>/dev/null || { echo WARM_MISS:base-head-normalization-failed; exit 0; }`, - // Best-effort promisor strip, mirroring ensureBaseRepo()'s epilogue. The - // warm path skips ensureBaseRepo(), and background status fetches that - // ran `git fetch --filter=blob:none` inside sibling worktrees register - // the shared base repo as a promisor remote (remote.origin.promisor + - // partialclonefilter). Left in place, `git worktree add` below would - // lazy-fetch missing blobs from upstream mid-checkout, so a transient - // network drop aborts workspace creation with "could not fetch - // from promisor remote" instead of the repairable missing-objects path. + ]; + + // Best-effort promisor heal, mirroring GIT_FETCH_SCRIPT's heal block. The + // warm path skips ensureBaseRepo(), and background status fetches that + // ran `git fetch --filter=blob:none` inside sibling worktrees register + // the shared base repo as a promisor remote (remote.origin.promisor + + // partialclonefilter). Left in place, `git worktree add` below would + // lazy-fetch missing blobs from upstream mid-checkout, so a transient + // network drop aborts workspace creation with "could not fetch + // from promisor remote" instead of the repairable missing-objects path. + // But stripping the keys while objects are still missing is worse: a + // plain fetch never resends blobs of commits the client already has, so + // the repo would lose its lazy-fetch fallback and worktree add would + // silently fall back to the stale bundle ref despite origin being + // reachable. So: batch-fetch exactly the missing OIDs first (an eager + // lazy fetch, done while the promisor config still allows it), then strip + // the keys only when enumeration proves the object store complete. + // Enumeration failure counts as "not proven complete". Everything is + // best-effort; when the keys stay, lazy fetch plus the promisor-remote + // failure classification below still cover worktree materialization. + // This block runs after originPreamble so the backfill sees the freshly + // configured origin URL. + const warmBaseRepoPromisorHealPreamble = [ + `if [ "$(git --git-dir=${baseRepoPathArg} config --local --get remote.origin.partialclonefilter 2>/dev/null)" = "blob:none" ]; then`, + ` xum_base_missing=$(git --git-dir=${baseRepoPathArg} rev-list --objects --missing=print --all --reflog 2>/dev/null) && xum_base_missing=$(printf '%s\\n' "$xum_base_missing" | sed -n 's/^?//p') || xum_base_missing=enumeration-failed`, + ` if [ -n "$xum_base_missing" ] && [ "$xum_base_missing" != "enumeration-failed" ]; then`, + ` printf '%s\\n' "$xum_base_missing" | git --git-dir=${baseRepoPathArg} -c protocol.version=2 fetch origin --stdin --no-tags --no-recurse-submodules --no-write-fetch-head >/dev/null 2>&1 || true`, + ` xum_base_missing=$(git --git-dir=${baseRepoPathArg} rev-list --objects --missing=print --all --reflog 2>/dev/null) && xum_base_missing=$(printf '%s\\n' "$xum_base_missing" | sed -n 's/^?//p') || xum_base_missing=enumeration-failed`, + ` fi`, + ` if [ -z "$xum_base_missing" ]; then`, ...BASE_REPO_PROMISOR_CONFIG_KEYS.map( (key) => - `git --git-dir=${baseRepoPathArg} config --local --unset-all ${shescape.quote(key)} 2>/dev/null || true` + ` git --git-dir=${baseRepoPathArg} config --local --unset-all ${shescape.quote(key)} 2>/dev/null || true` ), - ]; + ` fi`, + `fi`, + ].join("\n"); const originPreamble = originUrlArg ? [ @@ -2695,6 +2719,8 @@ export class SSHRuntime extends RemoteRuntime { ...warmBaseRepoNormalizationPreamble, // Optional origin fetch (preserves slow-path origin-freshness). ...originPreamble, + // Promisor heal after origin setup so its backfill can reach upstream. + warmBaseRepoPromisorHealPreamble, // Choose the worktree base ref. Prefer freshly-fetched // `refs/remotes/origin/` whenever the fetch succeeded; otherwise // fall back to the local-snapshot bundle ref, matching