Archived
fix(workspaces): hide worktrees whose checkout directory is gone
git worktree list keeps reporting a linked worktree after its directory is deleted outside PI WEB, marking it prunable. The workspace list showed those as normal selectable workspaces. The porcelain parser now reads the prunable and locked keys, and WorkspaceService filters prunable linked worktrees out while always keeping the project's own path. Listing stays read-only; git worktree prune is never run.
This commit is contained in:
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,10 @@ export interface GitWorktreeInfo {
|
|||||||
branch?: string;
|
branch?: string;
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
detached?: 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<boolean> {
|
export async function isGitRepository(path: string): Promise<boolean> {
|
||||||
@@ -22,6 +26,14 @@ export async function isGitRepository(path: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
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);
|
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
||||||
|
|
||||||
return chunks.map((chunk) => {
|
return chunks.map((chunk) => {
|
||||||
@@ -33,6 +45,8 @@ export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInf
|
|||||||
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
|
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
|
||||||
if (key === "bare") info.bare = true;
|
if (key === "bare") info.bare = true;
|
||||||
if (key === "detached") info.detached = true;
|
if (key === "detached") info.detached = true;
|
||||||
|
if (key === "prunable") info.prunable = true;
|
||||||
|
if (key === "locked") info.locked = true;
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}).filter((w) => w.path);
|
}).filter((w) => w.path);
|
||||||
|
|||||||
@@ -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 })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { Project } from "../types.js";
|
import type { Project } from "../types.js";
|
||||||
import type { Workspace } 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);
|
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<boolean>;
|
||||||
|
discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGit: WorkspaceGitPort = { isGitRepository, discoverGitWorktrees };
|
||||||
|
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
|
constructor(private readonly git: WorkspaceGitPort = realGit) {}
|
||||||
|
|
||||||
async list(project: Project): Promise<Workspace[]> {
|
async list(project: Project): Promise<Workspace[]> {
|
||||||
const isGitRepo = await isGitRepository(project.path);
|
const isGitRepo = await this.git.isGitRepository(project.path);
|
||||||
if (!isGitRepo) {
|
if (!isGitRepo) {
|
||||||
return [this.single(project, false)];
|
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)];
|
if (worktrees.length === 0) return [this.single(project, true)];
|
||||||
|
|
||||||
return worktrees.map((worktree) => {
|
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 {
|
private single(project: Project, isGitRepo: boolean): Workspace {
|
||||||
return {
|
return {
|
||||||
id: idFor(`${project.id}:${project.path}`),
|
id: idFor(`${project.id}:${project.path}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user