Files
paseo/packages/server/src/server/workspace-git-service.observation.test.ts
T
paseo-bot[bot]andDominic Anaeto bfac132920 Stop Git polling while workspace folders are missing (#8462)
* fix(server): stop polling git for a workspace whose folder is gone

When a workspace's folder disappears together with its project (the
repository deleted, a volume unmounted), reconciliation leaves the
workspace registered, and the daemon kept polling it: a failed git
command and a warning about once a minute, and a working-tree watcher
retry every minute or two, for as long as the daemon ran.

The degraded poll now checks that the workspace folder exists before
refreshing. A workspace whose folder is gone is suspended once: it is
detached from its repository and working-tree targets, its timers stop,
and one warning is logged. A worktree that shared a repository target
hands the repository's polling and background fetch to a surviving
worktree. Nothing is archived. A working-tree watch that an open diff
still holds skips its consumer notifications and watcher recovery while
its folder is missing.

While suspended, the workspace runs no git. A stat every 60 seconds, a
new subscription, a forced snapshot or a refresh request checks whether
the folder is back; when it is, the workspace reloads its git facts,
refreshes, and rebuilds its observation.

Fixes #7856

* fix(server): keep missing workspaces suspended during refresh

Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* fix(server): preserve workspace snapshots through suspension

Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:02:26 +00:00

2831 lines
110 KiB
TypeScript

