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:
@@ -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"
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WorktreeEntry> | 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"], {
|
||||
|
||||
Reference in New Issue
Block a user