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,78 @@
import { describe, expect, test } from "vitest";
import {
diffWorkspaceGitRefs,
diffWorkspaceGitRemoteRefs,
parseWorkspaceGitRefs,
} from "./workspace-git-fetch.js";
describe("workspace Git remote refs", () => {
test("parses refs for semantic before/after comparison", () => {
expect(
parseWorkspaceGitRefs(
[
"refs/remotes/origin/main\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"refs/remotes/upstream/topic\0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"refs/heads/main\0cccccccccccccccccccccccccccccccccccccccc",
].join("\n"),
),
).toEqual(
new Map([
["refs/remotes/origin/main", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
["refs/remotes/upstream/topic", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
["refs/heads/main", "cccccccccccccccccccccccccccccccccccccccc"],
]),
);
});
test("distinguishes remote changes from concurrent local ref changes", () => {
expect(
diffWorkspaceGitRefs(
new Map([
["refs/remotes/origin/main", "aaaaaaaa"],
["refs/heads/topic", "bbbbbbbb"],
]),
new Map([
["refs/remotes/origin/main", "cccccccc"],
["refs/heads/topic", "dddddddd"],
]),
),
).toEqual({
remoteChanges: [
{
kind: "moved",
ref: "origin/main",
beforeOid: "aaaaaaaa",
afterOid: "cccccccc",
},
],
nonRemoteRefsChanged: true,
});
});
test("reports added, changed, and deleted remote refs", () => {
expect(
diffWorkspaceGitRemoteRefs(
new Map([
["origin/main", "aaaaaaaa"],
["origin/deleted", "bbbbbbbb"],
["origin/unchanged", "cccccccc"],
]),
new Map([
["origin/main", "dddddddd"],
["origin/added", "eeeeeeee"],
["origin/unchanged", "cccccccc"],
]),
),
).toEqual([
{
kind: "moved",
ref: "origin/main",
beforeOid: "aaaaaaaa",
afterOid: "dddddddd",
},
{ kind: "added", ref: "origin/added", afterOid: "eeeeeeee" },
{ kind: "deleted", ref: "origin/deleted", beforeOid: "bbbbbbbb" },
]);
});
});
@@ -0,0 +1,139 @@
import { runGitCommand } from "../utils/run-git-command.js";
export interface WorkspaceGitFetchResult {
changes: WorkspaceGitRemoteRefChange[] | null;
nonRemoteRefsChanged?: boolean;
remoteRefs?: ReadonlySet<string>;
error: unknown | null;
}
export interface WorkspaceGitFetchObserver {
onRefSnapshot(phase: "before" | "after"): void;
}
export type WorkspaceGitRemoteRefChange =
| { kind: "moved"; ref: string; beforeOid: string; afterOid: string }
| { kind: "added"; ref: string; afterOid: string }
| { kind: "deleted"; ref: string; beforeOid: string };
export async function fetchWorkspaceGitRemote(
cwd: string,
observer: WorkspaceGitFetchObserver,
): Promise<WorkspaceGitFetchResult> {
let before: Map<string, string> | null = null;
let after: Map<string, string> | null = null;
let error: unknown | null = null;
try {
before = await readWorkspaceGitRefs(cwd);
observer.onRefSnapshot("before");
} catch (caught) {
error = caught;
}
try {
await runGitCommand(["fetch", "origin", "--prune"], {
cwd,
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
timeout: 120_000,
});
} catch (caught) {
error ??= caught;
}
try {
after = await readWorkspaceGitRefs(cwd);
} catch (caught) {
error ??= caught;
}
if (!before || !after) {
return { changes: null, error };
}
const diff = diffWorkspaceGitRefs(before, after);
const result = {
changes: diff.remoteChanges,
nonRemoteRefsChanged: diff.nonRemoteRefsChanged,
remoteRefs: collectWorkspaceGitRemoteRefs(after),
error,
} satisfies WorkspaceGitFetchResult;
observer.onRefSnapshot("after");
return result;
}
function collectWorkspaceGitRemoteRefs(refs: ReadonlyMap<string, string>): Set<string> {
const remotePrefix = "refs/remotes/";
return new Set(
[...refs.keys()]
.filter((ref) => ref.startsWith(remotePrefix))
.map((ref) => ref.slice(remotePrefix.length)),
);
}
async function readWorkspaceGitRefs(cwd: string): Promise<Map<string, string>> {
const { stdout } = await runGitCommand(["for-each-ref", "--format=%(refname)%00%(objectname)"], {
cwd,
});
return parseWorkspaceGitRefs(stdout);
}
export function parseWorkspaceGitRefs(stdout: string): Map<string, string> {
const refs = new Map<string, string>();
for (const line of stdout.split("\n")) {
const [ref, objectId] = line.split("\0");
if (ref?.startsWith("refs/") && objectId) {
refs.set(ref, objectId);
}
}
return refs;
}
export interface WorkspaceGitRefDiff {
remoteChanges: WorkspaceGitRemoteRefChange[];
nonRemoteRefsChanged: boolean;
}
export function diffWorkspaceGitRefs(
before: ReadonlyMap<string, string>,
after: ReadonlyMap<string, string>,
): WorkspaceGitRefDiff {
const remotePrefix = "refs/remotes/";
const beforeRemote = new Map<string, string>();
const afterRemote = new Map<string, string>();
let nonRemoteRefsChanged = false;
for (const [ref, objectId] of before) {
if (ref.startsWith(remotePrefix)) {
beforeRemote.set(ref.slice(remotePrefix.length), objectId);
} else if (after.get(ref) !== objectId) {
nonRemoteRefsChanged = true;
}
}
for (const [ref, objectId] of after) {
if (ref.startsWith(remotePrefix)) {
afterRemote.set(ref.slice(remotePrefix.length), objectId);
} else if (!before.has(ref)) {
nonRemoteRefsChanged = true;
}
}
return {
remoteChanges: diffWorkspaceGitRemoteRefs(beforeRemote, afterRemote),
nonRemoteRefsChanged,
};
}
export function diffWorkspaceGitRemoteRefs(
before: ReadonlyMap<string, string>,
after: ReadonlyMap<string, string>,
): WorkspaceGitRemoteRefChange[] {
const changes: WorkspaceGitRemoteRefChange[] = [];
for (const [ref, objectId] of after) {
const beforeOid = before.get(ref);
if (beforeOid === undefined) {
changes.push({ kind: "added", ref, afterOid: objectId });
} else if (beforeOid !== objectId) {
changes.push({ kind: "moved", ref, beforeOid, afterOid: objectId });
}
}
for (const [ref, beforeOid] of before) {
if (!after.has(ref)) {
changes.push({ kind: "deleted", ref, beforeOid });
}
}
return changes;
}
@@ -0,0 +1,466 @@
import { execFile, execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import { afterAll, beforeAll, expect, test } from "vitest";
import {
getGitCommandMetrics,
startGitCommandMetrics,
stopGitCommandMetrics,
waitForGitCommandMetricsIdle,
} from "../utils/run-git-command.js";
import { fetchWorkspaceGitRemote } from "./workspace-git-fetch.js";
import {
WorkspaceGitServiceImpl,
getWorkspaceGitSelfHealPhaseMs,
} from "./workspace-git-service.js";
const SIBLING_COUNT = 59;
const REMOTE_REF_COUNT = 19_500;
const cleanupPaths: string[] = [];
let fixture: ReturnType<typeof seedFetchFixture>;
beforeAll(() => {
fixture = seedFetchFixture();
}, 120_000);
afterAll(() => {
for (const path of cleanupPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
}, 120_000);
function git(cwd: string, args: string[], input?: string): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
input,
env: {
...process.env,
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
},
}).trim();
}
function gitAsync(cwd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{
cwd,
env: {
...process.env,
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
},
},
(error) => {
if (error) reject(error);
else resolve();
},
);
});
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function hasSubmittedGitOperation(operation: string): boolean {
return getGitCommandMetrics().submissions.some((command) => command.args[0] === operation);
}
async function waitForCondition(
description: string,
condition: () => boolean,
timeoutMs = 120_000,
): Promise<void> {
const startedAt = Date.now();
while (!condition()) {
if (Date.now() - startedAt >= timeoutMs) {
throw new Error(`Timed out waiting for ${description}`);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
function seedFetchFixture(): {
originRoot: string;
paseoHome: string;
repoRoot: string;
worktrees: string[];
} {
const fixtureRoot = mkdtempSync(join(tmpdir(), "paseo-noop-fetch-"));
cleanupPaths.push(fixtureRoot);
const repoRoot = join(fixtureRoot, "repo");
const originRoot = join(fixtureRoot, "origin.git");
const paseoHome = join(fixtureRoot, "paseo-home");
const worktreesRoot = join(fixtureRoot, "worktrees");
mkdirSync(repoRoot, { recursive: true });
mkdirSync(paseoHome, { recursive: true });
mkdirSync(worktreesRoot, { recursive: true });
git(repoRoot, ["init", "-b", "main"]);
git(repoRoot, ["config", "user.email", "repro@example.com"]);
git(repoRoot, ["config", "user.name", "Repro"]);
writeFileSync(join(repoRoot, "README.md"), "no-op fetch repro\n");
git(repoRoot, ["add", "README.md"]);
git(repoRoot, ["commit", "-m", "initial"]);
git(fixtureRoot, ["init", "--bare", originRoot]);
git(repoRoot, ["remote", "add", "origin", originRoot]);
git(repoRoot, ["push", "--quiet", "-u", "origin", "main"]);
const head = git(repoRoot, ["rev-parse", "HEAD"]);
const remoteRefUpdates = Array.from(
{ length: REMOTE_REF_COUNT },
(_, index) => `update refs/heads/load/${index.toString().padStart(5, "0")} ${head}`,
).join("\n");
git(originRoot, ["update-ref", "--stdin"], `${remoteRefUpdates}\n`);
git(originRoot, ["pack-refs", "--all", "--prune"]);
git(repoRoot, ["fetch", "--quiet", "origin", "--prune"]);
git(repoRoot, ["pack-refs", "--all", "--prune"]);
const worktrees = Array.from({ length: SIBLING_COUNT }, (_, index) => {
let suffix = 0;
let branch = `sibling-${index}-${suffix}`;
let cwd = join(worktreesRoot, branch);
while (getWorkspaceGitSelfHealPhaseMs(cwd) < 50_000) {
suffix += 1;
branch = `sibling-${index}-${suffix}`;
cwd = join(worktreesRoot, branch);
}
git(repoRoot, ["worktree", "add", "--quiet", "-b", branch, cwd, "HEAD"]);
return cwd;
});
return { originRoot, paseoHome, repoRoot, worktrees };
}
function advanceOriginRef(originRoot: string, ref: string): void {
git(originRoot, ["config", "user.email", "repro@example.com"]);
git(originRoot, ["config", "user.name", "Repro"]);
const previous = git(originRoot, ["rev-parse", ref]);
const tree = git(originRoot, ["show", "-s", "--format=%T", previous]);
const next = git(originRoot, ["commit-tree", tree, "-p", previous], `advance ${ref}\n`);
git(originRoot, ["update-ref", ref, next, previous]);
}
function advanceOriginMain(originRoot: string): void {
advanceOriginRef(originRoot, "refs/heads/main");
}
async function measureFetchScenario(
name: string,
beforeFetch: () => void,
duringFetch?: () => Promise<void>,
quietMs = 1_500,
): Promise<{
fetchCount: number;
gitCommands: number;
nonRemoteRefsChanged: boolean | undefined;
operations: Record<string, number>;
snapshotUpdates: number;
}> {
const fetchStarted = createDeferred<void>();
const releaseFetch = createDeferred<void>();
let fetchCount = 0;
let fetchChangedNonRemoteRefs: boolean | undefined;
const service = new WorkspaceGitServiceImpl({
logger: pino({ level: "silent" }),
paseoHome: fixture.paseoHome,
deps: {
runGitFetch: async (cwd, observer) => {
fetchStarted.resolve();
await releaseFetch.promise;
const result = await fetchWorkspaceGitRemote(cwd, observer);
fetchChangedNonRemoteRefs = result.nonRemoteRefsChanged;
fetchCount += 1;
return result;
},
},
});
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
const subscriptions = fixture.worktrees.map((cwd) =>
service.registerWorkspace({ cwd }, () => {
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
}),
);
try {
await fetchStarted.promise;
await waitForCondition(
"all sibling workspace snapshots to warm before the measured fetch runs",
() =>
[...snapshotCounts.values()].every((count) => count >= 1) &&
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
service.getMetrics().workspaceRefreshQueuedCount === 0,
);
beforeFetch();
const snapshotBaseline = new Map(snapshotCounts);
const fetchCycleStartedAt = Date.now();
startGitCommandMetrics();
releaseFetch.resolve();
await duringFetch?.();
await waitForGitCommandMetricsIdle({ quietMs, timeoutMs: 120_000 });
const postFetch = stopGitCommandMetrics();
const snapshotUpdatesAfterFetch = [...snapshotCounts].reduce(
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
0,
);
const operations = Object.fromEntries(
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
.map(([operation, commands]) => [operation, commands.length] as const)
.sort(([left], [right]) => left.localeCompare(right)),
);
console.info(
"[workspace-git-fetch]",
JSON.stringify({
scenario: name,
siblingCount: SIBLING_COUNT,
remoteRefCount: REMOTE_REF_COUNT,
fetchCount,
durationMs: Date.now() - fetchCycleStartedAt,
gitCommands: postFetch.submitted,
maxConcurrentGitCommands: postFetch.maxConcurrent,
operations,
snapshotUpdates: snapshotUpdatesAfterFetch,
}),
);
return {
fetchCount,
gitCommands: postFetch.submitted,
nonRemoteRefsChanged: fetchChangedNonRemoteRefs,
operations,
snapshotUpdates: snapshotUpdatesAfterFetch,
};
} finally {
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
service.dispose();
}
}
async function measureExternalFetchScenario(
name: string,
beforeFetch: () => void,
): Promise<{
gitCommands: number;
operations: Record<string, number>;
snapshotUpdates: number;
}> {
const initialFetchCompleted = createDeferred<void>();
const service = new WorkspaceGitServiceImpl({
logger: pino({ level: "silent" }),
paseoHome: fixture.paseoHome,
deps: {
runGitFetch: async (cwd, observer) => {
const result = await fetchWorkspaceGitRemote(cwd, observer);
initialFetchCompleted.resolve();
return result;
},
},
});
const snapshotCounts = new Map(fixture.worktrees.map((cwd) => [cwd, 0]));
const subscriptions = fixture.worktrees.map((cwd) =>
service.registerWorkspace({ cwd }, () => {
snapshotCounts.set(cwd, (snapshotCounts.get(cwd) ?? 0) + 1);
}),
);
try {
await initialFetchCompleted.promise;
await waitForCondition(
"all sibling workspace snapshots to warm before the external fetch runs",
() =>
[...snapshotCounts.values()].every((count) => count >= 1) &&
service.getMetrics().repositoryWorkspaceLinkCount === SIBLING_COUNT &&
service.getMetrics().workspaceRefreshInFlightCount === 0 &&
service.getMetrics().workspaceRefreshQueuedCount === 0,
);
await new Promise((resolve) => setTimeout(resolve, 6_000));
const snapshotBaseline = new Map(snapshotCounts);
beforeFetch();
const fetchCycleStartedAt = Date.now();
startGitCommandMetrics();
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
await waitForGitCommandMetricsIdle({ quietMs: 6_000, timeoutMs: 120_000 });
const postFetch = stopGitCommandMetrics();
const snapshotUpdates = [...snapshotCounts].reduce(
(total, [cwd, count]) => total + Math.max(0, count - (snapshotBaseline.get(cwd) ?? 0)),
0,
);
const operations = Object.fromEntries(
[...Map.groupBy(postFetch.submissions, (command) => command.args[0] ?? "").entries()]
.map(([operation, commands]) => [operation, commands.length] as const)
.sort(([left], [right]) => left.localeCompare(right)),
);
console.info(
"[workspace-git-fetch]",
JSON.stringify({
scenario: name,
siblingCount: SIBLING_COUNT,
remoteRefCount: REMOTE_REF_COUNT,
durationMs: Date.now() - fetchCycleStartedAt,
gitCommands: postFetch.submitted,
maxConcurrentGitCommands: postFetch.maxConcurrent,
operations,
snapshotUpdates,
}),
);
return { gitCommands: postFetch.submitted, operations, snapshotUpdates };
} finally {
for (const subscription of subscriptions) subscription.unsubscribe();
service.dispose();
}
}
test("a no-op fetch does not refresh sibling worktrees", async () => {
expect(await measureFetchScenario("a no-op fetch", () => {})).toEqual({
fetchCount: 1,
gitCommands: 3,
nonRemoteRefsChanged: false,
operations: { fetch: 1, "for-each-ref": 2 },
snapshotUpdates: 0,
});
}, 240_000);
test("an origin/main update narrowly refreshes 59 sibling worktrees", async () => {
expect(
await measureFetchScenario("an origin/main update", () => {
advanceOriginMain(fixture.originRoot);
}),
).toEqual({
fetchCount: 1,
gitCommands: 239,
nonRemoteRefsChanged: false,
operations: {
diff: 59,
fetch: 1,
"for-each-ref": 2,
"ls-files": 59,
"merge-base": 59,
"rev-list": 59,
},
snapshotUpdates: 59,
});
}, 240_000);
test("an overlapping external no-op fetch stays idle", async () => {
const result = await measureFetchScenario(
"an overlapping external no-op fetch",
() => {},
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
6_000,
);
expect(result).toEqual({
fetchCount: 1,
gitCommands: 3,
nonRemoteRefsChanged: false,
operations: { fetch: 1, "for-each-ref": 2 },
snapshotUpdates: 0,
});
}, 240_000);
test("an overlapping external fetch of one main update stays narrow", async () => {
const result = await measureFetchScenario(
"an overlapping external fetch of one main update",
() => {
advanceOriginMain(fixture.originRoot);
},
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
6_000,
);
expect(result).toEqual({
fetchCount: 1,
gitCommands: 239,
nonRemoteRefsChanged: false,
operations: {
diff: 59,
fetch: 1,
"for-each-ref": 2,
"ls-files": 59,
"merge-base": 59,
"rev-list": 59,
},
snapshotUpdates: 59,
});
}, 240_000);
test("an overlapping external fetch of an unrelated branch stays idle", async () => {
const result = await measureFetchScenario(
"an overlapping external fetch of an unrelated branch",
() => {
advanceOriginRef(fixture.originRoot, "refs/heads/load/00000");
},
() => gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]),
6_000,
);
expect(result).toEqual({
fetchCount: 1,
gitCommands: 3,
nonRemoteRefsChanged: false,
operations: { fetch: 1, "for-each-ref": 2 },
snapshotUpdates: 0,
});
}, 240_000);
test("a second same-ref fetch during calculation stays narrow", async () => {
const result = await measureFetchScenario(
"a second same-ref fetch during calculation",
() => {
advanceOriginMain(fixture.originRoot);
},
async () => {
await waitForCondition("the first narrow calculation to start", () =>
hasSubmittedGitOperation("rev-list"),
);
advanceOriginMain(fixture.originRoot);
await gitAsync(fixture.repoRoot, ["fetch", "origin", "--prune"]);
},
6_000,
);
expect(result).toEqual({
fetchCount: 1,
gitCommands: 475,
nonRemoteRefsChanged: false,
operations: {
diff: 118,
fetch: 1,
"for-each-ref": 2,
"ls-files": 118,
"merge-base": 118,
"rev-list": 118,
},
snapshotUpdates: 118,
});
}, 240_000);
test("a standalone external fetch of an unrelated packed branch stays idle", async () => {
const result = await measureExternalFetchScenario(
"a standalone external fetch of an unrelated packed branch",
() => {
advanceOriginRef(fixture.originRoot, "refs/heads/load/00001");
},
);
expect(result).toEqual({ gitCommands: 0, operations: {}, snapshotUpdates: 0 });
}, 240_000);
@@ -157,6 +157,10 @@ function createService(
deps: {
subscribe: watcher.subscribe,
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
})),
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState: vi.fn(async (cwd: string) => {
@@ -441,6 +445,441 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("a loose remote-ref watcher echo during fetch coalesces into the narrow refresh", async () => {
const watcher = createWatcherHarness();
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
baseRef: "main",
hasRemote: true,
remoteUrl: "https://example.com/repo.git",
}),
),
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer?.onRefSnapshot("before");
await releaseFetch.promise;
observer?.onRefSnapshot("after");
return {
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
error: null,
};
}),
});
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(1);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
listener.mockClear();
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
]);
releaseFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
service.dispose();
});
test("a packed-refs event covered by the fetch snapshot uses the exact ref delta", async () => {
const watcher = createWatcherHarness();
const fetchWindowOpen = createDeferred<void>();
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn();
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer.onRefSnapshot("before");
fetchWindowOpen.resolve();
await releaseFetch.promise;
observer.onRefSnapshot("after");
return {
changes: [
{
kind: "moved" as const,
ref: "origin/unrelated",
beforeOid: "a",
afterOid: "b",
},
],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main", "origin/unrelated"]),
error: null,
};
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await fetchWindowOpen.promise;
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
releaseFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
subscription.unsubscribe();
service.dispose();
});
test.each(["before", "after"] as const)(
"a packed-refs event %s the fetch snapshot remains conservative",
async (phase) => {
const watcher = createWatcherHarness();
const afterSnapshot = createDeferred<void>();
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
remoteUrl: "https://example.com/repo.git",
}));
const runGitFetch = vi.fn(async (_cwd, observer) => {
if (phase === "before") {
await releaseFetch.promise;
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
} else {
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
afterSnapshot.resolve();
await releaseFetch.promise;
}
return {
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main"]),
error: null,
};
});
const service = createService(watcher, {
getCheckoutSnapshotFacts,
hasOriginRemote: vi.fn(async () => true),
runGitFetch,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(1);
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
});
if (phase === "after") await afterSnapshot.promise;
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
releaseFetch.resolve();
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
subscription.unsubscribe();
service.dispose();
},
);
test("a post-snapshot delete overrides the fetch's moved-ref classification", async () => {
const watcher = createWatcherHarness();
const afterSnapshot = createDeferred<void>();
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
remoteUrl: "https://example.com/repo.git",
}));
const getCheckoutRefDerivedState = vi.fn();
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
afterSnapshot.resolve();
await releaseFetch.promise;
return {
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main"]),
error: null,
};
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await afterSnapshot.promise;
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "delete" },
]);
releaseFetch.resolve();
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
subscription.unsubscribe();
service.dispose();
});
test("known packed remote refs materialize narrowly while namespace directories are ignored", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer.onRefSnapshot("before");
observer.onRefSnapshot("after");
return {
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main", "origin/load/00001"]),
error: null,
};
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
repoWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "load"), type: "create" },
{
path: path.join(GIT_DIR, "refs", "remotes", "origin", "load", "00001"),
type: "create",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
repoWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
service.dispose();
});
test("a second remote-ref move during a narrow refresh queues a final calculation", async () => {
const watcher = createWatcherHarness();
const firstRefRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi
.fn<
(
cwd: string,
facts: CheckoutSnapshotFacts,
current: CheckoutStatusGit,
) => Promise<CheckoutStatusGit>
>()
.mockImplementationOnce(() => firstRefRefresh.promise)
.mockImplementation(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
baseRef: "main",
hasRemote: true,
remoteUrl: "https://example.com/repo.git",
}),
),
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async () => ({
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
error: null,
})),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1);
firstRefRefresh.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
subscription.unsubscribe();
service.dispose();
});
test("an unmatched remote-ref event buffered after the fetch snapshot is refreshed", async () => {
const watcher = createWatcherHarness();
const fetchSnapshotRead = createDeferred<void>();
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "feature",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
baseRef: "main",
hasRemote: true,
remoteUrl: "https://example.com/repo.git",
}),
),
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async (_cwd, observer) => {
observer?.onRefSnapshot("before");
observer?.onRefSnapshot("after");
fetchSnapshotRead.resolve();
await releaseFetch.promise;
return { changes: [], nonRemoteRefsChanged: false, error: null };
}),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await fetchSnapshotRead.promise;
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
releaseFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
subscription.unsubscribe();
service.dispose();
});
test("origin/main refreshes a main checkout without configured upstream", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutFacts(cwd),
currentBranch: "main",
remoteUrl: "https://example.com/repo.git",
resolvedBaseRef: "main",
comparisonBaseRef: null,
branchRemoteName: null,
branchMergeRef: null,
upstreamStatus: null,
}));
const service = createService(watcher, { getCheckoutSnapshotFacts });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
subscription.unsubscribe();
service.dispose();
});
test("routes private worktree metadata to its owner and shared base refs to dependents", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createLinkedCheckoutFacts(cwd));
@@ -535,7 +974,7 @@ describe("WorkspaceGitService checkout observation", () => {
service.dispose();
});
test("routes a newly created remote tracking ref to its configured workspace", async () => {
test("a newly created remote tracking ref refreshes every repository workspace", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
const facts = createLinkedCheckoutFacts(cwd);
@@ -577,7 +1016,7 @@ describe("WorkspaceGitService checkout observation", () => {
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]);
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
});
first.unsubscribe();
@@ -831,7 +1270,10 @@ describe("WorkspaceGitService checkout observation", () => {
test("ten worktrees share one repository metadata and fetch observer while retaining checkout state", async () => {
const watcher = createWatcherHarness();
const fetch = createDeferred<void>();
const runGitFetch = vi.fn(() => fetch.promise);
const runGitFetch = vi.fn(async () => {
await fetch.promise;
return { changes: [], error: null };
});
const commonGitDir = path.resolve("/tmp/paseo-shared-repository.git");
const worktrees = Array.from({ length: 10 }, (_, index) =>
path.resolve(`/tmp/paseo-shared-worktree-${index}`),
@@ -886,7 +1328,7 @@ describe("WorkspaceGitService checkout observation", () => {
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(2);
});
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1]);
expect(runGitFetch).toHaveBeenLastCalledWith(worktrees[1], expect.anything());
for (const subscription of subscriptions.slice(1)) {
subscription.unsubscribe();
@@ -334,6 +334,10 @@ function buildDefaultServiceDeps() {
return {
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutFacts(cwd)),
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
})),
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat: vi.fn(async () => ({
additions: 1,
@@ -349,7 +353,10 @@ function buildDefaultServiceDeps() {
forgeOverrides: { github: createGitHubServiceStub() },
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(() => createDeferred<void>().promise),
runGitFetch: vi.fn(async () => {
await createDeferred<void>().promise;
return { changes: [], error: null };
}),
runGitCommand: vi.fn(async () => ({
stdout: `${REPO_CWD}\n`,
stderr: "",
@@ -264,6 +264,10 @@ function buildDefaultTestServiceDeps() {
return {
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn(async () => {}) })),
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
getCheckoutRefDerivedState: vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
})),
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat: vi.fn(async () => ({
additions: 1,
@@ -274,7 +278,7 @@ function buildDefaultTestServiceDeps() {
forgeOverrides: { github: createGitHubServiceStub() },
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => false),
runGitFetch: vi.fn(async () => {}),
runGitFetch: vi.fn(async () => ({ changes: [], error: null })),
runGitCommand: vi.fn(async () => ({
stdout: `${REPO_CWD}\n`,
stderr: "",
@@ -842,17 +846,28 @@ describe("WorkspaceGitServiceImpl", () => {
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
const firstFetch = createDeferred<void>();
const runGitFetch = vi.fn(() => firstFetch.promise);
const runGitFetch = vi.fn(async () => {
await firstFetch.promise;
return {
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
error: null,
};
});
const hasOriginRemote = vi.fn(async () => true);
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutSnapshotFacts(cwd),
gitCommonDir: join(REPO_CWD, ".git"),
absoluteGitDir: join(REPO_CWD, ".git"),
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
resolveAbsoluteGitDir,
hasOriginRemote,
runGitFetch,
@@ -877,7 +892,8 @@ describe("WorkspaceGitServiceImpl", () => {
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(2);
});
await vi.advanceTimersByTimeAsync(180_000);
@@ -890,6 +906,165 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("remote ref name drift outside the fetch interval takes the structural fallback", async () => {
const runGitFetch = vi
.fn()
.mockResolvedValueOnce({
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main"]),
error: null,
})
.mockResolvedValueOnce({
changes: [],
nonRemoteRefsChanged: false,
remoteRefs: new Set(["origin/main", "origin/new"]),
error: null,
});
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutSnapshotFacts(cwd),
gitCommonDir: join(REPO_CWD, ".git"),
absoluteGitDir: join(REPO_CWD, ".git"),
}));
const service = createService({
getCheckoutSnapshotFacts,
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => true),
runGitFetch,
});
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(1);
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
getCheckoutSnapshotFacts.mockClear();
await vi.advanceTimersByTimeAsync(180_000);
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(2);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
first.unsubscribe();
second.unsubscribe();
service.dispose();
});
test.each([
{
name: "an added remote ref",
result: {
changes: [{ kind: "added" as const, ref: "origin/new", afterOid: "b" }],
error: null,
},
},
{
name: "a deleted remote ref",
result: {
changes: [{ kind: "deleted" as const, ref: "origin/main", beforeOid: "a" }],
error: null,
},
},
{
name: "a concurrent local ref change",
result: { changes: [], nonRemoteRefsChanged: true, error: null },
},
])("$name takes the repository-wide structural fallback", async ({ result }) => {
const releaseFetch = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutSnapshotFacts(cwd),
gitCommonDir: join(REPO_CWD, ".git"),
absoluteGitDir: join(REPO_CWD, ".git"),
}));
const getCheckoutRefDerivedState = vi.fn();
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async () => {
await releaseFetch.promise;
return result;
}),
});
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
releaseFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4);
});
expect(getCheckoutRefDerivedState).not.toHaveBeenCalled();
first.unsubscribe();
second.unsubscribe();
service.dispose();
});
test("a failed narrow remote-ref refresh falls back for that workspace", async () => {
const releaseFetch = createDeferred<void>();
const getCheckoutShortstat = vi
.fn()
.mockResolvedValueOnce({ additions: 1, deletions: 0 })
.mockResolvedValue({ additions: 2, deletions: 0 });
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
...createCheckoutSnapshotFacts(cwd),
gitCommonDir: join(REPO_CWD, ".git"),
absoluteGitDir: join(REPO_CWD, ".git"),
}));
const getCheckoutRefDerivedState = vi.fn(async () => {
throw new Error("ref moved again");
});
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutShortstat,
resolveAbsoluteGitDir: vi.fn(async () => join(REPO_CWD, ".git")),
hasOriginRemote: vi.fn(async () => true),
runGitFetch: vi.fn(async () => {
await releaseFetch.promise;
return {
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
error: null,
};
}),
});
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
releaseFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
});
expect(listener.mock.lastCall?.[0].git.diffStat).toEqual({ additions: 2, deletions: 0 });
subscription.unsubscribe();
service.dispose();
});
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
const getPullRequestStatus = vi
.fn<() => Promise<PullRequestStatusResult>>()
@@ -1180,7 +1355,10 @@ describe("WorkspaceGitServiceImpl", () => {
const backgroundFetch = createDeferred<void>();
const service = createService({
getCheckoutWorktreeState,
runGitFetch: vi.fn(() => backgroundFetch.promise),
runGitFetch: vi.fn(async () => {
await backgroundFetch.promise;
return { changes: [], error: null };
}),
subscribe,
});
const workspaceListener = vi.fn();
@@ -15,6 +15,7 @@ import {
type CheckoutDiffCompare,
type CheckoutDiffResult,
getCheckoutDiff,
getCheckoutRefDerivedState,
getCheckoutSnapshotFacts,
getCheckoutShortstat,
getCheckoutStatus,
@@ -46,14 +47,22 @@ import {
isRealpathInsideRoot,
} from "../utils/path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { branchNameFromRef } from "../utils/worktree-metadata.js";
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
import { classifyGitMetadataPath, getPrunedGitMetadataPaths } from "./git-metadata-event-rules.js";
import {
fetchWorkspaceGitRemote,
type WorkspaceGitFetchResult,
type WorkspaceGitFetchObserver,
type WorkspaceGitRemoteRefChange,
} from "./workspace-git-fetch.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
@@ -75,6 +84,21 @@ const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64;
// Small values (booleans, short strings, small arrays); generous cap.
const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256;
function mergeSets<T>(
left: ReadonlySet<T>,
right: ReadonlySet<T>,
): { merged: Set<T>; added: boolean } {
const merged = new Set(left);
let added = false;
for (const value of right) {
if (!merged.has(value)) {
merged.add(value);
added = true;
}
}
return { merged, added };
}
export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number {
return (
createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS
@@ -269,15 +293,17 @@ interface WorkspaceGitRefreshRequest {
reason: string;
notify: boolean;
queueIfBusy: boolean;
movedRemoteRefs: Set<string>;
}
interface ScheduledWorkspaceGitRefreshOptions {
force?: boolean;
scope?: "structure" | "worktree";
scope?: "refs" | "structure" | "worktree";
includeForge?: boolean;
emitUnchanged?: boolean;
reason?: string;
queueIfBusy?: boolean;
movedRemoteRefs?: ReadonlySet<string>;
}
type WorkspaceGitRefreshState =
@@ -294,6 +320,7 @@ type WorkspaceGitRefreshState =
interface WorkspaceGitServiceDependencies {
subscribe: typeof parcelWatcher.subscribe;
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
getCheckoutRefDerivedState: typeof getCheckoutRefDerivedState;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getCheckoutWorktreeState: typeof getCheckoutWorktreeState;
@@ -311,7 +338,10 @@ interface WorkspaceGitServiceDependencies {
forgeOverrides?: Record<string, ForgeService>;
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
runGitFetch: (
cwd: string,
observer: WorkspaceGitFetchObserver,
) => Promise<WorkspaceGitFetchResult>;
runGitCommand: typeof runGitCommand;
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs;
now: () => Date;
@@ -405,11 +435,20 @@ interface RepoGitTarget {
recovery: WatchRecoveryState;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
bufferedFetchMetadataEvents: parcelWatcher.Event[];
recentFetchRemoteRefChanges: Map<
string,
{ change: WorkspaceGitRemoteRefChange; expiresAtMs: number }
>;
knownRemoteRefs: Set<string> | null;
closed: boolean;
}
interface RepoMetadataWorkspaceRefresh {
refreshBase: boolean;
structural: boolean;
movedRemoteRefs: Set<string>;
queueIfBusy: boolean;
}
interface WorkingTreeWatchTarget {
@@ -433,6 +472,10 @@ interface WatchRecoveryState {
timer: NodeJS.Timeout | null;
}
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
return left.size === right.size && [...left].every((value) => right.has(value));
}
type WorkingTreeWatchFallbackReason =
| "not_a_git_checkout"
| "watcher_error"
@@ -456,6 +499,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
return {
subscribe: subscribeToWorkspaceFileChanges,
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
@@ -467,7 +511,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
listPaseoWorktrees,
resolveAbsoluteGitDir,
hasOriginRemote,
runGitFetch,
runGitFetch: fetchWorkspaceGitRemote,
runGitCommand,
getWorkspaceGitSelfHealPhaseMs,
now: () => new Date(),
@@ -863,6 +907,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: "refresh",
notify: true,
queueIfBusy: false,
movedRemoteRefs: new Set(),
});
this.scheduleWorkspaceObservationSetup(target);
}
@@ -1130,6 +1175,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: "initial",
notify: true,
queueIfBusy: false,
movedRemoteRefs: new Set(),
});
});
}
@@ -1475,6 +1521,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: "working-tree-watch-fallback",
notify: true,
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
if (target.repoRoot === null && workspaceTarget.latestGit?.isGit === true) {
workspaceTarget.observationSetupComplete = false;
@@ -1741,6 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
recovery: { attemptCount: 0, timer: null },
intervalId: null,
fetchInFlight: false,
bufferedFetchMetadataEvents: [],
recentFetchRemoteRefChanges: new Map(),
knownRemoteRefs: null,
closed: false,
};
this.repoTargets.set(repoGitRoot, repoTarget);
@@ -1821,12 +1871,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
);
if (relevantEvents.length > 0) {
this.scheduleRepoMetadataRefresh(
target,
"git-metadata-watch",
true,
this.routeRepoMetadataEvents(target, relevantEvents),
);
const immediateEvents = target.fetchInFlight
? relevantEvents.filter((event) => {
if (this.isFetchRemoteMetadataEvent(target, event)) {
target.bufferedFetchMetadataEvents.push(event);
return false;
}
return true;
})
: relevantEvents;
if (immediateEvents.length > 0) {
const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents);
this.scheduleRepoMetadataRefresh(
target,
"git-metadata-watch",
routedRefreshes === null || [...routedRefreshes.values()].some((r) => r.structural),
routedRefreshes,
);
}
}
},
{ ignore },
@@ -1922,6 +1984,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return refreshes;
}
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: parcelWatcher.Event): boolean {
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
const effect = classifyGitMetadataPath("common", relativePath ?? "");
return (
(effect.kind === "ref" && effect.namespace === "remote") ||
(effect.kind === "all" &&
(relativePath === "packed-refs" || relativePath?.startsWith("reftable/") === true))
);
}
private routeRepoMetadataEvent(
target: RepoGitTarget,
event: parcelWatcher.Event,
@@ -1942,10 +2014,39 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (effect.namespace === "local") {
this.routeLocalBranchRef(target, effect.ref, refreshes);
} else {
this.routeRemoteBranchRef(target, effect.ref, refreshes);
if (event.type === "delete") {
target.recentFetchRemoteRefChanges.delete(effect.ref);
target.knownRemoteRefs = null;
return false;
}
const recent = target.recentFetchRemoteRefChanges.get(effect.ref);
if (recent && recent.expiresAtMs >= this.deps.now().getTime()) {
this.routeRemoteBranchRef(target, effect.ref, refreshes, {
narrow: recent.change.kind === "moved",
});
} else {
target.recentFetchRemoteRefChanges.delete(effect.ref);
if (target.knownRemoteRefs?.has(effect.ref)) {
this.routeRemoteBranchRef(target, effect.ref, refreshes, { narrow: true });
} else if (
event.type === "create" &&
target.knownRemoteRefs &&
[...target.knownRemoteRefs].some((ref) => ref.startsWith(`${effect.ref}/`))
) {
return true;
} else {
return false;
}
}
}
return true;
case "all":
if (
commonRelativePath === "packed-refs" ||
commonRelativePath?.startsWith("reftable/") === true
) {
target.knownRemoteRefs = null;
}
return false;
}
}
@@ -2016,6 +2117,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: RepoGitTarget,
remoteRef: string,
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
options?: { narrow?: boolean; queueIfBusy?: boolean },
): void {
const qualifiedRemoteRef = `refs/remotes/${remoteRef}`;
for (const workspaceKey of target.workspaceKeys) {
@@ -2028,16 +2130,26 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
facts.branchRemoteName && facts.branchRemoteName !== "." && trackedBranch
? `${facts.branchRemoteName}/${trackedBranch}`
: null;
const shortstatRemoteRef =
facts.currentBranch &&
(!facts.resolvedBaseRef || branchNameFromRef(facts.resolvedBaseRef) === facts.currentBranch)
? `origin/${facts.currentBranch}`
: null;
const refs = [
facts.storedBaseRef,
facts.resolvedBaseRef,
facts.comparisonBaseRef,
facts.upstreamStatus?.ref,
configuredRemoteRef,
shortstatRemoteRef,
];
const usesRemoteRef = refs.some((ref) => ref === remoteRef || ref === qualifiedRemoteRef);
if (usesRemoteRef) {
this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true);
this.addWorkspaceMetadataRefresh(refreshes, workspaceKey, true, {
structural: options?.narrow !== true,
movedRemoteRef: options?.narrow === true ? remoteRef : undefined,
queueIfBusy: options?.queueIfBusy,
});
}
}
}
@@ -2046,10 +2158,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
workspaceKey: string,
refreshBase: boolean,
options?: {
structural?: boolean;
movedRemoteRef?: string;
queueIfBusy?: boolean;
},
): void {
const previous = refreshes.get(workspaceKey);
const movedRemoteRefs = new Set(previous?.movedRemoteRefs);
if (options?.movedRemoteRef) {
movedRemoteRefs.add(options.movedRemoteRef);
}
refreshes.set(workspaceKey, {
refreshBase: previous?.refreshBase === true || refreshBase,
structural: previous?.structural === true || options?.structural !== false,
movedRemoteRefs,
queueIfBusy: (previous?.queueIfBusy ?? false) || (options?.queueIfBusy ?? true),
});
}
@@ -2066,11 +2190,23 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const refreshes =
routedRefreshes ??
new Map(
Array.from(target.workspaceKeys, (workspaceKey) => [workspaceKey, { refreshBase: true }]),
Array.from(target.workspaceKeys, (workspaceKey) => [
workspaceKey,
{
refreshBase: true,
structural: true,
movedRemoteRefs: new Set<string>(),
queueIfBusy: true,
},
]),
);
for (const [workspaceKey, refresh] of refreshes) {
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
if (workspaceTarget) {
let scope: ScheduledWorkspaceGitRefreshOptions["scope"];
if (!refreshWorktree) {
scope = refresh.structural ? "structure" : "refs";
}
if (refresh.refreshBase) {
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
if (workspaceTarget.latestFacts?.isGit) {
@@ -2084,9 +2220,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
}
this.scheduleWorkspaceRefresh(workspaceTarget, {
scope: refreshWorktree ? undefined : "structure",
scope,
emitUnchanged: refresh.refreshBase,
reason,
queueIfBusy: refresh.queueIfBusy,
movedRemoteRefs: refresh.movedRemoteRefs,
});
}
}
@@ -2129,6 +2267,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: "git-metadata-watch-fallback",
notify: true,
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
}),
);
@@ -2221,6 +2360,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: "self-heal-git",
notify: true,
queueIfBusy: true,
movedRemoteRefs: new Set(),
}).catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
@@ -2488,7 +2628,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
(request.refreshStructure && !active.refreshStructure) ||
(request.refreshWorktree && !active.refreshWorktree) ||
(request.includeForge && !active.includeForge) ||
(request.emitUnchanged === true && active.emitUnchanged !== true);
(request.emitUnchanged === true && active.emitUnchanged !== true) ||
[...request.movedRemoteRefs].some((ref) => !active.movedRemoteRefs.has(ref));
if (request.queueIfBusy || addsWork) {
target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request);
}
@@ -2533,6 +2674,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
reason: options?.reason ?? defaultReason,
notify,
queueIfBusy: false,
movedRemoteRefs: new Set(),
};
}
@@ -2554,13 +2696,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const scope = options?.scope;
return {
force: options?.force === true,
refreshStructure: scope !== "worktree",
refreshWorktree: scope !== "structure",
refreshStructure: scope !== "worktree" && scope !== "refs",
refreshWorktree: scope !== "structure" && scope !== "refs",
includeForge: options?.includeForge ?? false,
emitUnchanged: options?.emitUnchanged,
reason: options?.reason ?? "watch",
notify: true,
queueIfBusy: options?.queueIfBusy ?? true,
movedRemoteRefs: new Set(options?.movedRemoteRefs),
};
}
@@ -2578,6 +2721,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const upgradesWorktree = request.refreshWorktree && !pending.refreshWorktree;
const upgradesForge = request.includeForge && !pending.includeForge;
const upgradesEmit = request.emitUnchanged === true && pending.emitUnchanged !== true;
const { merged: movedRemoteRefs, added: upgradesMovedRefs } = mergeSets(
pending.movedRemoteRefs,
request.movedRemoteRefs,
);
return {
force,
refreshStructure: pending.refreshStructure || request.refreshStructure,
@@ -2585,11 +2732,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
includeForge: pending.includeForge || request.includeForge,
emitUnchanged: pending.emitUnchanged === true || request.emitUnchanged === true,
reason:
upgradesForce || upgradesStructure || upgradesWorktree || upgradesForge || upgradesEmit
upgradesForce ||
upgradesStructure ||
upgradesWorktree ||
upgradesForge ||
upgradesEmit ||
upgradesMovedRefs
? request.reason
: pending.reason,
notify: pending.notify || request.notify,
queueIfBusy: pending.queueIfBusy || request.queueIfBusy,
movedRemoteRefs,
};
}
@@ -2602,7 +2755,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
let failure: { error: unknown } | null = null;
while (true) {
if (request.emitUnchanged === true && target.latestSnapshot) {
if (
request.emitUnchanged === true &&
request.movedRemoteRefs.size === 0 &&
target.latestSnapshot
) {
this.rememberSnapshot(target, target.latestSnapshot, {
notify: request.notify,
forceEmit: true,
@@ -2621,7 +2778,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
snapshot = admittedSnapshot;
this.rememberSnapshot(target, snapshot, {
notify: request.notify,
forceEmit: request.force,
forceEmit:
request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0),
});
failure = null;
} catch (error) {
@@ -2653,6 +2811,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
let facts = target.latestFacts;
if (request.movedRemoteRefs.size > 0 && !request.refreshStructure && !request.refreshWorktree) {
if (facts?.isGit && target.latestGit?.isGit) {
try {
await this.refreshRefDerivedSnapshot(target, facts, request.movedRemoteRefs);
return this.combineSnapshot(target);
} catch (error) {
this.logger.debug(
{ err: error, cwd: target.cwd, movedRemoteRefs: [...request.movedRemoteRefs] },
"Narrow remote ref refresh failed; using structural refresh",
);
}
}
facts = await this.refreshGitSnapshot(target, {
...request,
refreshStructure: true,
refreshWorktree: true,
});
return this.combineSnapshot(target);
}
if (request.refreshStructure || !facts || !target.latestGit) {
facts = await this.refreshGitSnapshot(target, request);
} else if (request.refreshWorktree) {
@@ -2670,6 +2847,36 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return snapshot;
}
private async refreshRefDerivedSnapshot(
target: WorkspaceGitTarget,
facts: Extract<CheckoutSnapshotFacts, { isGit: true }>,
movedRemoteRefs: ReadonlySet<string>,
): Promise<void> {
const latestGit = target.latestGit;
if (!latestGit?.isGit) {
throw new Error("Remote ref refresh requires a warmed Git snapshot");
}
target.lastShellOutAtMs = this.deps.now().getTime();
const derived = await this.deps.getCheckoutRefDerivedState(
target.cwd,
facts,
{ aheadBehind: latestGit.aheadBehind, diffStat: latestGit.diffStat },
movedRemoteRefs,
{ paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, logger: this.logger, facts },
);
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
target.latestGit = {
...latestGit,
aheadBehind: derived.aheadBehind,
diffStat: derived.diffStat,
upstreamRef: derived.upstreamStatus?.ref ?? null,
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
};
const loadedAtMs = this.deps.now().getTime();
target.latestGitLoadedAtMs = loadedAtMs;
}
private async refreshWorktreeSnapshot(
target: WorkspaceGitTarget,
facts: CheckoutSnapshotFacts,
@@ -2899,10 +3106,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Running background git fetch",
);
let succeeded = false;
let result: WorkspaceGitFetchResult | null = null;
const eventsBeforeFetchSnapshot: parcelWatcher.Event[] = [];
try {
await this.deps.runGitFetch(target.cwd);
succeeded = true;
result = await this.deps.runGitFetch(target.cwd, {
onRefSnapshot: (phase) => {
const events = target.bufferedFetchMetadataEvents.splice(0);
if (phase === "before") {
eventsBeforeFetchSnapshot.push(...events);
}
},
});
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
@@ -2911,10 +3125,73 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
} finally {
target.fetchInFlight = false;
}
if (!succeeded) {
this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot);
if (!result || result.changes === null) {
target.recentFetchRemoteRefChanges.clear();
target.knownRemoteRefs = null;
this.flushBufferedFetchMetadataEvents(target);
if (result) {
this.logger.warn(
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch ref classification failed; using structural refresh",
);
}
this.scheduleRepoMetadataRefresh(target, "repo-fetch-unclassified", false);
return;
}
this.scheduleRepoMetadataRefresh(target, "repo-fetch", false);
if (result.error) {
this.logger.warn(
{ err: result.error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch completed with errors after changing refs",
);
}
const expiresAtMs = this.deps.now().getTime() + FETCH_METADATA_ECHO_TTL_MS;
const remoteRefShapeChanged =
target.knownRemoteRefs !== null &&
result.remoteRefs !== undefined &&
!setsEqual(target.knownRemoteRefs, result.remoteRefs);
if (result.remoteRefs) {
target.knownRemoteRefs = new Set(result.remoteRefs);
}
target.recentFetchRemoteRefChanges.clear();
for (const change of result.changes) {
target.recentFetchRemoteRefChanges.set(change.ref, { change, expiresAtMs });
}
this.flushBufferedFetchMetadataEvents(target);
if (
result.nonRemoteRefsChanged === true ||
remoteRefShapeChanged ||
result.changes.some((change) => change.kind !== "moved")
) {
this.scheduleRepoMetadataRefresh(target, "repo-fetch-ref-shape", false);
return;
}
if (result.changes.length === 0) {
return;
}
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
for (const change of result.changes) {
this.routeRemoteBranchRef(target, change.ref, refreshes, { narrow: true });
}
this.scheduleRepoMetadataRefresh(target, "repo-fetch", false, refreshes);
}
private flushBufferedFetchMetadataEvents(target: RepoGitTarget): void {
this.flushFetchMetadataEvents(target, target.bufferedFetchMetadataEvents.splice(0));
}
private flushFetchMetadataEvents(target: RepoGitTarget, events: parcelWatcher.Event[]): void {
if (events.length === 0) {
return;
}
const routedRefreshes = this.routeRepoMetadataEvents(target, events);
this.scheduleRepoMetadataRefresh(
target,
"git-metadata-watch-after-fetch",
routedRefreshes === null ||
[...routedRefreshes.values()].some((refresh) => refresh.structural),
routedRefreshes,
);
}
private removeWorkspaceListener(cwd: string, listener: WorkspaceGitListener): void {
@@ -3250,11 +3527,3 @@ function computeGenericForgeNextInterval(
FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS,
);
}
async function runGitFetch(cwd: string): Promise<void> {
await runGitCommand(["fetch", "origin", "--prune"], {
cwd,
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
timeout: 120_000,
});
}
@@ -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;