diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts index 358a80908b..a6c864603c 100644 --- a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -296,11 +296,10 @@ test("replayed common-ref observation survives runtime service reconstruction", await reconstructed.destroy(workspaceId); }, 20_000); -test("selected sibling worktrees share common-ref fan-out without touching an unrelated workspace", async () => { +test("selected sibling worktrees retain common-ref fan-out across lifecycle transitions", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-")); cleanupRoots.push(root); const source = await createRepository(path.join(root, "source")); - const unrelated = await createRepository(path.join(root, "unrelated")); const runtimeIds = new Map(); const service = createWorkspaceRuntimeService({ paseoHome: path.join(root, "paseo-home"), @@ -325,13 +324,6 @@ test("selected sibling worktrees share common-ref fan-out without touching an un placement: { kind: "branch", branchName, baseRef: "main", worktreeSlug: branchName }, }); } - await service.create({ - workspaceId: "unrelated", - runtimeId: "local", - project: { id: "unrelated-project", source: { kind: "host-directory", path: unrelated } }, - placement: { kind: "existing" }, - }); - let resolveFirst!: () => void; let resolveSibling!: () => void; const firstChanged = new Promise((resolve) => { @@ -349,10 +341,8 @@ test("selected sibling worktrees share common-ref fan-out without touching an un const siblingChangedAfterOwnerDestroy = new Promise((resolve) => { resolveSiblingAfterOwnerDestroy = resolve; }); - let unrelatedChanges = 0; const siblingA = await service.bind("sibling-a"); const siblingB = await service.bind("sibling-b"); - const unrelatedGit = await service.bind("unrelated"); const subscriptions = [ await observeWorkspaceGit(siblingA, () => { if (phase === "first-change") resolveFirst(); @@ -362,32 +352,22 @@ test("selected sibling worktrees share common-ref fan-out without touching an un if (phase === "after-pause-change") resolveSiblingAfterOwnerPause(); if (phase === "after-destroy-change") resolveSiblingAfterOwnerDestroy(); }), - await observeWorkspaceGit(unrelatedGit, () => { - unrelatedChanges += 1; - }), ]; await createBranchThroughRuntime(service, "sibling-a", "shared-ref-change"); await Promise.all([ eventWithin(firstChanged, "first sibling ref update"), eventWithin(siblingChanged, "second sibling ref update"), ]); - expect(unrelatedChanges).toBe(0); - await service.pause("sibling-a"); phase = "after-pause-change"; await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-pause"); await eventWithin(siblingChangedAfterOwnerPause, "sibling ref update after owner pause"); - expect(unrelatedChanges).toBe(0); - await service.destroy("sibling-a"); phase = "after-destroy-change"; await createBranchThroughRuntime(service, "sibling-b", "shared-ref-after-owner-destroy"); await eventWithin(siblingChangedAfterOwnerDestroy, "sibling ref update after owner destroy"); - expect(unrelatedChanges).toBe(0); - await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); await Promise.all([service.destroy("sibling-a"), service.destroy("sibling-b")]); - await service.destroy("unrelated"); }, 20_000); test("sibling Git observations stay workspace-bound across pause, resume, and unsubscribe", async () => { diff --git a/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts index 670b3c914b..7ee3bd0fc3 100644 --- a/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -11,6 +11,7 @@ import { type FileObserverCallback, type FileObserverOptions, } from "../../file-observer/index.js"; +import { getGitCommonDir } from "../../../utils/worktree.js"; import { createHostGitObservationOwner } from "./host-git-observation.js"; describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () => { @@ -22,17 +23,32 @@ describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () git(source, ["branch", "sibling"]); git(source, ["worktree", "add", sibling, "sibling"]); const unrelated = await repository(path.join(root, "unrelated")); - const owner = createHostGitObservationOwner(); + const observer = new InstrumentedFileObserver(); + const owner = createHostGitObservationOwner(observer); + let sourceChanges = 0; + let siblingChanges = 0; + let unrelatedChanges = 0; const [sourceSubscription, siblingSubscription] = await Promise.all([ - owner.observe(source, () => undefined), - owner.observe(sibling, () => undefined), + owner.observe(source, () => { + sourceChanges += 1; + }), + owner.observe(sibling, () => { + siblingChanges += 1; + }), ]); expect(owner.getDiagnostics().activeObservationCount).toBe(1); - const unrelatedSubscription = await owner.observe(unrelated, () => undefined); + const unrelatedSubscription = await owner.observe(unrelated, () => { + unrelatedChanges += 1; + }); expect(owner.getDiagnostics().activeObservationCount).toBe(2); + observer.emit(await realpath(await getGitCommonDir(source))); + expect(sourceChanges).toBe(1); + expect(siblingChanges).toBe(1); + expect(unrelatedChanges).toBe(0); + await sourceSubscription.unsubscribe(); expect(owner.getDiagnostics().activeObservationCount).toBe(2); await siblingSubscription.unsubscribe(); @@ -85,7 +101,7 @@ describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () class InstrumentedFileObserver implements FileObserver { readonly observer = createFileObserver(); - readonly callbacks = new Set(); + readonly callbacks = new Map(); subscribeCount = 0; async subscribe( @@ -94,7 +110,7 @@ class InstrumentedFileObserver implements FileObserver { options?: FileObserverOptions, ) { this.subscribeCount += 1; - this.callbacks.add(callback); + this.callbacks.set(callback, directory); const subscription = await this.observer.subscribe(directory, callback, options); let active = true; return { @@ -109,7 +125,14 @@ class InstrumentedFileObserver implements FileObserver { } failActive(error: Error): void { - for (const callback of this.callbacks) callback(error, []); + for (const callback of this.callbacks.keys()) callback(error, []); + } + + emit(directory: string): void { + for (const [callback, observedDirectory] of this.callbacks) { + if (observedDirectory === directory) + callback(null, [{ path: "refs/heads/main", type: "update" }]); + } } getDiagnostics() {