From c9d30f8be3e8ac06cc9c7a9c1ca3eaac4fd82e63 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 17:25:10 +0900 Subject: [PATCH] feat: add porcelain worktree parser with listWorktrees and parseWorktreeListPorcelain Introduce git worktree list --porcelain parsing following upstream opencode patterns. Exports listWorktrees() for full worktree enumeration with branch info alongside existing detectWorktreePath(). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/start-work/index.ts | 2 +- .../start-work/worktree-detector.test.ts | 112 +++++++++++++++++- src/hooks/start-work/worktree-detector.ts | 63 ++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts index ee270861a..56470022e 100644 --- a/src/hooks/start-work/index.ts +++ b/src/hooks/start-work/index.ts @@ -1,4 +1,4 @@ export { HOOK_NAME, createStartWorkHook } from "./start-work-hook" -export { detectWorktreePath } from "./worktree-detector" +export { detectWorktreePath, listWorktrees, parseWorktreeListPorcelain } from "./worktree-detector" export type { ParsedUserRequest } from "./parse-user-request" export { parseUserRequest } from "./parse-user-request" diff --git a/src/hooks/start-work/worktree-detector.test.ts b/src/hooks/start-work/worktree-detector.test.ts index b02d5af1b..d68e99682 100644 --- a/src/hooks/start-work/worktree-detector.test.ts +++ b/src/hooks/start-work/worktree-detector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" import * as childProcess from "node:child_process" -import { detectWorktreePath } from "./worktree-detector" +import { detectWorktreePath, parseWorktreeListPorcelain, listWorktrees } from "./worktree-detector" describe("detectWorktreePath", () => { let execFileSyncSpy: ReturnType @@ -77,3 +77,113 @@ describe("detectWorktreePath", () => { }) }) }) + +describe("parseWorktreeListPorcelain", () => { + test("#given porcelain output with multiple worktrees #when parsing #then returns all entries", () => { + // given + const output = [ + "worktree /home/user/main-repo", + "HEAD abc1234", + "branch refs/heads/main", + "", + "worktree /home/user/worktrees/feature-a", + "HEAD def5678", + "branch refs/heads/feature-a", + "", + ].join("\n") + + // when + const result = parseWorktreeListPorcelain(output) + + // then + expect(result).toEqual([ + { path: "/home/user/main-repo", branch: "main", bare: false }, + { path: "/home/user/worktrees/feature-a", branch: "feature-a", bare: false }, + ]) + }) + + test("#given bare worktree #when parsing #then marks bare flag", () => { + // given + const output = [ + "worktree /home/user/bare-repo", + "HEAD abc1234", + "bare", + "", + ].join("\n") + + // when + const result = parseWorktreeListPorcelain(output) + + // then + expect(result).toEqual([ + { path: "/home/user/bare-repo", branch: undefined, bare: true }, + ]) + }) + + test("#given empty output #when parsing #then returns empty array", () => { + expect(parseWorktreeListPorcelain("")).toEqual([]) + }) + + test("#given output without trailing newline #when parsing #then still captures last entry", () => { + // given + const output = [ + "worktree /repo", + "HEAD abc1234", + "branch refs/heads/dev", + ].join("\n") + + // when + const result = parseWorktreeListPorcelain(output) + + // then + expect(result).toEqual([ + { path: "/repo", branch: "dev", bare: false }, + ]) + }) +}) + +describe("listWorktrees", () => { + let execFileSyncSpy: ReturnType + + beforeEach(() => { + execFileSyncSpy = spyOn(childProcess, "execFileSync").mockImplementation( + ((_file: string, _args: string[]) => "") as typeof childProcess.execFileSync, + ) + }) + + afterEach(() => { + execFileSyncSpy.mockRestore() + }) + + test("#given valid git repo #when listing #then returns parsed worktree entries", () => { + // given + execFileSyncSpy.mockImplementation( + ((_file: string, _args: string[]) => + "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\n") as typeof childProcess.execFileSync, + ) + + // when + const result = listWorktrees("/repo") + + // then + expect(result).toEqual([{ path: "/repo", branch: "main", bare: false }]) + expect(execFileSyncSpy).toHaveBeenCalledWith( + "git", + ["worktree", "list", "--porcelain"], + expect.objectContaining({ cwd: "/repo" }), + ) + }) + + test("#given non-git directory #when listing #then returns empty array", () => { + // given + execFileSyncSpy.mockImplementation((_file: string, _args: string[]) => { + throw new Error("not a git repository") + }) + + // when + const result = listWorktrees("/tmp/not-a-repo") + + // then + expect(result).toEqual([]) + }) +}) diff --git a/src/hooks/start-work/worktree-detector.ts b/src/hooks/start-work/worktree-detector.ts index 74c919593..fe9567b7a 100644 --- a/src/hooks/start-work/worktree-detector.ts +++ b/src/hooks/start-work/worktree-detector.ts @@ -1,5 +1,68 @@ import { execFileSync } from "node:child_process" +export type WorktreeEntry = { + path: string + branch: string | undefined + bare: boolean +} + +export function parseWorktreeListPorcelain(output: string): WorktreeEntry[] { + const lines = output.split("\n").map((line) => line.trim()) + const entries: WorktreeEntry[] = [] + let current: Partial | undefined + + for (const line of lines) { + if (!line) { + if (current?.path) { + entries.push({ + path: current.path, + branch: current.branch, + bare: current.bare ?? false, + }) + } + current = undefined + continue + } + + if (line.startsWith("worktree ")) { + current = { path: line.slice("worktree ".length).trim() } + continue + } + + if (!current) continue + + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "") + } else if (line === "bare") { + current.bare = true + } + } + + if (current?.path) { + entries.push({ + path: current.path, + branch: current.branch, + bare: current.bare ?? false, + }) + } + + return entries +} + +export function listWorktrees(directory: string): WorktreeEntry[] { + try { + const output = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }) + return parseWorktreeListPorcelain(output) + } catch { + return [] + } +} + export function detectWorktreePath(directory: string): string | null { try { return execFileSync("git", ["rev-parse", "--show-toplevel"], {