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,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 });
|
||||
});
|
||||
@@ -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