Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/browser/stores/GitStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
274 changes: 274 additions & 0 deletions src/common/utils/git/gitStatus.fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,278 @@ 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);

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

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");
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);
});
Loading
Loading