diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index a4db7ce7bf..16dae500f4 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { mkdtempSync, realpathSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w cleanupClients.add(client); await client.connect(); await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; const added = await client.addProject(projectRoot); - expect(added).toEqual({ + expect(added).toMatchObject({ requestId: expect.any(String), project: { projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/), @@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w }, error: null, }); - const project = added.project!; + const { + projectIconRevision: _icon, + projectCustomIconRevision: _customIcon, + ...project + } = added.project!; const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] }); @@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w label: "project.update after git init", }); - expect(update).toEqual({ + expect(update).toMatchObject({ kind: "upsert", - project: { ...project, projectKind: "git" }, + project: { ...project, projectKind: "git", projectKey: expect.any(String) }, }); const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(afterGitInit).toMatchObject({ entries: [], - emptyProjects: [{ ...project, projectKind: "git" }], + emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }], }); const persistedProjects = JSON.parse( @@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w createdAt: expect.any(String), updatedAt: expect.any(String), archivedAt: null, + projectKey: expect.any(String), + customIconRevision: null, }); }, 30_000); + +test("a failed worktree attempt before git init does not prevent creation afterward", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-"))); + const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-"))); + cleanupPaths.add(projectRoot); + cleanupPaths.add(paseoHomeRoot); + await writeFile(path.join(projectRoot, "README.md"), "hi\n"); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; + const added = await client.addProject(projectRoot); + expect(added.project?.projectKind).toBe("non_git"); + const source = { + kind: "worktree" as const, + cwd: projectRoot, + projectId: added.project!.projectId, + action: "branch-off" as const, + refName: "main", + branchName: "after-init", + worktreeSlug: "after-init", + }; + const before = await client.createWorkspace({ source }); + expect(before.error).toBe("Create worktree requires a git repository"); + + const gitProjectUpdate = waitForProjectUpdate( + client, + (payload) => + payload.kind === "upsert" && + payload.project.projectId === added.project!.projectId && + payload.project.projectKind === "git", + ); + await execFile("git", ["init", "-b", "main"], { cwd: projectRoot }); + await execFile("git", ["add", "README.md"], { cwd: projectRoot }); + await execFile( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.dev", + "-c", + "commit.gpgSign=false", + "commit", + "-m", + "initial", + ], + { cwd: projectRoot }, + ); + await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" }); + const projects = await client.fetchWorkspaces({ + filter: { projectId: added.project!.projectId }, + }); + expect(projects.emptyProjects[0]?.projectKind).toBe("git"); + + const created = await client.createWorkspace({ source }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceKind).toBe("worktree"); + expect(created.workspace?.projectId).toBe(added.project!.projectId); +}, 30_000); diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 711b826348..95fe416c54 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const deletions = expectFileChanges(removedPaths, "delete"); const movedFiles = expectFileChanges(movedPaths); const recovery = expectFileChanges([sentinel]); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { population.record(event); @@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await writes.complete; - // A native watcher may recover coalesced paths through its safety audit. - // Completion comes from the delivered deletions, not an idle diagnostic or - // a deadline shorter than that recovery cycle. + // The first notice for each write can arrive before its remaining + // classifications. Drain those deliveries before starting a separate deletion + // phase, so a late create cannot coalesce with the expected delete as update. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingClassificationCount: 0, + reconciliationInFlightCount: 0, + }); + + // Completion still requires the delivered deletion notices. await Promise.all(removedPaths.map((path) => rm(path))); await deletions.complete; 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 a98dcda0fd..8ac1ef0018 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 @@ -63,6 +63,27 @@ afterEach(async () => { } }); +test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-"))); + const repo = path.join(root, "repo"); + mkdirSync(repo); + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + }); + cleanup.push(async () => { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + }); + + expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); + + expect((await service.getCheckout(repo)).isGit).toBe(true); + const repoRoot = await service.resolveRepoRoot(repo); + expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo)); +}, 30_000); + test("recursive observation updates tracked state and prunes ignored storms", async () => { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-"))); const repoDir = path.join(tempDir, "repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 2f3483cb6f..a2b89bed39 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }); + const cached = this.peekSnapshot(normalizedCwd); + if (cached && cached.git.isGit !== status.isGit) { + // A fresh checkout read discovered a repository transition. Keep the + // shared snapshot current before exposing the new project capability. + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "checkout-classification-changed", + }); + } if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false,