diff --git a/src/server/workspaces/gitWorktreeDiscovery.test.ts b/src/server/workspaces/gitWorktreeDiscovery.test.ts new file mode 100644 index 0000000..6feb326 --- /dev/null +++ b/src/server/workspaces/gitWorktreeDiscovery.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { parseGitWorktreeList } from "./gitWorktreeDiscovery.js"; + +// Fixtures below are verbatim `git worktree list --porcelain` output captured from real git. +const mainAndLinked = [ + "worktree /repo", + "HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de", + "branch refs/heads/main", + "", + "worktree /repo-worktrees/feature", + "HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de", + "branch refs/heads/feat/thing", + "", +].join("\n"); + +const removedAndLocked = [ + "worktree /repo", + "HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de", + "branch refs/heads/main", + "", + "worktree /repo-worktrees/gone", + "HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de", + "branch refs/heads/gone", + "prunable gitdir file points to non-existent location", + "", + "worktree /repo-worktrees/kept", + "HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de", + "branch refs/heads/kept", + "locked keep me", + "", +].join("\n"); + +describe("parseGitWorktreeList", () => { + it("reads paths and short branch names for the main and linked worktrees", () => { + expect(parseGitWorktreeList(mainAndLinked)).toEqual([ + { path: "/repo", branch: "main" }, + { path: "/repo-worktrees/feature", branch: "feat/thing" }, + ]); + }); + + it("reports prunable with its reason and locked with or without a reason", () => { + expect(parseGitWorktreeList(removedAndLocked)).toEqual([ + { path: "/repo", branch: "main" }, + { path: "/repo-worktrees/gone", branch: "gone", prunable: true }, + { path: "/repo-worktrees/kept", branch: "kept", locked: true }, + ]); + + const bareLocked = ["worktree /repo-worktrees/kept", "HEAD abc", "detached", "locked", ""].join("\n"); + expect(parseGitWorktreeList(bareLocked)).toEqual([{ path: "/repo-worktrees/kept", detached: true, locked: true }]); + }); + + it("reads bare repositories and ignores chunks without a worktree path", () => { + const bare = ["worktree /repo.git", "bare", "", "HEAD abc", ""].join("\n"); + expect(parseGitWorktreeList(bare)).toEqual([{ path: "/repo.git", bare: true }]); + }); + + it("returns nothing for empty output", () => { + expect(parseGitWorktreeList("\n")).toEqual([]); + }); +}); diff --git a/src/server/workspaces/gitWorktreeDiscovery.ts b/src/server/workspaces/gitWorktreeDiscovery.ts index f4127bf..e20a656 100644 --- a/src/server/workspaces/gitWorktreeDiscovery.ts +++ b/src/server/workspaces/gitWorktreeDiscovery.ts @@ -9,6 +9,10 @@ export interface GitWorktreeInfo { branch?: string; bare?: boolean; detached?: boolean; + /** Git reports a linked worktree as prunable when its checkout directory no longer exists. */ + prunable?: boolean; + /** Git reports a locked worktree with a bare `locked` line, optionally followed by a reason. */ + locked?: boolean; } export async function isGitRepository(path: string): Promise { @@ -22,6 +26,14 @@ export async function isGitRepository(path: string): Promise { export async function discoverGitWorktrees(path: string): Promise { const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() }); + return parseGitWorktreeList(stdout); +} + +/** + * Parses `git worktree list --porcelain` output into facts only. Deciding which worktrees a + * project should show (for example hiding prunable ones) is workspace policy, not parsing. + */ +export function parseGitWorktreeList(stdout: string): GitWorktreeInfo[] { const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean); return chunks.map((chunk) => { @@ -33,6 +45,8 @@ export async function discoverGitWorktrees(path: string): Promise w.path); diff --git a/src/server/workspaces/workspaceService.test.ts b/src/server/workspaces/workspaceService.test.ts new file mode 100644 index 0000000..d3b9b49 --- /dev/null +++ b/src/server/workspaces/workspaceService.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { Project } from "../types.js"; +import type { GitWorktreeInfo } from "./gitWorktreeDiscovery.js"; +import { WorkspaceService, type WorkspaceGitPort } from "./workspaceService.js"; + +const project: Project = { + id: "p1", + name: "Project", + path: "/repo", + createdAt: "2026-05-25T00:00:00.000Z", +}; + +function serviceFor(worktrees: GitWorktreeInfo[], isGitRepo = true): WorkspaceService { + const git: WorkspaceGitPort = { + isGitRepository: () => Promise.resolve(isGitRepo), + discoverGitWorktrees: () => Promise.resolve(worktrees), + }; + return new WorkspaceService(git); +} + +describe("WorkspaceService.list", () => { + it("hides a linked worktree whose checkout directory was removed outside PI WEB", async () => { + const service = serviceFor([ + { path: "/repo", branch: "main" }, + { path: "/repo-worktrees/gone", branch: "gone", prunable: true }, + { path: "/repo-worktrees/live", branch: "live" }, + ]); + + const workspaces = await service.list(project); + + expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/live"]); + }); + + it("keeps a locked worktree, which is still a real checkout", async () => { + const service = serviceFor([ + { path: "/repo", branch: "main" }, + { path: "/repo-worktrees/kept", branch: "kept", locked: true }, + ]); + + const workspaces = await service.list(project); + + expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/kept"]); + }); + + it("keeps the project's own worktree even if git marks it prunable, so a project is never empty", async () => { + const service = serviceFor([{ path: "/repo", branch: "main", prunable: true }]); + + const workspaces = await service.list(project); + + expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "main", isMain: true, isGitWorktree: true })]); + }); + + it("falls back to the project itself when every linked worktree is filtered away", async () => { + const service = serviceFor([{ path: "/repo-worktrees/gone", branch: "gone", prunable: true }]); + + const workspaces = await service.list(project); + + expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "Project", isMain: true, isGitRepo: true, isGitWorktree: false })]); + }); + + it("labels detached and unnamed worktrees without inventing a branch", async () => { + const service = serviceFor([ + { path: "/repo", branch: "main" }, + { path: "/repo-worktrees/detached", detached: true }, + ]); + + const workspaces = await service.list(project); + + expect(workspaces.map((workspace) => ({ label: workspace.label, branch: workspace.branch }))).toEqual([ + { label: "main", branch: "main" }, + { label: "detached", branch: undefined }, + ]); + }); + + it("returns a single non-git workspace when the project is not a repository", async () => { + const service = serviceFor([], false); + + expect(await service.list(project)).toEqual([expect.objectContaining({ path: "/repo", isGitRepo: false, isGitWorktree: false })]); + }); +}); diff --git a/src/server/workspaces/workspaceService.ts b/src/server/workspaces/workspaceService.ts index 786fa6f..8bb98d9 100644 --- a/src/server/workspaces/workspaceService.ts +++ b/src/server/workspaces/workspaceService.ts @@ -1,18 +1,28 @@ import { createHash } from "node:crypto"; import type { Project } from "../types.js"; import type { Workspace } from "../types.js"; -import { discoverGitWorktrees, isGitRepository } from "./gitWorktreeDiscovery.js"; +import { discoverGitWorktrees, isGitRepository, type GitWorktreeInfo } from "./gitWorktreeDiscovery.js"; const idFor = (value: string) => createHash("sha1").update(value).digest("hex").slice(0, 12); +/** The git facts this service needs, injectable so workspace policy is testable without a real repo. */ +export interface WorkspaceGitPort { + isGitRepository(path: string): Promise; + discoverGitWorktrees(path: string): Promise; +} + +const realGit: WorkspaceGitPort = { isGitRepository, discoverGitWorktrees }; + export class WorkspaceService { + constructor(private readonly git: WorkspaceGitPort = realGit) {} + async list(project: Project): Promise { - const isGitRepo = await isGitRepository(project.path); + const isGitRepo = await this.git.isGitRepository(project.path); if (!isGitRepo) { return [this.single(project, false)]; } - const worktrees = await discoverGitWorktrees(project.path); + const worktrees = this.selectable(await this.git.discoverGitWorktrees(project.path), project); if (worktrees.length === 0) return [this.single(project, true)]; return worktrees.map((worktree) => { @@ -30,6 +40,16 @@ export class WorkspaceService { }); } + /** + * Git keeps listing a linked worktree after its checkout directory is deleted outside PI WEB, + * marking it `prunable`. Such an entry is not a usable workspace, so it is hidden rather than + * offered as a selectable ghost. Listing stays read-only: we never run `git worktree prune`. + * The project's own path is always kept so a project cannot end up with no workspace at all. + */ + private selectable(worktrees: GitWorktreeInfo[], project: Project): GitWorktreeInfo[] { + return worktrees.filter((worktree) => worktree.prunable !== true || worktree.path === project.path); + } + private single(project: Project, isGitRepo: boolean): Workspace { return { id: idFor(`${project.id}:${project.path}`),