import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { FileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
const REPO_CWD = path.resolve("/tmp/paseo-observation-repo");
const GIT_DIR = path.join(REPO_CWD, ".git");
// Checkout observation must not depend on installed forge CLIs or host-auth probes.
const REMOTE_URL = pathToFileURL(path.join(REPO_CWD, "remote.git")).href;
const WORKTREE_A = path.resolve("/tmp/paseo-observation-worktree-a");
const WORKTREE_B = path.resolve("/tmp/paseo-observation-worktree-b");
interface WatchEvent {
path: string;
type: "create" | "update" | "delete";
}
interface WatchRecord {
directory: string;
callback: (error: Error | null, events: WatchEvent[]) => void;
ignore: Array<string | RegExp>;
updateIgnore: ReturnType<typeof vi.fn>;
unsubscribe: ReturnType<typeof vi.fn>;
}
function createWatcherHarness(harnessOptions?: { failDirectories?: Set<string> }) {
const records: WatchRecord[] = [];
const subscribe = vi.fn(
async (
directory: string,
callback: WatchRecord["callback"],
options?: { ignore?: Array<string | RegExp> },
) => {
if (harnessOptions?.failDirectories?.has(directory)) {
throw new Error(`watch failed: ${directory}`);
}
const updateIgnore = vi.fn(async (paths: string[]) => {
const record = records.find((candidate) => candidate.updateIgnore === updateIgnore);
if (record) record.ignore = paths;
});
const unsubscribe = vi.fn(async () => {});
records.push({
directory,
callback,
ignore: options?.ignore ?? [],
updateIgnore,
unsubscribe,
});
return { updateIgnore, unsubscribe };
},
);
return { records, subscribe };
}
function createCheckoutFacts(cwd: string): CheckoutSnapshotFacts {
return {
isGit: true,
worktreeRoot: cwd,
currentBranch: "main",
remoteUrl: null,
absoluteGitDir: path.join(cwd, ".git"),
gitCommonDir: path.join(cwd, ".git"),
paseoWorktree: { isPaseoOwnedWorktree: false },
storedBaseRef: null,
resolvedBaseRef: "main",
mainRepoRoot: null,
comparisonBaseRef: null,
branchRemoteName: null,
branchMergeRef: null,
pullRequestLookupTarget: { headRef: "main" },
};
}
function createLinkedCheckoutFacts(cwd: string): CheckoutSnapshotFacts {
const worktreeName = path.basename(cwd);
return {
...createCheckoutFacts(cwd),
currentBranch: worktreeName,
absoluteGitDir: path.join(GIT_DIR, "worktrees", worktreeName),
gitCommonDir: GIT_DIR,
resolvedBaseRef: "main",
pullRequestLookupTarget: { headRef: worktreeName },
};
}
function createCheckoutStatus(
cwd: string,
overrides?: Partial<CheckoutStatusGit>,
): CheckoutStatusGit {
return {
isGit: true,
repoRoot: cwd,
mainRepoRoot: null,
currentBranch: "main",
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: false,
...overrides,
};
}
function createLogger(): pino.Logger {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
return logger as unknown as pino.Logger;
}
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 getCalledCwds(mock: ReturnType<typeof vi.fn>): string[] {
return mock.mock.calls.map(([cwd]) => cwd as string);
}
function getWatcherRecordsForDirectory(
watcher: ReturnType<typeof createWatcherHarness>,
directory: string,
): WatchRecord[] {
return watcher.records.filter((record) => record.directory === directory);
}
function getWatcherSubscribeCallCount(
watcher: ReturnType<typeof createWatcherHarness>,
directory: string,
): number {
return watcher.subscribe.mock.calls.filter(([calledDirectory]) => calledDirectory === directory)
.length;
}
// Recovery backoff ladder: base 30s, doubling each attempt, capped at 300s
// (base * 2 ** 4). Mirrors WATCH_RECOVERY_BASE_DELAY_MS /
// WATCH_RECOVERY_MAX_BACKOFF_STEPS / WATCH_RECOVERY_MAX_DELAY_MS in
// workspace-git-service.ts.
const WATCH_RECOVERY_LADDER_DELAYS_MS = [30_000, 60_000, 120_000, 240_000, 300_000, 300_000];
/**
* Drives a watch target (working tree or repository metadata) through six
* recovery cycles, proving three things about `advanceWatchRecoveryLadder`:
* - recovery keeps retrying well past the old cap of 3 attempts (this walks
* it to 6, i.e. 7 total subscriptions),
* - the delay between attempts follows 30s/60s/120s/240s and then plateaus
* at 300s rather than growing without bound, and
* - a recovered subscription that emits an event (proving it is live) and
* then errors again immediately — before surviving the 300s durability
* window — does NOT reset the ladder back to the 30s base. Each "advance
* by 30s only" check below would observe a premature retry if a reset had
* happened.
*/
async function driveWatchRecoveryLadder(
watcher: ReturnType<typeof createWatcherHarness>,
directory: string,
): Promise<void> {
for (const [index, delayMs] of WATCH_RECOVERY_LADDER_DELAYS_MS.entries()) {
const expectedCallCount = index + 2;
if (delayMs > 30_000) {
await vi.advanceTimersByTimeAsync(30_000);
// Not yet reset to the 30s base: no new attempt after only 30s.
expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount - 1);
await vi.advanceTimersByTimeAsync(delayMs - 30_000);
} else {
await vi.advanceTimersByTimeAsync(delayMs);
}
await vi.waitFor(() => {
expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount);
});
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, directory)[index + 1];
recoveredWatcher?.callback(null, [
{ path: path.join(directory, `recovered-${index + 1}.txt`), type: "update" },
]);
recoveredWatcher?.callback(new Error(`recovered watcher stopped ${index + 1}`), []);
}
}
function createService(
watcher: ReturnType<typeof createWatcherHarness>,
overrides?: Record<string, unknown>,
logger: pino.Logger = createLogger(),
fileObserver?: FileObserver,
) {
const defaultGetCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const defaultGetCheckoutShortstat = vi.fn(async () => null);
const getCheckoutStatus =
(overrides?.getCheckoutStatus as typeof defaultGetCheckoutStatus | undefined) ??
defaultGetCheckoutStatus;
const getCheckoutShortstat =
(overrides?.getCheckoutShortstat as typeof defaultGetCheckoutShortstat | undefined) ??
defaultGetCheckoutShortstat;
return new WorkspaceGitServiceImpl({
logger,
paseoHome: "/tmp/paseo-home",
fileObserver,
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) => {
const status = await getCheckoutStatus(cwd);
if (!status.isGit) {
throw new Error("Expected a git checkout");
}
return {
isDirty: status.isDirty,
diffStat: await getCheckoutShortstat(),
};
}),
resolveAbsoluteGitDir: vi.fn(async () => GIT_DIR),
hasOriginRemote: vi.fn(async () => false),
runGitCommand: vi.fn(async () => ({
stdout: `${REPO_CWD}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
})),
createWatcherLivenessCanary: vi.fn(() => ({
path: "",
filterEvents: (events) => events,
verify: vi.fn(async () => {}),
})),
workspaceFolderExists: vi.fn(async () => true),
...overrides,
} as never,
});
}
async function flushPromises(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve();
}
}
describe("WorkspaceGitService checkout observation", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("waits for the initial watcher inventory before building a cold diff", async () => {
const watcher = createWatcherHarness();
const inventoryFinished = createDeferred<void>();
let builds = 0;
const service = createService(watcher, {
subscribe: async (...args: Parameters<typeof watcher.subscribe>) => {
await inventoryFinished.promise;
return watcher.subscribe(...args);
},
getCheckoutDiff: async () => {
builds += 1;
return { diff: "", structured: [] };
},
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {});
try {
const read = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
await flushPromises();
expect(builds).toBe(0);
inventoryFinished.resolve();
await read;
expect(builds).toBe(1);
} finally {
inventoryFinished.resolve();
subscription.unsubscribe();
await service.dispose();
}
});
test("dispose waits for file observation to finish closing", async () => {
const watcher = createWatcherHarness();
const closeFinished = createDeferred<void>();
let closeCalls = 0;
const fileObserver: FileObserver = {
subscribe: watcher.subscribe as FileObserver["subscribe"],
getDiagnostics: () => {
throw new Error("Diagnostics are not used by this lifecycle test");
},
close: () => {
closeCalls += 1;
return closeFinished.promise;
},
};
const service = createService(watcher, undefined, createLogger(), fileObserver);
const disposal = service.dispose();
const repeatedDisposal = service.dispose();
expect(disposal).toBeInstanceOf(Promise);
expect(repeatedDisposal).toBe(disposal);
expect(closeCalls).toBe(1);
const status = await Promise.race([
disposal.then(() => "disposed" as const),
Promise.resolve("pending" as const),
]);
expect(status).toBe("pending");
closeFinished.resolve();
await disposal;
});
test("shares one recursive checkout observer between cwd-equivalent consumers", async () => {
const watcher = createWatcherHarness();
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, { runGitCommand });
const firstListener = vi.fn();
const secondListener = vi.fn();
const first = await service.requestWorkingTreeWatch(REPO_CWD, firstListener);
const second = await service.requestWorkingTreeWatch(path.join(REPO_CWD, "."), secondListener);
expect(first.repoRoot).toBe(REPO_CWD);
expect(second.repoRoot).toBe(REPO_CWD);
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
expect(runGitCommand).toHaveBeenCalledTimes(2);
expect(watcher.records[0]).toMatchObject({ directory: REPO_CWD });
expect(watcher.records[0]?.ignore).toContain(GIT_DIR);
watcher.records[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
]);
expect(firstListener).toHaveBeenCalledTimes(1);
expect(secondListener).toHaveBeenCalledTimes(1);
first.unsubscribe();
expect(watcher.records[0]?.unsubscribe).not.toHaveBeenCalled();
second.unsubscribe();
await vi.waitFor(() => {
expect(watcher.records[0]?.unsubscribe).toHaveBeenCalledTimes(1);
});
service.dispose();
});
test("a second workspace client adds no observation or Git work", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
runGitCommand,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(runGitCommand).toHaveBeenCalledTimes(2);
expect(watcher.records.filter((record) => record.directory === REPO_CWD)).toHaveLength(1);
expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(1);
first.unsubscribe();
second.unsubscribe();
service.dispose();
});
test("an observer abandoned during async setup is closed", async () => {
const watcher = createWatcherHarness();
const openedSubscription = createDeferred<{ unsubscribe: () => Promise<void> }>();
const unsubscribeWatcher = vi.fn(async () => {});
watcher.subscribe.mockImplementationOnce(async () => openedSubscription.promise);
const service = createService(watcher);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
});
subscription.unsubscribe();
openedSubscription.resolve({
updateIgnore: vi.fn(async () => {}),
unsubscribe: unsubscribeWatcher,
});
await vi.waitFor(() => {
expect(unsubscribeWatcher).toHaveBeenCalledTimes(1);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(0);
});
service.dispose();
});
test("a tracked edit refreshes summary and active uncommitted diff without structural or forge work", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
let diffStat = { additions: 1, deletions: 0 };
let diffFile = { path: "tracked.txt", additions: 1, deletions: 0, status: "modified" };
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { isDirty: true }),
);
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getPullRequestStatus = vi.fn();
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [diffFile] }));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
await service.getSnapshot(REPO_CWD);
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
REPO_CWD,
expect.any(Function),
expect.any(Object),
);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
diffStat = { additions: 4, deletions: 2 };
diffFile = { path: "tracked.txt", additions: 4, deletions: 2, status: "modified" };
watcher.records[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(summaryListener).toHaveBeenLastCalledWith(
expect.objectContaining({
git: expect.objectContaining({
isDirty: true,
diffStat: { additions: 4, deletions: 2 },
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 4, deletions: 2, status: "modified" }],
error: null,
});
});
await flushPromises();
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).not.toHaveBeenCalled();
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
test("worktree events invalidate uncommitted diffs and metadata events invalidate both", async () => {
const watcher = createWatcherHarness();
const projectionVersions = { uncommitted: 0, base: 0 };
const getCheckoutDiff = vi.fn(
async (_cwd: string, options: { mode: "uncommitted" | "base" }) => {
projectionVersions[options.mode] += 1;
return { diff: `${options.mode}-${projectionVersions[options.mode]}`, structured: [] };
},
);
const service = createService(watcher, { getCheckoutDiff });
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
const initialBase = await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(initialUncommitted.diff).toBe("uncommitted-1");
expect(initialBase.diff).toBe("base-1");
watcher.records
.find((record) => record.directory === REPO_CWD)
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
const changedUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
const cachedBase = await service.getCheckoutDiff(REPO_CWD, { mode: "base", baseRef: "main" });
expect(changedUncommitted.diff).toBe("uncommitted-2");
expect(cachedBase.diff).toBe("base-1");
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]);
const changedByMetadata = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" });
const changedBase = await service.getCheckoutDiff(REPO_CWD, {
mode: "base",
baseRef: "main",
});
expect(changedByMetadata.diff).toBe("uncommitted-3");
expect(changedBase.diff).toBe("base-2");
subscription.unsubscribe();
service.dispose();
});
test("metadata refreshes notify ref-dependent consumers when the summary is unchanged", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
const service = createService(watcher, { getCheckoutSnapshotFacts });
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
listener.mockClear();
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
});
expect(listener).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
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: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: "origin/main",
}));
const getCheckoutRefDerivedState = vi.fn(async (_cwd, facts, current) => ({
...current,
upstreamStatus: facts.upstreamStatus,
}));
const 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 service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
baseRef: "main",
hasRemote: true,
remoteUrl: REMOTE_URL,
}),
),
hasOriginRemote: vi.fn(async () => true),
runGitFetch,
});
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await service.getSnapshot(REPO_CWD);
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(1);
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 vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
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: REMOTE_URL,
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: REMOTE_URL,
}));
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: REMOTE_URL,
}));
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: REMOTE_URL,
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: REMOTE_URL,
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 runGitFetch = vi.fn(async () => ({
changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }],
error: null,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
getCheckoutStatus: vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
baseRef: "main",
hasRemote: true,
remoteUrl: REMOTE_URL,
}),
),
hasOriginRemote: vi.fn(async () => true),
runGitFetch,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await service.getSnapshot(REPO_CWD);
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(1);
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
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: REMOTE_URL,
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: REMOTE_URL,
}),
),
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 workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await fetchSnapshotRead.promise;
// Observation setup also reads facts. Wait for the initial snapshot to be
// published before injecting an event that must produce a second refresh.
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.currentBranch).toBe("feature");
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
});
const [repoWatcher] = getWatcherRecordsForDirectory(watcher, GIT_DIR);
if (!repoWatcher) throw new Error("Repository watcher was not registered");
repoWatcher.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), 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("origin/main refreshes a main checkout without configured upstream", async () => {
const watcher = createWatcherHarness();
const releaseInitialFacts = createDeferred<void>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
await releaseInitialFacts.promise;
return {
...createCheckoutFacts(cwd),
currentBranch: "main",
remoteUrl: REMOTE_URL,
resolvedBaseRef: "main",
comparisonBaseRef: null,
branchRemoteName: null,
branchMergeRef: null,
upstreamStatus: null,
};
});
const runGitFetch = vi.fn(async () => ({
changes: [],
nonRemoteRefsChanged: false,
error: null,
}));
const service = createService(watcher, { getCheckoutSnapshotFacts, runGitFetch });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
releaseInitialFacts.resolve();
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().fetchInFlightCount).toBe(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
const repoWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]!;
repoWatcher.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));
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { currentBranch: path.basename(cwd) }),
);
const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus });
const first = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn());
const second = service.registerWorkspace({ cwd: WORKTREE_B }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 1,
repositoryWorkspaceLinkCount: 2,
workspaceObservationSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
});
});
const repoWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
expect(repoWatcher).toBeDefined();
getCheckoutStatus.mockClear();
repoWatcher?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs.lock"), type: "create" }]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
expect(getCheckoutStatus).not.toHaveBeenCalled();
repoWatcher?.callback(null, [
{
path: path.join(createLinkedCheckoutFacts(WORKTREE_A).absoluteGitDir, "COMMIT_EDITMSG"),
type: "update",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
expect(getCheckoutStatus).not.toHaveBeenCalled();
repoWatcher?.callback(null, [
{
path: path.join(createLinkedCheckoutFacts(WORKTREE_A).absoluteGitDir, "index"),
type: "update",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A]);
});
getCheckoutStatus.mockClear();
repoWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "heads", "main"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort());
});
first.unsubscribe();
second.unsubscribe();
service.dispose();
});
test("routes the main checkout index to the main checkout only", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
cwd === REPO_CWD ? createCheckoutFacts(cwd) : createLinkedCheckoutFacts(cwd),
);
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus });
const main = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const linked = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 1,
repositoryWorkspaceLinkCount: 2,
workspaceObservationSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
});
});
getCheckoutStatus.mockClear();
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "index"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus)).toEqual([REPO_CWD]);
});
main.unsubscribe();
linked.unsubscribe();
service.dispose();
});
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);
const branch = path.basename(cwd);
return {
...facts,
currentBranch: branch,
storedBaseRef: "refs/heads/main",
resolvedBaseRef: "refs/heads/main",
comparisonBaseRef: "refs/heads/main",
branchRemoteName: "origin",
branchMergeRef: `refs/heads/${branch}`,
upstreamStatus: null,
} satisfies CheckoutSnapshotFacts;
});
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { currentBranch: path.basename(cwd) }),
);
const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus });
const first = service.registerWorkspace({ cwd: WORKTREE_A }, vi.fn());
const second = service.registerWorkspace({ cwd: WORKTREE_B }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 1,
repositoryWorkspaceLinkCount: 2,
workspaceObservationSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
});
});
getCheckoutStatus.mockClear();
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{
path: path.join(GIT_DIR, "refs", "remotes", "origin", path.basename(WORKTREE_A)),
type: "create",
},
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort());
});
first.unsubscribe();
second.unsubscribe();
service.dispose();
});
test("repository metadata observation ignores root and pruned-directory noise", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutStatus });
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
getCheckoutSnapshotFacts.mockClear();
getCheckoutStatus.mockClear();
listener.mockClear();
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: GIT_DIR, type: "update" },
{ path: path.join(GIT_DIR, "objects", "pack", "temporary.pack"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled();
expect(getCheckoutStatus).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
subscription.unsubscribe();
service.dispose();
});
test("metadata-only changes refresh worktree summary and active uncommitted diff", async () => {
const watcher = createWatcherHarness();
let isDirty = true;
let diffStat = { additions: 3, deletions: 1 };
let diffFiles = [{ path: "tracked.txt", additions: 3, deletions: 1, status: "modified" }];
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles }));
const service = createService(watcher, {
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const summaryListener = vi.fn();
const diffListener = vi.fn();
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, summaryListener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
isDirty = false;
diffStat = { additions: 0, deletions: 0 };
diffFiles = [];
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "index"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(summaryListener).toHaveBeenLastCalledWith(
expect.objectContaining({
git: expect.objectContaining({
isDirty: false,
diffStat: { additions: 0, deletions: 0 },
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [],
error: null,
});
});
diffSubscription.unsubscribe();
summarySubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
test("base diff projections follow metadata but ignore volatile worktree updates", async () => {
const watcher = createWatcherHarness();
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: [] }));
const getCheckoutWorktreeState = vi.fn(async () => ({
isDirty: true,
diffStat: { additions: 4, deletions: 2 },
}));
const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState });
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "base", baseRef: "main" } },
vi.fn(),
);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
.find((record) => record.directory === REPO_CWD)
?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await flushPromises();
expect(getCheckoutDiff).toHaveBeenCalledTimes(1);
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [{ path: path.join(GIT_DIR, "packed-refs"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(150);
await vi.waitFor(() => {
expect(getCheckoutDiff).toHaveBeenCalledTimes(2);
});
diffSubscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
test("an event burst during an in-flight refresh produces one final follow-up", async () => {
const watcher = createWatcherHarness();
const firstRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.mockResolvedValueOnce(createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(() => firstRefresh.promise)
.mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true }));
const getCheckoutShortstat = vi
.fn()
.mockResolvedValueOnce({ additions: 0, deletions: 0 })
.mockResolvedValueOnce({ additions: 1, deletions: 0 })
.mockResolvedValue({ additions: 100, deletions: 25 });
const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat });
const listener = vi.fn();
await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const edit = { path: path.join(REPO_CWD, "tracked.txt"), type: "update" as const };
watcher.records[0]?.callback(null, [edit]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
});
for (let event = 0; event < 100; event += 1) {
watcher.records[0]?.callback(null, [edit]);
}
await vi.advanceTimersByTimeAsync(1_000);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1);
firstRefresh.resolve(createCheckoutStatus(REPO_CWD, { isDirty: true }));
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
git: expect.objectContaining({
isDirty: true,
diffStat: { additions: 100, deletions: 25 },
}),
}),
);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
subscription.unsubscribe();
service.dispose();
});
test("an event queued during a failed refresh still reaches final state", async () => {
const watcher = createWatcherHarness();
const failedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.mockResolvedValueOnce(createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(() => failedRefresh.promise)
.mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true }));
const getCheckoutShortstat = vi
.fn()
.mockResolvedValueOnce({ additions: 0, deletions: 0 })
.mockResolvedValue({ additions: 42, deletions: 7 });
const service = createService(watcher, { getCheckoutStatus, getCheckoutShortstat });
const listener = vi.fn();
await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const edit = { path: path.join(REPO_CWD, "tracked.txt"), type: "update" as const };
watcher.records[0]?.callback(null, [edit]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
});
watcher.records[0]?.callback(null, [edit]);
await vi.advanceTimersByTimeAsync(1_000);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(1);
failedRefresh.reject(new Error("transient Git read failure"));
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
git: expect.objectContaining({
isDirty: true,
diffStat: { additions: 42, deletions: 7 },
}),
}),
);
subscription.unsubscribe();
service.dispose();
});
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(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}`),
);
const getCheckoutSnapshotFacts = vi.fn(
async (cwd: string): Promise<CheckoutSnapshotFacts> => ({
...createCheckoutFacts(cwd),
remoteUrl: "https://github.com/acme/shared.git",
absoluteGitDir: path.join(commonGitDir, "worktrees", path.basename(cwd)),
gitCommonDir: commonGitDir,
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const runGitCommand = vi.fn(async (_args: string[], options: { cwd: string }) => ({
stdout: `${options.cwd}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
runGitCommand,
runGitFetch,
});
const subscriptions = worktrees.map((cwd) => service.registerWorkspace({ cwd }, vi.fn()));
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
});
expect(service.getMetrics()).toMatchObject({
workspaceTargetCount: 10,
repositoryTargetCount: 1,
repositoryWorkspaceLinkCount: 10,
workingTreeWatchTargetCount: 10,
});
expect(watcher.records.filter((record) => record.directory === commonGitDir)).toHaveLength(1);
expect(watcher.records.filter((record) => worktrees.includes(record.directory))).toHaveLength(
10,
);
subscriptions[0]?.unsubscribe();
fetch.resolve();
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(180_000);
await vi.waitFor(() => {
expect(runGitFetch).toHaveBeenCalledTimes(2);
});
expect(runGitFetch).toHaveBeenLastCalledWith(
worktrees[1],
expect.anything(),
expect.anything(),
);
for (const subscription of subscriptions.slice(1)) {
subscription.unsubscribe();
}
service.dispose();
});
test("watcher setup failure uses scoped non-overlapping polling", async () => {
const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) });
const blockedPoll = createDeferred<CheckoutStatusGit>();
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.mockResolvedValueOnce(createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(() => blockedPoll.promise)
.mockResolvedValue(createCheckoutStatus(REPO_CWD, { isDirty: true }));
const getPullRequestStatus = vi.fn();
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getPullRequestStatus,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
REPO_CWD,
expect.any(Function),
expect.any(Object),
);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(7_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
});
await vi.advanceTimersByTimeAsync(15_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).not.toHaveBeenCalled();
blockedPoll.resolve(createCheckoutStatus(REPO_CWD, { isDirty: true }));
await vi.waitFor(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
});
subscription.unsubscribe();
service.dispose();
});
test("degraded polling backs off while the snapshot is unchanged and resets on a change", async () => {
// A repository large enough to defeat the recursive watcher makes every degraded refresh
// expensive, so a fixed cadence keeps the daemon shelling out Git on an untouched workspace.
const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) });
let isDirty = false;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const service = createService(watcher, { getCheckoutStatus });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
});
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
for (const [elapsedMs, expectedCalls] of [
[5_000, 2],
[10_000, 3],
[20_000, 4],
[40_000, 5],
[60_000, 6],
[60_000, 7],
] as const) {
await vi.advanceTimersByTimeAsync(elapsedMs);
expect(getCheckoutStatus).toHaveBeenCalledTimes(expectedCalls);
}
// A fixed 5s cadence would have run 39 polls over the same 195s.
expect(getCheckoutStatus.mock.calls.length).toBeLessThan(10);
// A real change snaps the loop back to the base interval.
isDirty = true;
await vi.advanceTimersByTimeAsync(60_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(8);
await vi.advanceTimersByTimeAsync(5_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(9);
subscription.unsubscribe();
service.dispose();
});
test("degraded repository-metadata polling backs off and resets on a change", async () => {
// Only the Git-directory watcher fails, so the metadata fallback is the one poll loop running.
const watcher = createWatcherHarness({ failDirectories: new Set([GIT_DIR]) });
let isDirty = false;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const service = createService(watcher, { getCheckoutStatus });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
GIT_DIR,
expect.any(Function),
expect.any(Object),
);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
const callsBeforePolling = getCheckoutStatus.mock.calls.length;
function pollCount(): number {
return getCheckoutStatus.mock.calls.length - callsBeforePolling;
}
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
for (const [elapsedMs, expectedPolls] of [
[5_000, 1],
[10_000, 2],
[20_000, 3],
[40_000, 4],
[60_000, 5],
[60_000, 6],
] as const) {
await vi.advanceTimersByTimeAsync(elapsedMs);
await vi.waitFor(() => {
expect(pollCount()).toBe(expectedPolls);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
}
isDirty = true;
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => expect(pollCount()).toBe(7));
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => expect(pollCount()).toBe(8));
subscription.unsubscribe();
service.dispose();
});
test("non-Git fallback promotes an externally initialized checkout", async () => {
const watcher = createWatcherHarness();
let isGit = false;
const getCheckoutSnapshotFacts = vi.fn(
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
isGit ? createCheckoutFacts(cwd) : { isGit: false },
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
isGit ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
);
const runGitCommand = vi.fn(async (args: string[]) => {
if (args[0] === "rev-parse") {
if (!isGit) {
throw new Error("not a git repository");
}
return {
stdout: `${REPO_CWD}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
}
if (args[0] === "ls-files") {
return {
stdout: "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
}
throw new Error(`Unexpected Git command: ${args.join(" ")}`);
});
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 60_000,
runGitCommand,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(false);
expect(service.getMetrics()).toMatchObject({
workspaceObservationSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
});
});
expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(0);
isGit = true;
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(true);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 1,
repositoryWorkspaceLinkCount: 1,
workspaceObservationSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
});
});
expect(watcher.records.filter((record) => record.directory === GIT_DIR)).toHaveLength(1);
expect(watcher.records.filter((record) => record.directory === REPO_CWD)).toHaveLength(1);
const factsCallCount = getCheckoutSnapshotFacts.mock.calls.length;
await vi.advanceTimersByTimeAsync(5_000);
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(factsCallCount);
summarySubscription.unsubscribe();
service.dispose();
});
test("watcher runtime error is closed, counted, and switches to scoped polling", async () => {
const watcher = createWatcherHarness();
let ignoredDirectories = "node_modules/\n";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd));
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
runGitCommand,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
expect(checkoutWatcher).toBeDefined();
checkoutWatcher?.callback(new Error("watcher stopped"), []);
expect(checkoutWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
ignoredDirectories = "node_modules/\nbuild/\n";
await vi.advanceTimersByTimeAsync(29_000);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
});
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[1];
expect(recoveredWatcher?.ignore).toContain(path.join(REPO_CWD, "build"));
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
recoveredWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "recovered.txt"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRecovery + 1);
});
const statusCallsAfterEvent = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(10_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterEvent);
subscription.unsubscribe();
service.dispose();
});
test("repository watcher canary timeout closes the subscription and starts fallback polling", async () => {
const watcher = createWatcherHarness();
const logger = createLogger();
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const verifyCanary = vi.fn(async () => {
throw new Error("watcher did not report its liveness canary");
});
const service = createService(
watcher,
{
getCheckoutStatus,
createWatcherLivenessCanary: vi.fn(() => ({
path: path.join(GIT_DIR, "paseo", ".watcher-canary-timeout"),
filterEvents: (events: WatchEvent[]) => events,
verify: verifyCanary,
})),
},
logger,
);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0];
expect(repositoryWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ repoGitRoot: GIT_DIR }),
"Failed to start repository metadata watcher; using degraded polling",
);
const statusCallsBeforePoll = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsBeforePoll);
});
subscription.unsubscribe();
service.dispose();
});
test("setup-time watcher error defers recovery until subscribe settles", async () => {
const watcher = createWatcherHarness();
const openedSubscription = createDeferred<{
updateIgnore: (paths: string[]) => Promise<void>;
unsubscribe: () => Promise<void>;
}>();
const erroredUnsubscribe = vi.fn(async () => {});
watcher.subscribe.mockImplementationOnce(async (_directory, callback) => {
callback(new Error("watcher stopped during setup"), []);
return openedSubscription.promise;
});
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(35_000);
await vi.waitFor(() => {
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
});
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
openedSubscription.resolve({
updateIgnore: vi.fn(async () => {}),
unsubscribe: erroredUnsubscribe,
});
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const statusCallsAfterSetup = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(29_000);
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(2);
});
expect(erroredUnsubscribe).toHaveBeenCalledTimes(1);
// Recovery schedules a debounced refresh. Advance the fake clock past the debounce
// explicitly; vi.waitFor alone runs out of real time before it on a slow runner.
await vi.advanceTimersByTimeAsync(2_000);
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
subscription.unsubscribe();
service.dispose();
});
test("watcher error during subscription setup closes the terminal observer", async () => {
const watcher = createWatcherHarness();
const erroredUnsubscribe = vi.fn(async () => {});
const updateIgnore = vi.fn(async () => {});
watcher.subscribe.mockImplementationOnce(async (_directory, callback) => {
callback(new Error("watcher stopped during setup"), []);
return { updateIgnore, unsubscribe: erroredUnsubscribe };
});
const service = createService(watcher);
const subscription = await service.requestWorkingTreeWatch(REPO_CWD, vi.fn());
expect(erroredUnsubscribe).toHaveBeenCalledTimes(1);
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
subscription.unsubscribe();
service.dispose();
});
test("repository watcher runtime error is closed, counted, and recovered", async () => {
const watcher = createWatcherHarness();
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const repositoryWatcher = watcher.records.find((record) => record.directory === GIT_DIR);
expect(repositoryWatcher).toBeDefined();
repositoryWatcher?.callback(new Error("repository watcher stopped"), []);
expect(repositoryWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
expect(service.getMetrics().watcherErrorCallbackCount).toBe(1);
const statusCallsBeforeReconciliation = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsBeforeReconciliation + 1);
});
await vi.advanceTimersByTimeAsync(29_000);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(2);
});
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[1];
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
recoveredWatcher?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRecovery + 1);
});
const statusCallsAfterEvent = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(10_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterEvent);
subscription.unsubscribe();
service.dispose();
});
test("watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => {
const watcher = createWatcherHarness();
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
checkoutWatcher?.callback(new Error("watcher stopped"), []);
await driveWatchRecoveryLadder(watcher, REPO_CWD);
// Past the old cap of 3 recovery attempts (4 total subscriptions), the
// service keeps retrying instead of giving up permanently.
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1,
);
const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(300_000);
await vi.waitFor(() => {
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2,
);
});
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder);
subscription.unsubscribe();
service.dispose();
});
test("repository watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => {
const watcher = createWatcherHarness();
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]?.callback(
new Error("repository watcher stopped"),
[],
);
await driveWatchRecoveryLadder(watcher, GIT_DIR);
// Past the old cap of 3 recovery attempts (4 total subscriptions), the
// service keeps retrying instead of giving up permanently.
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1,
);
const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(300_000);
await vi.waitFor(() => {
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2,
);
});
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder);
subscription.unsubscribe();
service.dispose();
});
test("non-Git discovery polling survives watcher recovery", async () => {
const watcher = createWatcherHarness();
const getCheckoutSnapshotFacts = vi.fn(
async (): Promise<CheckoutSnapshotFacts> => ({
isGit: false,
}),
);
const getCheckoutStatus = vi.fn(async () => ({ isGit: false }) as const);
const runGitCommand = vi.fn(async () => {
throw new Error("not a git repository");
});
const service = createService(watcher, {
getCheckoutSnapshotFacts,
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
checkoutWatcher?.callback(new Error("watcher stopped"), []);
await vi.advanceTimersByTimeAsync(30_000);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
const statusCallsAfterRecovery = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterRecovery);
});
subscription.unsubscribe();
service.dispose();
});
test("adding ignored directories updates filtering without replacing the watcher", async () => {
const watcher = createWatcherHarness();
let ignoredDirectories = "node_modules/\n";
const runGitCommand = vi.fn(async (args: string[]) => {
return {
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "node_modules"));
ignoredDirectories = "node_modules/\nbuild/\n";
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
await vi.waitFor(() => {
expect(runGitCommand).toHaveBeenCalledTimes(3);
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(checkoutWatcher?.unsubscribe).not.toHaveBeenCalled();
const statusCallsAfterRefresh = getCheckoutStatus.mock.calls.length;
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "build", "output.js"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(2_000);
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh);
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" }]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh + 1);
});
subscription.unsubscribe();
service.dispose();
});
test("pruneKnownDirectories case-folds a Windows-shaped watch root", () => {
// `pruneKnownDirectories` only needs to fold case when the watch root
// *looks* like a Windows path (drive letter or UNC) — see
// `looksLikeDefiniteWindowsPath` in `../utils/path.js`, the same rule
// `isPathInsideRoot`/`isRealpathInsideRoot` used before this method was
// rewritten from realpath comparisons to string prefixes (see the
// class-level comment on `pruneKnownDirectories`). macOS's real
// filesystem is already case-insensitive, so a test built from real
// directories on disk would pass whether or not that fold logic
// exists — a synthetic Windows-shaped root exercises the comparison
// logic itself instead of relying on host filesystem behavior.
//
// Driven directly against the private target rather than through
// `registerWorkspace`, for two independent reasons, both checked before
// settling on this shape:
//
// 1. Reachability: `registerWorkspace` resolves `cwd` through
// `node:path`'s `resolve()` before anything else runs, and on this
// POSIX test host that rewrites a drive-letter path into an ordinary
// POSIX one, destroying the shape this test depends on. This step can
// be routed around — mocking `git rev-parse --show-toplevel`'s stdout
// lets `target.watchPath` carry the raw Windows-shaped string, since
// `parseGitRevParsePath` does not call `resolve()` — but
// `loadIgnoredDirs` then calls `resolve(rootPath, rel)` on that same
// watch path to build `ignoredDirectories`, which on POSIX prefixes it
// with `process.cwd()` and breaks the shared prefix the fold logic
// compares against. Two independent `resolve()` calls, each assuming
// host-platform semantics, block the public path from both sides.
// 2. Observability: even granting reachability, `knownDirectories` has
// exactly one reader outside this method — the Set-lookup fast path in
// `noteWorkingTreeDirectories` — and that method's fallback branch
// re-derives ignored-ness via `isPathInsideRoot`, which already folds
// case correctly on its own. Verified by deleting this method's fold
// logic (forcing `foldCase = false`) and running this file plus the
// integration suite: only this test failed. No public-surface
// assertion distinguishes a correctly pruned directory from one left
// stale, because the redundant check downstream produces the same
// outcome either way.
const service = createService(createWatcherHarness());
const target = {
watchPath: "C:/Users/dev/Repo",
knownDirectories: new Set<string>(["C:/Users/dev/Repo/Deps/package-a"]),
ignoredDirectories: new Set<string>(["C:/Users/dev/Repo/deps"]),
};
(
service as unknown as { pruneKnownDirectories: (t: typeof target) => void }
).pruneKnownDirectories(target);
expect(target.knownDirectories.has("C:/Users/dev/Repo/Deps/package-a")).toBe(false);
service.dispose();
});
test("a previously unseen directory refreshes the ignore set", async () => {
const watcher = createWatcherHarness();
// A fresh worktree has no ignored directories on disk yet.
let ignoredDirectories = "";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
expect(checkoutWatcher?.ignore).not.toContain(path.join(REPO_CWD, "deps"));
// Dependencies get installed. No .gitignore is touched.
ignoredDirectories = "deps/\n";
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith(
expect.arrayContaining([path.join(REPO_CWD, "deps")]),
);
});
subscription.unsubscribe();
service.dispose();
});
test("a failed ignore refresh does not permanently mark a directory known", async () => {
// Regression for the failure path reintroducing the original #4558 bug:
// `noteWorkingTreeDirectories` marks a newly seen directory known and
// schedules a debounced refresh, but `loadIgnoredDirs` deliberately keeps
// the previous ignore set when `git ls-files` fails (a transient failure
// must not un-ignore the whole dependency tree). If that success/failure
// distinction is not carried back, the directory stays "known" forever
// with the fast-path Set lookup in `noteWorkingTreeDirectories` skipping
// it on every later event — so no refresh is ever attempted again and
// the directory is watched forever, same as before #4558 was fixed.
const watcher = createWatcherHarness();
let ignoredDirectories = "";
let lsFilesCallCount = 0;
const runGitCommand = vi.fn(async (args: string[]) => {
if (args[0] === "rev-parse") {
return { stdout: `${REPO_CWD}\n`, stderr: "", truncated: false, exitCode: 0, signal: null };
}
lsFilesCallCount += 1;
// The seed load at registration (call 1) succeeds. The refresh
// triggered by the newly discovered "deps" directory (call 2) fails
// transiently.
if (lsFilesCallCount === 2) {
throw new Error("git ls-files timed out");
}
return {
stdout: ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
expect(lsFilesCallCount).toBe(1);
// Dependencies get installed. No .gitignore is touched, so this only
// schedules a refresh via directory discovery — the one wired to fail.
ignoredDirectories = "deps/\n";
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount).toBe(2);
});
// The failed refresh must not have applied "deps" to the live watcher.
expect(checkoutWatcher?.updateIgnore).not.toHaveBeenCalled();
// A later write under the same directory. If the directory were left
// permanently "known" by the failed refresh, `noteWorkingTreeDirectories`
// would silently skip it here and no second refresh would ever happen.
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "deps", "package-a", "index2.js"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount).toBe(3);
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith(
expect.arrayContaining([path.join(REPO_CWD, "deps")]),
);
});
subscription.unsubscribe();
service.dispose();
});
test("repeat writes in known directories do not re-run git ls-files", async () => {
const watcher = createWatcherHarness();
let ignoredDirectories = "";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
// Registration itself performs one ls-files call to seed the ignore set. Measure the
// burst's effect as a delta from this baseline, not an absolute count, so the assertion
// cannot pass vacuously off of setup's own call.
const lsFilesAtSetup = runGitCommand.mock.calls.filter(
(call) => call[0][0] === "ls-files",
).length;
// One burst in a new directory: one debounced refresh, not one per event.
ignoredDirectories = "deps/\n";
for (let index = 0; index < 500; index += 1) {
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "deps", `module-${index}.js`), type: "create" },
]);
}
await vi.advanceTimersByTimeAsync(3_000);
const lsFilesAfterBurst = runGitCommand.mock.calls.filter(
(call) => call[0][0] === "ls-files",
).length;
// The burst must have refreshed the ignore set at least once...
expect(lsFilesAfterBurst - lsFilesAtSetup).toBeGreaterThanOrEqual(1);
// ...but debounced into at most 2 calls, not one per event. (The brief's original absolute
// bound was <= 3 total calls with a baseline of 1 from setup, i.e. at most 2 burst-triggered
// refreshes — preserve that numeric intent here as a delta.)
expect(lsFilesAfterBurst - lsFilesAtSetup).toBeLessThanOrEqual(2);
// The directory is known now. Further writes trigger nothing.
for (let index = 0; index < 200; index += 1) {
checkoutWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "src", `file-${index}.ts`), type: "update" },
]);
}
await vi.advanceTimersByTimeAsync(3_000);
const lsFilesAtEnd = runGitCommand.mock.calls.filter(
(call) => call[0][0] === "ls-files",
).length;
expect(lsFilesAtEnd).toBeLessThanOrEqual(lsFilesAfterBurst + 1);
subscription.unsubscribe();
service.dispose();
});
test("a deleted known directory is dropped, so a later event under it re-runs git ls-files", async () => {
// Regression for the finding that `knownDirectories` only ever grows:
// nothing previously removed an entry once its directory was deleted
// from disk, so a long-lived daemon over a churning tree (deps
// installed and removed, build output cycling) would accumulate
// tombstones for the life of the target and slow every future
// `pruneKnownDirectories` pass. `removeDeletedKnownDirectories` must
// drop a directory (and anything nested under it) once a `delete`
// event reports the directory path itself — proven here by making a
// write reappear under the same path afterward and asserting it costs
// a fresh `git ls-files` refresh rather than being silently skipped by
// the known-directory fast path.
const watcher = createWatcherHarness();
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
const lsFilesCallCount = () =>
runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length;
const lsFilesAtSetup = lsFilesCallCount();
const depsDir = path.join(REPO_CWD, "deps");
// First sight of `deps`: marks it known and schedules one debounced refresh.
checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index.js"), type: "create" }]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup);
});
const lsFilesAfterDepsDiscovery = lsFilesCallCount();
// A nested directory under `deps` becomes known too. A directory delete
// does not guarantee a delete event for everything beneath it, so
// removal must drop this along with `deps` itself.
const nestedDir = path.join(depsDir, "nested");
checkoutWatcher?.callback(null, [{ path: path.join(nestedDir, "index.js"), type: "create" }]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsDiscovery);
});
const lsFilesAfterNestedDiscovery = lsFilesCallCount();
// Steady state: another write in a known directory triggers nothing.
checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index2.js"), type: "update" }]);
await vi.advanceTimersByTimeAsync(2_000);
expect(lsFilesCallCount()).toBe(lsFilesAfterNestedDiscovery);
// `deps` (the parent) is deleted from disk. The watcher reports the
// directory path itself, not just files under it.
checkoutWatcher?.callback(null, [{ path: depsDir, type: "delete" }]);
// A write reappears directly under `deps`. If the delete had not
// dropped it from `knownDirectories`, the known-directory fast path
// would skip this event and no refresh would run.
checkoutWatcher?.callback(null, [
{ path: path.join(depsDir, "reinstalled.js"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterNestedDiscovery);
});
const lsFilesAfterDepsRediscovery = lsFilesCallCount();
// The formerly nested `deps/nested` must have been dropped too, even
// though only `deps` itself received a delete event.
checkoutWatcher?.callback(null, [
{ path: path.join(nestedDir, "reinstalled.js"), type: "create" },
]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsRediscovery);
});
subscription.unsubscribe();
service.dispose();
});
test("a large event batch refreshes ignored roots without scanning every new directory", async () => {
const watcher = createWatcherHarness();
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
const lsFilesCallCount = () =>
runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length;
const lsFilesAtSetup = lsFilesCallCount();
// Establish one directory as known and steady before the flood.
const markerDir = path.join(REPO_CWD, "marker");
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file.js"), type: "create" }]);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup);
});
const lsFilesAfterMarker = lsFilesCallCount();
// Steady state: another write under the marker directory triggers nothing.
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file2.js"), type: "update" }]);
await vi.advanceTimersByTimeAsync(2_000);
expect(lsFilesCallCount()).toBe(lsFilesAfterMarker);
// A single discovery refreshes Git's complete ignored-directory inventory.
// The other directories in this batch need not enter the known cache.
const floodEvents = Array.from({ length: 50_000 }, (_, index) => ({
path: path.join(REPO_CWD, "gen", `d${index}`, "file.js"),
type: "create" as const,
}));
checkoutWatcher?.callback(null, floodEvents);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterMarker);
});
const lsFilesAfterFlood = lsFilesCallCount();
// The flood must not evict an unrelated known directory by processing
// every entry before the cap is checked. Its later edit stays on the
// cache fast path and does not trigger a redundant Git refresh.
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file3.js"), type: "create" }]);
await vi.advanceTimersByTimeAsync(2_000);
expect(lsFilesCallCount()).toBe(lsFilesAfterFlood);
subscription.unsubscribe();
service.dispose();
});
test("watcher recovery keeps retrying after repeated failures", async () => {
// The initial subscribe must succeed so the target starts out healthy; only subscribe
// attempts made *after* that (i.e. recovery attempts) should fail. `failDirectories` is
// read live on every `subscribe()` call, so mutating it after registration is enough —
// no need to fork the harness.
const failDirectories = new Set<string>();
const watcher = createWatcherHarness({ failDirectories });
let ignoredDirectories = "node_modules/\n";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, {
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
const subscribeCallsBeforeFailure = getWatcherSubscribeCallCount(watcher, REPO_CWD);
// From here on, every further subscribe attempt for this directory fails — a watcher that
// cannot be re-established (permission error, unmounted volume, etc).
failDirectories.add(REPO_CWD);
// Force the live subscription to fail and enter recovery. A rejected updateIgnore is the
// most direct trigger: it nulls target.subscription and schedules recovery.
checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed"));
ignoredDirectories = "node_modules/\nbuild/\n";
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
// Recovery backs off at WATCH_RECOVERY_BASE_DELAY_MS * 2**(attempt-1) with base 30_000ms,
// landing at 30s, 60s, 120s, 240s for the first 4 attempts (WATCH_RECOVERY_MAX_BACKOFF_STEPS
// caps the exponent at 4, and WATCH_RECOVERY_MAX_DELAY_MS's 300s ceiling doesn't bind until
// the 5th). A 4th recovery attempt needs the first four delays to have elapsed:
// 30_000 + 60_000 + 120_000 + 240_000 = 450_000ms. Advance past that with headroom.
await vi.advanceTimersByTimeAsync(500_000);
const subscribeCallsAfterRecoveryWindow = getWatcherSubscribeCallCount(watcher, REPO_CWD);
// 1 initial success + 3 capped recovery attempts = 4 total. A 5th call would prove recovery
// keeps retrying past the hard cap instead of giving up on this target forever.
expect(subscribeCallsAfterRecoveryWindow).toBeGreaterThan(subscribeCallsBeforeFailure + 3);
subscription.unsubscribe();
service.dispose();
});
test("removing an ignored directory updates the watcher without replacement", async () => {
const watcher = createWatcherHarness();
let ignoredDirectories = "node_modules/\nbuild/\n";
const runGitCommand = vi.fn(async (args: string[]) => {
return {
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(watcher, {
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const originalWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
ignoredDirectories = "node_modules/\n";
originalWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
await vi.waitFor(() => {
expect(originalWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(originalWatcher?.unsubscribe).not.toHaveBeenCalled();
expect(originalWatcher?.ignore).not.toContain(path.join(REPO_CWD, "build"));
const statusCallsAfterRefresh = getCheckoutStatus.mock.calls.length;
originalWatcher?.callback(null, [
{ path: path.join(REPO_CWD, "build", "output.js"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(statusCallsAfterRefresh + 1);
});
subscription.unsubscribe();
service.dispose();
});
test("repository exclude changes update working-tree filtering", async () => {
const watcher = createWatcherHarness();
let ignoredDirectories = "node_modules/\n";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService(watcher, { runGitCommand });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0];
ignoredDirectories = "node_modules/\nbuild/\n";
repositoryWatcher?.callback(null, [
{ path: path.join(GIT_DIR, "info", "exclude"), type: "update" },
]);
await vi.waitFor(() => {
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
});
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build"));
subscription.unsubscribe();
service.dispose();
});
test("ignore events during a refresh trigger a trailing recomputation", async () => {
const watcher = createWatcherHarness();
const blockedRefresh = createDeferred<{
stdout: string;
stderr: string;
truncated: boolean;
exitCode: number;
signal: null;
}>();
let lsFilesCallCount = 0;
const runGitCommand = vi.fn(async (args: string[]) => {
if (args[0] === "rev-parse") {
return {
stdout: `${REPO_CWD}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
}
lsFilesCallCount += 1;
if (lsFilesCallCount === 2) return blockedRefresh.promise;
return {
stdout: lsFilesCallCount === 1 ? "node_modules/\n" : "node_modules/\nbuild/\n",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const service = createService(watcher, { runGitCommand });
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
await vi.waitFor(() => expect(lsFilesCallCount).toBe(2));
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
blockedRefresh.resolve({
stdout: "node_modules/\n",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
});
await vi.waitFor(() => expect(lsFilesCallCount).toBe(3));
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build"));
subscription.unsubscribe();
service.dispose();
});
test("ignore watcher update failure enters polling with a distinct reason", async () => {
const watcher = createWatcherHarness();
const logger = createLogger();
let ignoredDirectories = "node_modules/\nbuild/\n";
const runGitCommand = vi.fn(async (args: string[]) => ({
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService(
watcher,
{
getCheckoutStatus,
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
runGitCommand,
},
logger,
);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed"));
ignoredDirectories = "node_modules/\n";
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
await vi.waitFor(() => {
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "watcher_update_failed" }),
"Working tree watcher unavailable; using bounded polling fallback",
);
});
expect(service.getMetrics().watcherErrorCallbackCount).toBe(0);
const statusCallsBeforePoll = getCheckoutStatus.mock.calls.length;
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => {
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsBeforePoll);
});
await vi.advanceTimersByTimeAsync(25_000);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
});
subscription.unsubscribe();
service.dispose();
});
test("a working-tree watcher event refreshes summary and diff subscribers", async () => {
const watcher = createWatcherHarness();
let isDirty = false;
let diffStat = { additions: 0, deletions: 0 };
let diffFiles: Array<{
path: string;
additions: number;
deletions: number;
status: string;
}> = [];
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
const getCheckoutShortstat = vi.fn(async () => diffStat);
const getCheckoutDiff = vi.fn(async () => ({ diff: "", structured: diffFiles }));
const service = createService(watcher, {
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
});
const workspaceRuntimeStore = createWorkspaceRuntimeStore();
service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot));
const diffManager = new CheckoutDiffManager({
workspaceRuntimeStore: workspaceRuntimeStore,
logger: createLogger(),
paseoHome: "/tmp/paseo-home",
workspaceGitService: service,
});
const listener = vi.fn();
const diffListener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
const diffSubscription = await diffManager.subscribe(
{ cwd: REPO_CWD, compare: { mode: "uncommitted" } },
diffListener,
);
await vi.waitFor(() => {
expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull();
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
isDirty = true;
diffStat = { additions: 7, deletions: 3 };
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
git: expect.objectContaining({
isDirty: true,
diffStat: { additions: 7, deletions: 3 },
}),
}),
);
expect(diffListener).toHaveBeenLastCalledWith({
cwd: REPO_CWD,
files: [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }],
error: null,
});
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
diffSubscription.unsubscribe();
subscription.unsubscribe();
diffManager.dispose();
service.dispose();
});
});