diff --git a/packages/server/src/server/git-observation-paths.ts b/packages/server/src/server/git-observation-paths.ts new file mode 100644 index 0000000000..1ef0a4e571 --- /dev/null +++ b/packages/server/src/server/git-observation-paths.ts @@ -0,0 +1,106 @@ +import { realpath } from "node:fs"; +import { readlink } from "node:fs/promises"; +import { promisify } from "node:util"; +import pLimit from "p-limit"; +import { dirname, resolve as resolvePath, sep } from "node:path"; +import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js"; + +const nativeRealpath = promisify(realpath.native); +const jsRealpath = promisify(realpath); +const identityIo = pLimit(2); + +/** A subscription owns its lexical namespace, including the OS's real-root alias. */ +export class GitObservationPaths { + private constructor( + private readonly root: string, + private readonly aliases: string[], + private readonly windows: boolean, + ) {} + + static async create(root: string): Promise { + const windows = looksLikeDefiniteWindowsPath(root); + const normalizePath = (value: string) => + windows + ? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase() + : normalizePathPreservingCase(value, false); + const aliases = new Set([normalizePath(root)]); + // Sequential: foreign paths must not fan out into the shared libuv pool. + for (const resolve of [nativeRealpath, jsRealpath]) { + try { + aliases.add(normalizePath(await identityIo(() => resolve(root)))); + } catch { + /* Missing roots retain lexical identity. */ + } + } + // A link may name /var/... while realpath returns /private/var/... on macOS. + // Retain literal targets too, including chains, without following links in callbacks. + let current = root; + const visited = new Set(); + for (let remaining = 40; remaining > 0; remaining--) { + const alias = normalizePath(current); + if (visited.has(alias)) break; + visited.add(alias); + aliases.add(alias); + try { + const target = await identityIo(() => readlink(current)); + current = resolvePath(dirname(current), target); + } catch { + break; + } + } + return new GitObservationPaths(root, [...aliases], windows); + } + + relative(candidate: string): string | null { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + for (const alias of this.aliases) { + if (comparable === alias) return ""; + const prefix = alias.endsWith("/") ? alias : `${alias}/`; + if (comparable.startsWith(prefix)) { + // Slice the original spelling, preserving case in ref names on Windows. + return normalized.slice(prefix.length); + } + } + return null; + } + + // Compile excluded subtrees in every established root namespace. The event callback + // only compares strings; neither an ignored root nor an event needs another realpath. + createEventFilter(excludedRoots: Iterable): (candidate: string) => boolean { + const excluded: string[] = []; + for (const root of excludedRoots) { + const relative = this.relative(root); + if (relative === null) continue; + for (const alias of this.aliases) { + const subtree = relative ? `${alias}/${relative}` : alias; + excluded.push(this.windows ? subtree.toLowerCase() : subtree); + } + } + const prefixes = excluded.map((root) => `${root}/`); + return (candidate) => { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + if (this.aliases.includes(comparable)) return false; + for (let i = 0; i < excluded.length; i++) { + if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false; + } + return true; + }; + } + + private normalizeEventPath(candidate: string): string { + if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/"); + if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/")) + return normalizePathPreservingCase(candidate, false); + return candidate; + } + + matches = (candidate: string): boolean => this.relative(candidate) === ""; + + toWatchPath(candidate: string): string { + const relative = this.relative(candidate); + if (relative === null) return candidate; + return relative ? `${this.root}${sep}${relative}` : this.root; + } +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 66740d2ae2..1579f92d5f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex ); expect(created.worktree.branchName).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "pending", @@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex branchName: "renamed-from-agent-context", }); expect(branchAfterFirst).toBe("renamed-from-agent-context"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = .toString() .trim(), ).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); @@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren createDeps(), ); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 61e00723d0..4080efd580 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, targetWorktreePath: createdWorktree.worktree.worktreePath, @@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: false, renamed: false, branchName: null }; } - let metadata: ReturnType; + let metadata: Awaited>; try { - metadata = readPaseoWorktreeMetadata(options.cwd); + metadata = await readPaseoWorktreeMetadata(options.cwd); } catch { return { attempted: false, renamed: false, branchName: null }; } @@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: { const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch; const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName; if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); return { attempted: true, renamed: false, branchName: null }; } - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); const branchName = await options.generateBranchNameFromContext({ cwd: options.cwd, @@ -264,15 +264,15 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { +async function maybeMarkFirstAgentBranchAutoNameEligible(options: { createdWorktree: Awaited>; -}): void { +}): Promise { const { createdWorktree } = options; if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return; } - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { placeholderBranchName: createdWorktree.worktree.branchName, }); } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e4a0ca7668..3755ff56d8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async () execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" }); - writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { placeholderBranchName: "placeholder-branch", }); const workspaceCwd = path.join(repoDir, "packages", "app"); @@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () firstAgentContext: { prompt: "Fix checkout title" }, }); await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd])); expect(generateCalls).toBe(1); expect(stored.get(workspace.workspaceId)).toMatchObject({ @@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () .toString() .trim(), ).toBe("placeholder-branch"); - expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index f738a0b0d6..22e903b5f1 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { test("preserves ordinary checkout behavior when no separate base was recorded", async () => { const fixture = await createBaseRecoveryFixture(null); - writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); + await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); await fixture.archiveAndRemove(); await fixture.service.restore(fixture.workspace.workspaceId); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull(); @@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing", }); expect(existsSync(fixture.workspace.cwd)).toBe(true); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull(); - const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd); + const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd); expect(metadata).toMatchObject({ baseRefName: "main" }); expect(metadata?.baseRef).toBeUndefined(); expect( @@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base", await fixture.archiveAndRemove(); execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir }); await fixture.service.restore(fixture.workspace.workspaceId); - expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main"); + expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main"); expect( await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), ).toMatchObject({ diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index fc9efe1684..cd9ec71d83 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) { commitFile(cwd, "feature.txt", "feature change\n"); const head = runGit(cwd, "rev-parse", "HEAD"); await client.checkoutRefresh(cwd); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true }); const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" }); expect(before.error).toBeNull(); expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index a8a1f11dfb..55265416dc 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -526,3 +526,183 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu rmSync(tempDir, { recursive: true, force: true }); } }, 15_000); + +test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { fileURLToPath } = await import("node:url"); + const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const source = ` + import assert from 'node:assert/strict'; + import { write } from 'node:fs'; + import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { setImmediate as tick } from 'node:timers/promises'; + import { createHook } from 'node:async_hooks'; + import pino from 'pino'; + import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts'; + import { runGitCommand } from './packages/server/src/utils/run-git-command.ts'; + import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts'; + const root = await mkdtemp(join(tmpdir(), 'git-async-contract-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const worktree = join(home, 'worktrees', 'project', 'topic'); + const alias = join(root, 'alias'); + const logger = pino({ level: 'trace' }, { write() {} }); + // Async descriptor writes avoid Windows stderr's synchronous stream API. + const marker = (label) => new Promise((resolve, reject) => { + write(2, label + '\\n', (error) => error ? reject(error) : resolve()); + }); + let callback; + let notifications = 0; + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: { + subscribe: async (_root, onChange) => { + callback = onChange; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + } + } }); + try { + await mkdir(repo); + await runGitCommand(['init', '-b', 'main'], { cwd: repo }); + await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo }); + await mkdir(join(home, 'worktrees', 'project'), { recursive: true }); + await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo }); + await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' }); + await writeFile(join(worktree, '.gitignore'), 'ignored/\\n'); + await mkdir(join(worktree, 'ignored')); + await writeFile(join(worktree, 'ignored', 'noise'), 'noise'); + await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); + const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); + // A shared CI runner can deschedule the child during a wall-clock sample. + // Keep the 20ms gate, but take three independent batches and require one + // responsive callback/timer pair. All batches must remain filesystem-free. + const samples = []; + for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) { + await tick(); + let fsCalls = 0; + const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + await marker('FILTER_START'); + hook.enable(); + const started = performance.now(); + const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0)); + callback(null, events); + const filterMs = performance.now() - started; + hook.disable(); + const loopGapMs = await pulse; + await marker('FILTER_END'); + assert.equal(fsCalls, 0, "filesystem calls"); + assert.equal(notifications, 0, "ignored event notification"); + samples.push({ filterMs, loopGapMs, fsCalls }); + } + console.log(JSON.stringify({ ...samples[0], samples })); + assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples)); + // Alias identity remains useful after deletion: no per-event realpath can recover this file. + callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); + assert.equal(notifications, 1); + await marker('REFRESH_START'); + const snapshot = await service.getSnapshot(worktree); + assert.equal(snapshot.git.isGit, true); + assert.equal(snapshot.git.isPaseoOwnedWorktree, true); + await runGitCommand(['status', '--porcelain'], { cwd: alias, logger }); + await marker('REFRESH_END'); + subscription.unsubscribe(); + } finally { + await service.dispose(); + await rm(root, { recursive: true, force: true }); + } + `; + for (const tracing of [false, true]) { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + ...(tracing ? ["--trace-sync-io"] : []), + "--input-type=module", + "--eval", + source, + ], + { + cwd: repositoryRoot, + env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" }, + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }, + ).catch((error) => { + throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]")); + }); + for (const phase of ["FILTER", "REFRESH"]) { + const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0]; + expect(output, `${phase}: synchronous filesystem IO`).toBe(""); + } + expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 }); + } +}, 60_000); + +test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => { + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const paths = await GitObservationPaths.create("C:\\paseo-observation-contract"); + const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]); + expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe( + "refs/heads/MixedCase", + ); + expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false); + expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true); +}); + +test("concurrent alias requests establish one working-tree subscription", async () => { + const { symlink } = await import("node:fs/promises"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-"))); + const repo = path.join(root, "repo"); + const alias = path.join(root, "alias"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir"); + let subscriptions = 0; + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { + subscribe: async () => { + subscriptions++; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + }, + }, + }); + try { + const watches = await Promise.all([ + service.requestWorkingTreeWatch(repo, () => {}), + service.requestWorkingTreeWatch(alias, () => {}), + ]); + expect(subscriptions).toBe(1); + for (const watch of watches) watch.unsubscribe(); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => { + const { mkdir, symlink } = await import("node:fs/promises"); + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-"))); + const repository = path.join(root, "repository"); + const parentAlias = path.join(root, "parent-alias"); + const workspaceAlias = path.join(root, "workspace-alias"); + const literalTarget = path.join(parentAlias, "repository"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + try { + await mkdir(repository); + await symlink(root, parentAlias, linkType); + await symlink(literalTarget, workspaceAlias, linkType); + const paths = await GitObservationPaths.create(workspaceAlias); + const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]); + for (const spelling of [workspaceAlias, literalTarget, repository]) { + expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt"); + expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index f91a96e464..ab93558793 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => { ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]); + expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort()); }); first.unsubscribe(); @@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => { 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); }); @@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => { [60_000, 6], ] as const) { await vi.advanceTimersByTimeAsync(elapsedMs); - expect(pollCount()).toBe(expectedPolls); + await vi.waitFor(() => { + expect(pollCount()).toBe(expectedPolls); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); } isDirty = true; await vi.advanceTimersByTimeAsync(60_000); - expect(pollCount()).toBe(7); + await vi.waitFor(() => expect(pollCount()).toBe(7)); await vi.advanceTimersByTimeAsync(5_000); - expect(pollCount()).toBe(8); + await vi.waitFor(() => expect(pollCount()).toBe(8)); subscription.unsubscribe(); service.dispose(); @@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => { expect(watcher.subscribe).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(35_000); - expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); - expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + await vi.waitFor(() => { + expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + }); expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1); openedSubscription.resolve({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 9a3599740c..1b43026c7d 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - expect(subscribe).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); - service.dispose(); + await service.dispose(); }); test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 493daa1ddd..b5672846bd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -42,18 +42,13 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { - createRealpathAwarePathMatcher, - getRealpathAwareRelativePath, - isPathInsideRoot, - isRealpathInsideRoot, - looksLikeDefiniteWindowsPath, -} from "../utils/path.js"; +import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js"; import { createRunGitCommand, runGitCommand, type RunGitCommand, } from "../utils/run-git-command.js"; +import { GitObservationPaths } from "./git-observation-paths.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { } interface WorkspaceGitTarget { + gitDirPaths: GitObservationPaths | null; cwd: string; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; @@ -443,6 +439,7 @@ interface WorkspaceGitTarget { } interface RepoGitTarget { + paths: GitObservationPaths; repoGitRoot: string; cwd: string; workspaceKeys: Set; @@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh { } interface WorkingTreeWatchTarget { + paths: GitObservationPaths; + acceptsEvent: (candidate: string) => boolean; cwd: string; watchPath: string; repoRoot: string | null; @@ -1170,6 +1169,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async resolveWorkingTreeWatchTarget(cwd: string): Promise { const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null; + this.assertNotDisposed(); const targetCwd = repoRoot ?? cwd; const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { @@ -1184,7 +1185,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } - const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; + const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd; const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { if (this.workingTreeWatchSetups.get(targetCwd) === setup) { this.workingTreeWatchSetups.delete(targetCwd); @@ -1203,6 +1204,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { + gitDirPaths: null, cwd, listeners: new Set(), workingTreeWatchTarget: null, @@ -1304,6 +1306,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); const gitDir = facts.absoluteGitDir; + const gitDirPaths = target.gitDirPaths?.matches(gitDir) + ? target.gitDirPaths + : await GitObservationPaths.create(gitDir); + if (!this.isActiveObservedWorkspaceTarget(target)) return; + target.gitDirPaths = gitDirPaths; const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1336,7 +1343,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { - target.latestFacts = facts; + if (!target.closed) target.latestFacts = facts; return facts; }) .finally(() => { @@ -1364,7 +1371,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const ignoredDirectories = repoRoot ? (await this.loadIgnoredDirs(watchPath)).ignored : new Set(); + const paths = await GitObservationPaths.create(watchPath); + this.assertNotDisposed(); const target: WorkingTreeWatchTarget = { + paths, + acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]), cwd, watchPath, repoRoot, @@ -1528,15 +1539,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + events = this.relevantWorkingTreeEvents(target, events); + if (events.length === 0) return; const discovered = this.noteWorkingTreeDirectories(target, events); if (events.some((event) => basename(event.path) === ".gitignore")) { void this.refreshWorkingTreeIgnoredDirectories(target); } else if (discovered) { this.scheduleWorkingTreeIgnoreRefresh(target); } - if (!this.hasRelevantWorkingTreeEvent(target, events)) { - return; - } this.notifyWorkingTreeChanged(target, "working-tree-watch"); }, { ignore }, @@ -1768,20 +1778,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.removeDeletedKnownDirectories(target, events); const gitDir = join(target.watchPath, ".git"); - // `matchesWatchPath` is realpath-aware because it compares an event-reported - // directory against `target.watchPath` itself — a config-time root that can - // legitimately differ from the watcher's reported form (e.g. a symlinked - // temp dir). It is now gated behind the plain Set lookup below, so it only - // runs for a directory this target has never seen before, not on every - // event. - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + // Event paths have already been mapped into this subscription's established namespace. + const matchesWatchPath = target.paths.matches; // One new directory is enough: the scheduled Git query inventories every // ignored directory that exists by then. Stop before a large event batch // multiplies containment checks across all its new directories. for (const event of events) { const directory = dirname(event.path); // Cheap Set lookup first — the steady-state case where the directory is - // already known must never reach the realpath-aware matcher below. + // already known do not need another root comparison. if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) { continue; } @@ -1960,28 +1965,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.pendingKnownDirectories.clear(); } - private hasRelevantWorkingTreeEvent( + private relevantWorkingTreeEvents( target: WorkingTreeWatchTarget, events: FileChange[], - ): boolean { - const gitDir = join(target.watchPath, ".git"); - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); - return events.some((event) => { - // Directory metadata changes at the watch root do not change Git state. - // FSEvents may emit these when every changed descendant was ignored. - if (matchesWatchPath(event.path)) { - return false; + ): FileChange[] { + const relevant: FileChange[] = []; + // Watch backends may report the same path more than once in a batch. + const acceptedPaths = new Map(); + for (const event of events) { + let accepted = acceptedPaths.get(event.path); + if (accepted === undefined) { + accepted = target.acceptsEvent(event.path); + acceptedPaths.set(event.path, accepted); } - if (isRealpathInsideRoot(gitDir, event.path)) { - return false; - } - for (const ignoredDirectory of target.ignoredDirectories) { - if (isRealpathInsideRoot(ignoredDirectory, event.path)) { - return false; - } - } - return true; - }); + if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) }); + } + return relevant; } private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { @@ -2036,6 +2035,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { target.ignoredDirectories = ignoredDirectories; + target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target)); this.pruneKnownDirectories(target); } if (target.fallbackPolling) { @@ -2157,7 +2157,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } + const paths = await GitObservationPaths.create(repoGitRoot); + if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return; + const establishedTarget = this.repoTargets.get(repoGitRoot); + if (establishedTarget) { + establishedTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } const repoTarget: RepoGitTarget = { + paths, repoGitRoot, cwd: workspaceTarget.cwd, workspaceKeys: new Set([workspaceTarget.cwd]), @@ -2210,10 +2218,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: RepoGitTarget, options?: { replaceFallback?: boolean }, ): Promise { + const paths = target.paths; const ignore = getPrunedGitMetadataPaths("common").map((path) => join(target.repoGitRoot, path), ); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = paths.matches; const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot); let openedSubscription: FileObserverSubscription | null = null; let watcherErrored = false; @@ -2228,7 +2237,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const subscription = await this.subscribeWithDeadline( target.repoGitRoot, (error, events) => { - const liveEvents = canary.filterEvents(events); + const liveEvents = canary.filterEvents( + events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })), + ); if (error) { if (watcherErrored) { return; @@ -2254,7 +2265,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const relevantEvents = liveEvents.filter( (event) => !matchesRepoGitRoot(event.path) && - ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), + ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)), ); if (relevantEvents.length > 0) { const immediateEvents = target.fetchInFlight @@ -2367,7 +2378,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { events: FileChange[], ): Map | null { const refreshes = new Map(); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = target.paths.matches; for (const event of events) { if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null; @@ -2382,10 +2393,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): void { const workspaceKeys = new Set(); for (const event of events) { - const commonRelativePath = getRealpathAwareRelativePath( - target.repoGitRoot, - event.path, - )?.replaceAll("\\", "/"); + const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/"); if (commonRelativePath === "config" || commonRelativePath === "info/exclude") { for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey); continue; @@ -2395,8 +2403,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if ( facts?.isGit && facts.absoluteGitDir && - getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") === - "config.worktree" + this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path) + ?.replaceAll("\\", "/") === "config.worktree" ) { workspaceKeys.add(workspaceKey); } @@ -2412,7 +2422,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean { - const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const relativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", relativePath ?? ""); return ( (effect.kind === "ref" && effect.namespace === "remote") || @@ -2424,12 +2434,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeRepoMetadataEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true; - const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const commonRelativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", commonRelativePath ?? ""); switch (effect.kind) { case "ignore": @@ -2481,7 +2491,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routePrivateGitDirEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { let matched = false; @@ -2490,8 +2500,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) { continue; } - const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path); - if (relativePath === null) continue; + const relativePath = this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path); + if (relativePath == null) continue; matched = true; const effect = classifyGitMetadataPath("worktree", relativePath); if (effect.kind === "owner") { @@ -2505,7 +2517,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeMainCheckoutMetadata( target: RepoGitTarget, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshBase: boolean, refreshes: Map, ): void { diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index cf192a44b6..817853dad9 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: { continue; } try { - pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); + await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); } catch (error) { options.logger.warn( { err: error, workspaceId: workspace.workspaceId }, diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index 8eb1625265..186dcbc0b1 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => { const repoRoot = createTempDir("paseo-branch-config-"); const worktreeRoot = createTempDir("paseo-branch-worktree-"); mkdirSync(path.join(worktreeRoot, ".git")); - writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { + await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { placeholderBranchName: "dazzling-yak", }); writeConfig(repoRoot, { diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 9d02d0a2ec..4ec533d0c3 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { second.worktree.worktreePath, "remote.paseo-pr-14.url", ); - const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath); + const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath); const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome }); expect(second.worktree.branchName).toBe("feature/gitlab-mr-1"); @@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { createCoreDeps({ github }), ); writeFileSync( - getPaseoWorktreeMetadataPath(second.worktree.worktreePath), + await getPaseoWorktreeMetadataPath(second.worktree.worktreePath), `${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`, "utf8", ); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index d614e32f9b..ee29579f55 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => { mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); - writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); const { baseRef, commits } = await listCheckoutCommits({ cwd: worktreeDir, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 3cc5c3dace..bd52b0e49f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2859,7 +2859,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2899,7 +2899,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "placeholder", @@ -2936,7 +2936,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/pinned", @@ -2975,7 +2975,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2997,7 +2997,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3062,7 +3062,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3114,7 +3114,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/gitlab-mr", @@ -3128,7 +3128,9 @@ const x = 1; const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); await renameCurrentBranch(workspaceCwd, "feature/renamed"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({ + expect( + (await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget, + ).toMatchObject({ localBranchName: "feature/renamed", }); const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); @@ -3164,7 +3166,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/placeholder", @@ -3174,7 +3176,7 @@ const x = 1; await renameCurrentBranch(workspaceDir, "feature/generated"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({ + expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({ headRef: "feature/generated", localBranchName: "feature/generated", }); @@ -3951,7 +3953,7 @@ const x = 1; cwd: worktree.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); @@ -3970,7 +3972,7 @@ const x = 1; paseoHome, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index a39b6445e2..eed39f63e5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,15 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises"; +import { + open as openFile, + readFile, + stat as statFile, + mkdtemp, + copyFile, + realpath, + access, + rm, +} from "fs/promises"; import { tmpdir } from "node:os"; import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; @@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = await realpath(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1229,7 +1237,7 @@ export async function renameCurrentBranch( const currentBranch = await getCurrentBranch(cwd); if (currentBranch) { - rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); } return { previousBranch, currentBranch }; } @@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri return metadata?.baseRef ?? metadata?.baseRefName ?? null; } -function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null { - return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot)); +async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise { + return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot)); } async function getStoredBaseRefForCwd( @@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise { const rebaseMergePath = resolve(gitDir, "rebase-merge"); const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - if (existsSync(mergeHeadPath)) { + if ( + await access(mergeHeadPath).then( + () => true, + () => false, + ) + ) { try { await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise { } } - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { + if ( + (await access(rebaseMergePath).then( + () => true, + () => false, + )) || + (await access(rebaseApplyPath).then( + () => true, + () => false, + )) + ) { try { await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts( } const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree - ? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) + ? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) : null; const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata); const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context)); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index a0ec908fbb..45cb8b01b5 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean { return getRealpathAwareRelativePath(root, candidate) !== null; } -function getRelativePathInsideRoot(root: string, candidate: string): string | null { +export function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); @@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s return compareAsWindows ? normalized.toLowerCase() : normalized; } -function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { +export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; const platformNormalized = platformPath.normalize(comparableValue); diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 32844d0de8..5153b606dc 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -507,7 +507,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, timeout: 30_000, maxOutputBytes: 20 * 1024 * 1024, acceptExitCodes: [0, 1], @@ -520,7 +519,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, durationMs: expect.any(Number), exitCode: 0, signal: null, @@ -530,6 +528,9 @@ describe("runGitCommand", () => { }), "Git command closed", ); + for (const [record] of trace.mock.calls) { + expect(record).not.toHaveProperty("cwdExists"); + } }); it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bdf7bc1f32..3a3cbf5b4c 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { existsSync } from "node:fs"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { @@ -311,7 +310,6 @@ function executeGitCommand( command: "git", args, cwd: options.cwd, - cwdExists: existsSync(options.cwd), timeout, maxOutputBytes, acceptExitCodes, diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index b977f7952b..13c95567db 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -1,7 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import pLimit from "p-limit"; import { isAbsolute, join, resolve } from "path"; import { z } from "zod"; +const MAX_WORKTREE_METADATA_BYTES = 64 * 1024; + const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), @@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null { return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; } -export function rebindPaseoWorktreeChangeRequestHint( +export async function rebindPaseoWorktreeChangeRequestHint( worktreeRoot: string, previousBranch: string, currentBranch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); - if (!metadata || !target) { - return false; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); + if (!metadata || !target) { + return false; + } - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: { - ...target, - ...(target.headRef === previousBranch && - !target.headRepositoryOwner && - target.changeRequestNumber === undefined - ? { headRef: currentBranch } - : {}), - localBranchName: currentBranch, - }, + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: { + ...target, + ...(target.headRef === previousBranch && + !target.headRepositoryOwner && + target.changeRequestNumber === undefined + ? { headRef: currentBranch } + : {}), + localBranchName: currentBranch, + }, + }); + return true; }); - return true; } -export function pinPaseoWorktreeBranchIdentityIfMissing( +export async function pinPaseoWorktreeBranchIdentityIfMissing( worktreeRoot: string, branch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - if (!metadata || metadata.changeRequestLookupTarget) { - return false; - } - const target = createPaseoWorktreeChangeRequestHint({ - headRef: branch, - localBranchName: branch, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + if (!metadata || metadata.changeRequestLookupTarget) { + return false; + } + const target = createPaseoWorktreeChangeRequestHint({ + headRef: branch, + localBranchName: branch, + }); + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: target, + }); + return true; }); - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: target, - }); - return true; } -function getGitDirForWorktreeRoot(worktreeRoot: string): string { +async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise { const gitPath = join(worktreeRoot, ".git"); - if (!existsSync(gitPath)) { - throw new Error(`Not a git repository: ${worktreeRoot}`); - } - - // In a worktree checkout, `.git` is a file containing `gitdir: `. - // In a normal checkout, `.git` is a directory. + let info; try { - const gitFileContent = readFileSync(gitPath, "utf8"); - const match = gitFileContent.match(/gitdir:\s*(.+)/); - if (match?.[1]) { - const raw = match[1].trim(); - return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); - } - } catch { - // If `.git` is a directory, readFileSync will throw; fall through. + info = await stat(gitPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error }); + throw error; + } + if (info.isDirectory()) return gitPath; + const gitFileContent = await readBoundedFile(gitPath); + const match = gitFileContent.match(/gitdir:\s*(.+)/); + if (match?.[1]) { + const raw = match[1].trim(); + return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); } return gitPath; } -export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { - const gitDir = getGitDirForWorktreeRoot(worktreeRoot); +export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise { + const gitDir = await getGitDirForWorktreeRoot(worktreeRoot); return join(gitDir, "paseo", "worktree.json"); } @@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void { } } -export function writePaseoWorktreeMetadata( +export async function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; baseRef?: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, -): void { - const baseRefName = normalizeBaseRefName(options.baseRefName); - assertValidBaseRef(baseRefName); - const baseRef = options.baseRef?.trim(); - if (baseRef) { - assertValidBaseRef(baseRef); - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const baseRefName = normalizeBaseRefName(options.baseRefName); + assertValidBaseRef(baseRefName); + const baseRef = options.baseRef?.trim(); + if (baseRef) { + assertValidBaseRef(baseRef); + } - const metadata: PaseoWorktreeMetadata = { - version: 1, - baseRefName, - ...(baseRef ? { baseRef } : {}), - ...(options.changeRequestLookupTarget - ? { changeRequestLookupTarget: options.changeRequestLookupTarget } - : {}), - }; - writePaseoWorktreeMetadataFile(worktreeRoot, metadata); -} - -export function writePaseoWorktreeRuntimeMetadata( - worktreeRoot: string, - options: { worktreePort: number }, -): void { - if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { - throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); - } - - const next: PaseoWorktreeMetadata = { - ...current, - version: 2, - runtime: { - worktreePort: options.worktreePort, - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); -} - -export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( - worktreeRoot: string, - options: { placeholderBranchName: string }, -): void { - const placeholderBranchName = options.placeholderBranchName.trim(); - if (!placeholderBranchName) { - throw new Error("Placeholder branch name is required"); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata"); - } - - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...current, - version: 2, - firstAgentBranchAutoName: { - status: "pending", - placeholderBranchName, - }, + const metadata: PaseoWorktreeMetadata = { + version: 1, + baseRefName, + ...(baseRef ? { baseRef } : {}), + ...(options.changeRequestLookupTarget + ? { changeRequestLookupTarget: options.changeRequestLookupTarget } + : {}), + }; + await writePaseoWorktreeMetadataFile(root, metadata); }); } -export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( +export async function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const next: PaseoWorktreeMetadata = { + ...current, + version: 2, + runtime: { + worktreePort: options.worktreePort, + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + }); +} + +export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( + worktreeRoot: string, + options: { placeholderBranchName: string }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const placeholderBranchName = options.placeholderBranchName.trim(); + if (!placeholderBranchName) { + throw new Error("Placeholder branch name is required"); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error( + "Cannot persist first-agent branch auto-name metadata: missing base metadata", + ); + } + + await writePaseoWorktreeMetadataFile(root, { + ...current, + version: 2, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName, + }, + }); + }); +} + +export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( worktreeRoot: string, options: { attemptedAt?: string } = {}, -): PaseoWorktreeMetadata | null { - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") { - return current; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if ( + !current || + current.version !== 2 || + current.firstAgentBranchAutoName?.status !== "pending" + ) { + return current; + } - const next: PaseoWorktreeMetadata = { - ...current, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, - attemptedAt: options.attemptedAt ?? new Date().toISOString(), - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); - return next; + const next: PaseoWorktreeMetadata = { + ...current, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, + attemptedAt: options.attemptedAt ?? new Date().toISOString(), + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + return next; + }); } -export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - if (!existsSync(metadataPath)) { - return null; - } - const parsed = JSON.parse(readFileSync(metadataPath, "utf8")); - return PaseoWorktreeMetadataSchema.parse(parsed); +export async function readPaseoWorktreeMetadata( + worktreeRoot: string, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + await metadataMutations.get(key)?.catch(() => {}); + return metadataIo(() => readMetadataFile(key)); } -export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +async function readMetadataFile(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + try { + return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also +// caps a regular file that grows between stat and read; readFile's whole-file loop does not. +async function readBoundedFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await file.stat(); + const limit = MAX_WORKTREE_METADATA_BYTES; + if (!info.isFile() || info.size > limit) + throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + const buffer = Buffer.alloc(limit + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + return buffer.toString("utf8", 0, length); + } finally { + await file.close(); + } +} + +export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`); } return metadata.baseRefName; } -export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise { + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { return null; } @@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul return null; } -function writePaseoWorktreeMetadataFile( +async function writePaseoWorktreeMetadataFile( worktreeRoot: string, metadata: PaseoWorktreeMetadata, -): void { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); - const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); - renameSync(tempPath, metadataPath); +): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const contents = `${JSON.stringify(metadata, null, 2)}\n`; + if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) { + throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`); + } + // Never recreate an archived Git directory while a queued mutation is pending. + try { + await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const tempPath = `${metadataPath}.${randomUUID()}.tmp`; + await writeFile(tempPath, contents, "utf8"); + await rename(tempPath, metadataPath); +} + +const metadataIo = pLimit(2); +const metadataMutations = new Map>(); + +// Only share in-flight identity resolution: a replaced symlink must not retain an old owner. +const metadataIdentities = new Map>(); + +function resolveMetadataIdentity(worktreeRoot: string): Promise { + const path = resolve(worktreeRoot); + const existing = metadataIdentities.get(path); + if (existing) return existing; + const identity = metadataIo(async () => { + try { + return await realpath(path); + } catch { + return path; + } + }).finally(() => { + if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path); + }); + metadataIdentities.set(path, identity); + return identity; +} + +async function mutateMetadata( + worktreeRoot: string, + mutation: (root: string) => Promise, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + const previous = metadataMutations.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key))); + metadataMutations.set(key, pending); + void pending + .finally(() => { + if (metadataMutations.get(key) === pending) metadataMutations.delete(key); + }) + .catch(() => {}); + return pending; } diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 7819e2275c..aede7cf8e3 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -21,7 +21,13 @@ import { type WorktreeConfig, } from "./worktree"; import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; -import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, +} from "./worktree-metadata.js"; import { getCheckoutDiff, getCheckoutStatus, @@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); expect(existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ @@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: result.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect(existsSync(result.worktreePath)).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); }); @@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.branchName).toBe("main-1"); expect(existsSync(result.worktreePath)).toBe(true); - expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({ + expect( + (await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget, + ).toEqual({ headRef: "main-1", localBranchName: "main-1", }); @@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("user/feature"); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ baseRefName: "main" }); }); @@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { "from-origin\n", ); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")), + JSON.parse( + readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"), + ), ).toMatchObject({ baseRefName: "main" }); }); @@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // to the commit the worktree was cut from: "main" resolves local-first, which here is // a different commit than the fork's upstream. expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" }); // An untouched child must report no work of its own. Comparing against the wrong base @@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "release+hotfix", baseRef: "refs/remotes/upstream/release+hotfix", @@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); }); + +describe.skipIf(isPlatform("win32"))("async worktree metadata", () => { + it("serializes metadata mutations per worktree while another worktree progresses", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-")); + const first = join(root, "first"); + const second = join(root, "second"); + mkdirSync(join(first, ".git"), { recursive: true }); + mkdirSync(join(second, ".git"), { recursive: true }); + try { + await writePaseoWorktreeMetadata(first, { baseRefName: "main" }); + await writePaseoWorktreeMetadata(second, { baseRefName: "release" }); + const order: string[] = []; + const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() => + order.push("first-runtime"), + ); + const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() => + order.push("second-runtime"), + ); + const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, { + placeholderBranchName: "topic", + }).then(() => order.push("first-naming")); + // A read issued while writes are pending observes their completed snapshot. + const snapshot = readPaseoWorktreeMetadata(first); + await Promise.all([runtime, unrelated, naming]); + expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming")); + expect(await snapshot).toMatchObject({ + baseRefName: "main", + runtime: { worktreePort: 4101 }, + firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" }, + }); + expect(await readPaseoWorktreeMetadata(second)).toMatchObject({ + baseRefName: "release", + runtime: { worktreePort: 4102 }, + }); + const alias = join(root, "first-alias"); + symlinkSync(first, alias, "dir"); + await Promise.all([ + writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }), + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, { + placeholderBranchName: "renamed", + }), + ]); + expect(await readPaseoWorktreeMetadata(first)).toMatchObject({ + runtime: { worktreePort: 4103 }, + firstAgentBranchAutoName: { placeholderBranchName: "renamed" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("a queued metadata mutation cannot recreate an archived Git directory", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-")); + mkdirSync(join(root, ".git")); + try { + await writePaseoWorktreeMetadata(root, { baseRefName: "main" }); + const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 }); + // The mutation is queued in a microtask, so archive wins this ordering. + rmSync(join(root, ".git"), { recursive: true }); + await expect(pending).rejects.toThrow("Not a git repository"); + expect(existsSync(join(root, ".git"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("recognizes a removed worktree under a symlinked ownership root", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-")); + const actual = join(root, "actual"); + const alias = join(root, "worktrees"); + mkdirSync(actual); + symlinkSync(actual, alias, "dir"); + try { + const cwd = join(alias, "project", "removed"); + expect( + await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }), + ).toMatchObject({ allowed: true, worktreePath: cwd }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects oversized metadata and named pipes without reading them whole", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-")); + mkdirSync(join(root, ".git", "paseo"), { recursive: true }); + try { + const path = await getPaseoWorktreeMetadataPath(root); + writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) })); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + rmSync(path); + execFileSync("mkfifo", [path]); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4d6f50bba6..605e2b8c20 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,7 @@ import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, + runWorktreeTeardownCommands, slugify, type CreateWorktreeOptions, type WorktreeConfig, @@ -171,7 +172,15 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); - expect(ownership.allowed).toBe(true); + expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath }); + // Windows may report the same root through native 8.3 and JS long spellings. + const nativeWorktreePath = realpathSync.native(created.worktreePath); + await expect( + runWorktreeTeardownCommands({ + worktreePath: created.worktreePath, + teardownCwd: nativeWorktreePath, + }), + ).resolves.toEqual([]); await expect( isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }), ).resolves.toMatchObject({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index dd6dfe7a44..3ddc71b472 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,11 +1,12 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { constants as fsConstants, realpath } from "fs"; +import { copyFile, mkdir, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; +import pLimit from "p-limit"; import { buildStringCommandShellInvocation, createStringCommandShellEnv, @@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { + expandTilde, + getRealpathAwareRelativePath, + getRelativePathInsideRoot, + isPathInsideRoot, +} from "./path.js"; import { terminateWithTreeKill } from "./tree-kill.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -595,7 +601,7 @@ async function assertPortAvailable(port: number): Promise { async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); // Normal repo/worktree: common dir is /.git if (basename(normalizedCommonDir) === ".git") { return dirname(normalizedCommonDir); @@ -611,12 +617,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< }); const topLevel = parseGitRevParsePath(stdout); if (topLevel) { - return normalizePathForOwnership(topLevel); + return await normalizePathForOwnership(topLevel); } } catch { // ignore } - return normalizePathForOwnership(worktreePath); + return await normalizePathForOwnership(worktreePath); } } @@ -699,12 +705,12 @@ export async function resolveWorktreeRuntimeEnv(options: { const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); if (worktreePort === null) { worktreePort = await getAvailablePort(); - const metadata = readPaseoWorktreeMetadata(options.worktreePath); + const metadata = await readPaseoWorktreeMetadata(options.worktreePath); if (metadata) { - writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); } } else { await assertPortAvailable(worktreePort); @@ -730,7 +736,7 @@ export async function runWorktreeTeardownCommands(options: { repoRootPath?: string; }): Promise { const teardownCwd = options.teardownCwd ?? options.worktreePath; - if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) { throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); } const teardownCommands = await getWorktreeTeardownCommands(teardownCwd); @@ -742,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: { options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv( createExternalProcessEnv(process.env, { @@ -819,12 +825,12 @@ function deriveShortAlphanumericHash(value: string): string { export async function deriveWorktreeProjectHash(cwd: string): Promise { try { const commonDir = await getGitCommonDir(cwd); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); const repoRoot = basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; return deriveShortAlphanumericHash(repoRoot); } catch { - return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); + return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd)); } } @@ -892,16 +898,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: { return mappedCwd; } -function normalizePathForOwnership(input: string): string { +// Keep the former realpathSync spelling on Windows: promises.realpath uses the +// native resolver and can return an 8.3 alias instead of the long path. +const resolveOwnershipPath = promisify(realpath); +const resolveNativeOwnershipPath = promisify(realpath.native); +const ownershipIdentityIo = pLimit(2); + +async function normalizePathForOwnership(input: string): Promise { try { - return realpathSync(input); + return await ownershipIdentityIo(() => resolveOwnershipPath(input)); } catch { return resolve(input); } } -function resolveRepoRootFromGitCommonDir(commonDir: string): string { - const normalizedCommonDir = normalizePathForOwnership(commonDir); +async function ownershipPathVariants(input: string): Promise { + const variants = new Set([input, await normalizePathForOwnership(input)]); + try { + variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input))); + } catch { + // Missing descendants still match a lexical or established root spelling. + } + return [...variants]; +} + +// Keep lexical variants for missing paths: archive must still recognize a removed +// worktree below a symlinked worktrees root, whose full realpath no longer exists. +async function getOwnershipRelativePath(root: string, candidate: string): Promise { + const roots = await ownershipPathVariants(root); + const candidates = await ownershipPathVariants(candidate); + for (const rootPath of roots) { + for (const candidatePath of candidates) { + const relative = getRelativePathInsideRoot(rootPath, candidatePath); + if (relative !== null) return relative; + } + } + return null; +} + +async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise { + const normalizedCommonDir = await normalizePathForOwnership(commonDir); return basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; @@ -911,25 +947,25 @@ export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: PaseoWorktreeOwnershipOptions, ): Promise { - const resolvedCwd = normalizePathForOwnership(cwd); + const resolvedCwd = await normalizePathForOwnership(cwd); // repoRoot is best-effort: git may be unreachable from the worktree (e.g. a // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; if (options?.knownGitCommonDir) { - repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); } else if (options?.knownGitCommonDir === undefined) { try { const gitCommonDir = await getGitCommonDir(cwd); - repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir); } catch { // ignore } } const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); - const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd); + const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd); // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the @@ -1000,9 +1036,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { return entries; } -function resolveWorktreeCreatedAtIso(worktreePath: string): string { +async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise { try { - const stats = statSync(worktreePath); + const stats = await stat(worktreePath); const birthtimeMs = stats.birthtimeMs; const createdAtMs = Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; @@ -1027,12 +1063,15 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); - return parseWorktreeList(stdout) - .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) - .filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null) - .map((entry) => - Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), - ); + const roots = await ownershipPathVariants(projectWorktreesRoot); + const worktrees: PaseoWorktreeInfo[] = []; + // Inspect foreign paths sequentially; a large worktree list must not consume the FS pool. + for (const entry of parseWorktreeList(stdout)) { + const path = await normalizePathForOwnership(entry.path); + if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue; + worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) }); + } + return worktrees; } export interface DeletePaseoWorktreeOptions { @@ -1071,7 +1110,7 @@ export async function deletePaseoWorktree({ } const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); - const resolvedRequested = normalizePathForOwnership(requestedPath); + const resolvedRequested = await normalizePathForOwnership(requestedPath); const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { paseoHome, worktreesRoot: worktreesBaseRoot, @@ -1079,7 +1118,7 @@ export async function deletePaseoWorktree({ const resolvedWorktree = ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - const relativeWorktreePath = getRealpathAwareRelativePath( + const relativeWorktreePath = await getOwnershipRelativePath( resolvedWorktreesRoot, resolvedWorktree, ); @@ -1195,12 +1234,12 @@ export const createWorktree = async ({ }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); + await mkdir(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision let finalWorktreePath = worktreePath; let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { + while (await pathExists(finalWorktreePath)) { finalWorktreePath = `${worktreePath}-${pathSuffix}`; pathSuffix++; } @@ -1210,7 +1249,7 @@ export const createWorktree = async ({ cwd, timeout: 120_000, }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + worktreePath = await normalizePathForOwnership(finalWorktreePath); if (sourcePlan.pushRemote) { await configureWorktreePushRemote({ @@ -1227,7 +1266,7 @@ export const createWorktree = async ({ }); } - writePaseoWorktreeMetadata(worktreePath, { + await writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName, ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), ...(sourcePlan.changeRequestLookupTarget