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:
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user