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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-12 17:25:10 +09:00
parent 2210997c89
commit c9d30f8be3
3 changed files with 175 additions and 2 deletions
+111 -1
View File
@@ -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<typeof spyOn>
@@ -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<typeof spyOn>
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([])
})
})