Keep shared-worktree fetches proportional to ref changes (#3078)

* fix(server): bound shared-worktree fetch refreshes

* fix(server): preserve buffered git metadata events

* fix(server): cover remote ref selection changes

* fix(server): preserve narrow fetch echo routing

* fix(server): preserve ref updates during refresh

Keep normal refresh requeue semantics for watcher events that arrive while a ref-derived calculation is active, so a second same-ref movement cannot be mistaken for the original fetch echo.

* fix(server): narrow external fetch ref updates

Use authoritative fetch boundaries to separate watcher echoes from outside events, and retain remote ref names so packed-to-loose materialization from other Git clients stays narrow without weakening add/delete fallbacks.
This commit is contained in:
Mohamed Boudra
2026-08-09 12:51:00 +02:00
committed by GitHub
parent c0daf8e066
commit 05dc2022c8
9 changed files with 1947 additions and 52 deletions
@@ -0,0 +1,185 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { type CheckoutSnapshotFacts, getCheckoutRefDerivedState } from "./checkout-git.js";
import {
startGitCommandMetrics,
stopGitCommandMetrics,
waitForGitCommandMetricsIdle,
} from "./run-git-command.js";
const cleanupPaths: string[] = [];
afterEach(() => {
for (const path of cleanupPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
}).trim();
}
function seedFeatureCheckout(): {
cwd: string;
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>;
} {
const cwd = mkdtempSync(join(tmpdir(), "paseo-ref-derived-"));
cleanupPaths.push(cwd);
git(cwd, ["init", "-b", "main"]);
git(cwd, ["config", "user.email", "repro@example.com"]);
git(cwd, ["config", "user.name", "Repro"]);
writeFileSync(join(cwd, "base.txt"), "base\n");
git(cwd, ["add", "base.txt"]);
git(cwd, ["commit", "-m", "base"]);
const base = git(cwd, ["rev-parse", "HEAD"]);
git(cwd, ["update-ref", "refs/remotes/origin/main", base]);
git(cwd, ["switch", "--quiet", "-c", "feature"]);
writeFileSync(join(cwd, "feature.txt"), "feature\n");
git(cwd, ["add", "feature.txt"]);
git(cwd, ["commit", "-m", "feature"]);
return {
cwd,
facts: {
isGit: true,
worktreeRoot: cwd,
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
absoluteGitDir: join(cwd, ".git"),
gitCommonDir: join(cwd, ".git"),
paseoWorktree: { isPaseoOwnedWorktree: false },
storedBaseRef: null,
resolvedBaseRef: "main",
mainRepoRoot: null,
comparisonBaseRef: "origin/main",
branchRemoteName: "origin",
branchMergeRef: "refs/heads/main",
upstreamStatus: {
ref: "refs/remotes/origin/main",
aheadBehind: { ahead: 0, behind: 0 },
},
pullRequestLookupTarget: { headRef: "feature" },
},
};
}
function seedMainCheckout(): {
cwd: string;
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>;
} {
const cwd = mkdtempSync(join(tmpdir(), "paseo-ref-derived-main-"));
cleanupPaths.push(cwd);
git(cwd, ["init", "-b", "main"]);
git(cwd, ["config", "user.email", "repro@example.com"]);
git(cwd, ["config", "user.name", "Repro"]);
writeFileSync(join(cwd, "base.txt"), "base\n");
git(cwd, ["add", "base.txt"]);
git(cwd, ["commit", "-m", "base"]);
git(cwd, ["update-ref", "refs/remotes/origin/main", git(cwd, ["rev-parse", "HEAD"])]);
writeFileSync(join(cwd, "local.txt"), "local\n");
git(cwd, ["add", "local.txt"]);
git(cwd, ["commit", "-m", "local"]);
return {
cwd,
facts: {
isGit: true,
worktreeRoot: cwd,
currentBranch: "main",
remoteUrl: "https://example.com/repo.git",
absoluteGitDir: join(cwd, ".git"),
gitCommonDir: join(cwd, ".git"),
paseoWorktree: { isPaseoOwnedWorktree: false },
storedBaseRef: null,
resolvedBaseRef: "main",
mainRepoRoot: null,
comparisonBaseRef: null,
branchRemoteName: "origin",
branchMergeRef: "refs/heads/main",
upstreamStatus: {
ref: "refs/remotes/origin/main",
aheadBehind: { ahead: 1, behind: 0 },
},
pullRequestLookupTarget: { headRef: "main" },
},
};
}
test("one comparison refresh updates a shared base and upstream ref", async () => {
const { cwd, facts } = seedFeatureCheckout();
startGitCommandMetrics();
const result = await getCheckoutRefDerivedState(
cwd,
facts,
{ aheadBehind: { ahead: 0, behind: 0 }, diffStat: null },
new Set(["origin/main"]),
);
await waitForGitCommandMetricsIdle({ quietMs: 50, timeoutMs: 5_000 });
const metrics = stopGitCommandMetrics();
expect(result.aheadBehind).toEqual({ ahead: 1, behind: 0 });
expect(result.upstreamStatus).toEqual({
ref: "refs/remotes/origin/main",
aheadBehind: { ahead: 1, behind: 0 },
});
expect(result.diffStat).toEqual({ additions: 1, deletions: 0 });
expect(metrics.submissions.map((command) => command.args[0]).sort()).toEqual([
"diff",
"ls-files",
"merge-base",
"rev-list",
]);
});
test("an upstream move refreshes the main checkout shortstat", async () => {
const { cwd, facts } = seedMainCheckout();
git(cwd, ["update-ref", "refs/remotes/origin/main", git(cwd, ["rev-parse", "HEAD"])]);
const result = await getCheckoutRefDerivedState(
cwd,
facts,
{ aheadBehind: null, diffStat: { additions: 1, deletions: 0 } },
new Set(["origin/main"]),
);
expect(result).toEqual({
aheadBehind: null,
diffStat: null,
upstreamStatus: {
ref: "refs/remotes/origin/main",
aheadBehind: { ahead: 0, behind: 0 },
},
});
});
test("an origin move refreshes an untracked main checkout shortstat", async () => {
const seeded = seedMainCheckout();
const facts = {
...seeded.facts,
branchRemoteName: null,
branchMergeRef: null,
upstreamStatus: null,
};
git(seeded.cwd, [
"update-ref",
"refs/remotes/origin/main",
git(seeded.cwd, ["rev-parse", "HEAD"]),
]);
expect(
await getCheckoutRefDerivedState(
seeded.cwd,
facts,
{ aheadBehind: null, diffStat: { additions: 1, deletions: 0 } },
new Set(["origin/main"]),
),
).toEqual({ aheadBehind: null, diffStat: null, upstreamStatus: null });
});
+142 -11
View File
@@ -1615,8 +1615,22 @@ async function getAheadBehind(
if (!comparisonBaseRef) {
return null;
}
return getAheadBehindForComparisonRef(cwd, comparisonBaseRef, currentBranch, context);
}
export interface UpstreamStatus {
ref: string;
aheadBehind: AheadBehind;
}
async function getAheadBehindForComparisonRef(
cwd: string,
comparisonRef: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<AheadBehind | null> {
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
["rev-list", "--left-right", "--count", `${comparisonRef}...${currentBranch}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
);
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
@@ -1628,11 +1642,6 @@ async function getAheadBehind(
return { ahead, behind };
}
interface UpstreamStatus {
ref: string;
aheadBehind: AheadBehind;
}
async function getUpstreamStatus(
cwd: string,
currentBranch: string,
@@ -2554,7 +2563,7 @@ function parseCheckoutShortstat(text: string): CheckoutShortstat | null {
const UNTRACKED_SHORTSTAT_MAX_FILES = 500;
async function countUntrackedAdditions(cwd: string): Promise<number> {
async function countUntrackedAdditions(cwd: string, throwOnGitError = false): Promise<number> {
try {
const { stdout } = await runGitCommand(["ls-files", "--others", "--exclude-standard"], {
cwd,
@@ -2582,14 +2591,25 @@ async function countUntrackedAdditions(cwd: string): Promise<number> {
}
}
return additions;
} catch {
} catch (error) {
if (throwOnGitError) {
throw error;
}
return 0;
}
}
function handleShortstatGitError(error: unknown, throwOnGitError = false): null {
if (throwOnGitError) {
throw error;
}
return null;
}
async function getCheckoutShortstatUncached(
cwd: string,
context?: CheckoutContext,
options?: { throwOnGitError?: boolean },
): Promise<CheckoutShortstat | null> {
if (context?.facts?.isGit === false) {
return null;
@@ -2632,7 +2652,7 @@ async function getCheckoutShortstatUncached(
cwd,
envOverlay: READ_ONLY_GIT_ENV,
}),
countUntrackedAdditions(cwd),
countUntrackedAdditions(cwd, options?.throwOnGitError),
]);
const tracked = parseCheckoutShortstat(stdout);
@@ -2644,8 +2664,8 @@ async function getCheckoutShortstatUncached(
return { additions: untrackedAdditions, deletions: 0 };
}
return null;
} catch {
return null;
} catch (error) {
return handleShortstatGitError(error, options?.throwOnGitError);
}
}
@@ -2713,6 +2733,117 @@ export async function getCheckoutShortstat(
return getOrLoadCheckoutShortstat(cwd, context, options);
}
export interface CheckoutRefDerivedState {
aheadBehind: AheadBehind | null;
diffStat: CheckoutShortstat | null;
upstreamStatus: UpstreamStatus | null;
}
function normalizeRemoteTrackingRef(ref: string): string {
return ref.startsWith("refs/remotes/") ? ref.slice("refs/remotes/".length) : ref;
}
function checkoutFactsConfiguredRemoteRef(
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
): string | null {
const trackedBranch = facts.branchMergeRef?.startsWith("refs/heads/")
? facts.branchMergeRef.slice("refs/heads/".length)
: null;
return facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch
? `${facts.branchRemoteName}/${trackedBranch}`
: null;
}
function getCheckoutRefMovement(
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
movedRemoteRefs: ReadonlySet<string>,
): {
baseMoved: boolean;
comparisonRef: string | null;
currentBranch: string | null;
normalizedResolvedBase: string | null;
upstreamMoved: boolean;
upstreamRef: string | null;
} {
const currentBranch = facts.currentBranch;
const comparisonRef = facts.comparisonBaseRef;
const normalizedMoves = new Set([...movedRemoteRefs].map(normalizeRemoteTrackingRef));
const normalizedResolvedBase = facts.resolvedBaseRef
? branchNameFromRef(facts.resolvedBaseRef)
: null;
const shortstatRemoteRef =
currentBranch && (!normalizedResolvedBase || normalizedResolvedBase === currentBranch)
? `origin/${currentBranch}`
: null;
const baseMoved = [facts.storedBaseRef, facts.resolvedBaseRef, comparisonRef, shortstatRemoteRef]
.filter((ref): ref is string => Boolean(ref))
.map(normalizeRemoteTrackingRef)
.some((ref) => normalizedMoves.has(ref));
const upstreamRef = facts.upstreamStatus?.ref ?? checkoutFactsConfiguredRemoteRef(facts);
const upstreamMoved = upstreamRef
? normalizedMoves.has(normalizeRemoteTrackingRef(upstreamRef))
: false;
return {
baseMoved,
comparisonRef,
currentBranch,
normalizedResolvedBase,
upstreamMoved,
upstreamRef,
};
}
export async function getCheckoutRefDerivedState(
cwd: string,
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
current: Pick<CheckoutRefDerivedState, "aheadBehind" | "diffStat">,
movedRemoteRefs: ReadonlySet<string>,
context?: CheckoutContext,
): Promise<CheckoutRefDerivedState> {
const {
baseMoved,
comparisonRef,
currentBranch,
normalizedResolvedBase,
upstreamMoved,
upstreamRef,
} = getCheckoutRefMovement(facts, movedRemoteRefs);
let aheadBehind = current.aheadBehind;
let diffStat = current.diffStat;
if (baseMoved && currentBranch && facts.resolvedBaseRef) {
aheadBehind = await getAheadBehind(cwd, facts.resolvedBaseRef, currentBranch, {
...context,
facts,
});
}
if (baseMoved || (upstreamMoved && currentBranch === normalizedResolvedBase)) {
diffStat = await getCheckoutShortstatUncached(
cwd,
{ ...context, facts },
{ throwOnGitError: true },
);
}
let upstreamStatus = facts.upstreamStatus;
if (upstreamMoved && currentBranch && upstreamRef) {
const normalizedUpstream = normalizeRemoteTrackingRef(upstreamRef);
const normalizedComparison = comparisonRef ? normalizeRemoteTrackingRef(comparisonRef) : null;
const upstreamAheadBehind =
baseMoved && normalizedComparison === normalizedUpstream
? aheadBehind
: await getAheadBehindForComparisonRef(cwd, upstreamRef, currentBranch, context);
upstreamStatus = upstreamAheadBehind
? {
ref: facts.upstreamStatus?.ref ?? `refs/remotes/${normalizedUpstream}`,
aheadBehind: upstreamAheadBehind,
}
: null;
}
return { aheadBehind, diffStat, upstreamStatus };
}
export interface CheckoutWorktreeState {
isDirty: boolean;
diffStat: CheckoutShortstat | null;