diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index cb1dde02123..9e114d6ee68 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -2419,6 +2419,129 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { ])); }); + async function seedSharedLocalWorkspace(cwd: string, options: { metadata?: Record } = {}) { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { enabled: true }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd, + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared workspace", + status: "active", + providerType: "local_fs", + cwd, + metadata: options.metadata ?? null, + }); + return { executionWorkspaceId }; + } + + it("treats an existing workspace directory that is not a git repository as ready", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-workspace-")); + tempDirs.add(workspaceDir); + await fs.writeFile(path.join(workspaceDir, "notes.txt"), "plain files\n", "utf8"); + const { executionWorkspaceId } = await seedSharedLocalWorkspace(workspaceDir); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + state: "ready_with_warnings", + isDestructiveCloseAllowed: true, + git: { repoRoot: null, hasDirtyTrackedFiles: false, hasUntrackedFiles: false }, + }); + expect(readiness?.blockingReasons).toEqual([]); + expect(readiness?.warnings.some((warning) => warning.startsWith("Could not inspect git status"))).toBe(false); + }); + + it("plans removal of a runtime-created plain workspace directory", async () => { + const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-runtime-")); + tempDirs.add(parentDir); + const workspaceDir = path.join(parentDir, "task"); + await fs.mkdir(workspaceDir); + const { executionWorkspaceId } = await seedSharedLocalWorkspace(workspaceDir, { + metadata: { createdByRuntime: true }, + }); + await db.update(projectWorkspaces).set({ cwd: path.join(parentDir, "primary") }) + .where(eq(projectWorkspaces.cwd, workspaceDir)); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness?.blockingReasons).toEqual([]); + expect(readiness?.git).toMatchObject({ repoRoot: null, createdByRuntime: true }); + expect(readiness?.plannedActions.map((action) => action.kind)).toContain("remove_local_directory"); + }); + + it("still blocks close when an ancestor directory holds an unreadable .git marker", async () => { + const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ancestor-git-workspace-")); + tempDirs.add(parentDir); + await fs.writeFile(path.join(parentDir, ".git"), `gitdir: ${path.join(parentDir, "missing-git-dir")}\n`, "utf8"); + const workspaceDir = path.join(parentDir, "nested", "task"); + await fs.mkdir(workspaceDir, { recursive: true }); + const { executionWorkspaceId } = await seedSharedLocalWorkspace(workspaceDir); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ state: "blocked", isDestructiveCloseAllowed: false }); + expect(readiness?.blockingReasons).toContain( + "Paperclip could not verify the workspace git status. Retry before destructive cleanup.", + ); + }); + + it("still blocks close when the workspace .git marker is a dangling symlink", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-dangling-git-workspace-")); + tempDirs.add(workspaceDir); + await fs.symlink(path.join(workspaceDir, "unmounted-repo", ".git"), path.join(workspaceDir, ".git")); + const { executionWorkspaceId } = await seedSharedLocalWorkspace(workspaceDir); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ state: "blocked", isDestructiveCloseAllowed: false }); + expect(readiness?.blockingReasons).toContain( + "Paperclip could not verify the workspace git status. Retry before destructive cleanup.", + ); + }); + + it("still blocks close when a workspace git repository cannot be inspected", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-broken-git-workspace-")); + tempDirs.add(workspaceDir); + // A worktree-style .git pointer whose git dir is missing: git refuses to read it. + await fs.writeFile(path.join(workspaceDir, ".git"), `gitdir: ${path.join(workspaceDir, "missing-git-dir")}\n`, "utf8"); + const { executionWorkspaceId } = await seedSharedLocalWorkspace(workspaceDir); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ state: "blocked", isDestructiveCloseAllowed: false }); + expect(readiness?.blockingReasons).toContain( + "Paperclip could not verify the workspace git status. Retry before destructive cleanup.", + ); + }); + it("clears matching environment selections transactionally without touching other workspaces", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 4a4601844bf..3b29a35aa28 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -521,6 +521,31 @@ async function pathExists(value: string | null | undefined) { } } +/** + * Returns false only when no `.git` entry exists on the path or any ancestor. + * Uses lstat so a dangling `.git` symlink still counts, and treats any error + * other than ENOENT as "may exist" so unreadable locations fail closed. + */ +async function hasGitMarkerInPathOrAncestors(start: string) { + let current: string; + try { + current = await fs.realpath(start); + } catch { + return true; + } + while (true) { + try { + await fs.lstat(path.join(current, ".git")); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== "ENOENT") return true; + } + const parent = path.dirname(current); + if (parent === current) return false; + current = parent; + } +} + async function runGit(args: string[], cwd: string) { return await execFileAsync("git", ["-C", cwd, ...args], { cwd }); } @@ -963,6 +988,29 @@ async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise< try { repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null; } catch (error) { + // A plain directory (e.g. a local_fs shared workspace) has no git state to + // lose. Only treat it that way when no `.git` exists on the path, so a + // broken or unreadable repository still fails closed. + if (workspace.providerType !== "git_worktree" && !(await hasGitMarkerInPathOrAncestors(workspacePath))) { + return { + git: { + repoRoot: null, + workspacePath, + branchName: workspace.branchName, + baseRef: workspace.baseRef, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: false, + dirtyEntryCount: 0, + untrackedEntryCount: 0, + aheadCount: null, + behindCount: null, + isMergedIntoBase: null, + createdByRuntime, + }, + warnings, + statusInspectionSucceeded: true, + }; + } warnings.push( `Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, );