feat(team-mode): add worktree manager (optional per-member isolation)
This commit is contained in:
@@ -3,3 +3,8 @@
|
||||
- `MemberSchema` needs `.strict()` on the base shape so the discriminatedUnion rejects members that mix `category` and `subagent_type`.
|
||||
- `backendType` and `isActive` defaults are part of the schema contract, so tests should use `toMatchObject` instead of exact object equality.
|
||||
- The eligibility registry must preserve the plan strings verbatim, especially the hard-reject messages for Momus verification.
|
||||
## Task 12 learnings
|
||||
|
||||
- `git worktree remove` can leave prunable entries behind, so pruning after removal keeps the repo index tidy.
|
||||
- For testability, a tiny git command runner hook made git-unavailable coverage simpler than mocking Bun directly.
|
||||
- Detached worktrees need unique temp paths in tests to avoid cross-run collisions.
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./team-worktree"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { findOrphanWorktrees } from "./cleanup"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("given runtime mismatch when findOrphanWorktrees then returns orphan paths", async () => {
|
||||
// given
|
||||
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "team-worktree-orphans-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
await fs.mkdir(path.join(baseDir, "worktrees", "t1", "m1"), { recursive: true })
|
||||
await fs.mkdir(path.join(baseDir, "runtime", "t1"), { recursive: true })
|
||||
await fs.writeFile(path.join(baseDir, "runtime", "t1", "state.json"), JSON.stringify({ status: "deleted" }))
|
||||
|
||||
// when
|
||||
const result = await findOrphanWorktrees(baseDir, {})
|
||||
|
||||
// then
|
||||
expect(result).toEqual([path.join(baseDir, "worktrees", "t1", "m1")])
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
import type { TeamModeConfig } from "./manager"
|
||||
|
||||
async function runGit(args: string[]): Promise<{ code: number; stderr: string }> {
|
||||
const process = Bun.spawn({ cmd: ["git", ...args], stdout: "pipe", stderr: "pipe" })
|
||||
const [exitCode, stderrText] = await Promise.all([process.exited, new Response(process.stderr).text()])
|
||||
return { code: exitCode, stderr: stderrText }
|
||||
}
|
||||
|
||||
export async function removeWorktree(worktreePath: string): Promise<void> {
|
||||
await fs.rm(worktreePath, { recursive: true, force: true })
|
||||
|
||||
const rootLookup = await Bun.spawn({
|
||||
cmd: ["git", "-C", worktreePath, "rev-parse", "--show-superproject-working-tree"],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [rootExitCode, rootStdout] = await Promise.all([
|
||||
rootLookup.exited,
|
||||
new Response(rootLookup.stdout).text(),
|
||||
new Response(rootLookup.stderr).text(),
|
||||
])
|
||||
const result =
|
||||
rootExitCode === 0 && rootStdout.trim().length > 0
|
||||
? await runGit(["-C", rootStdout.trim(), "worktree", "remove", "--force", worktreePath])
|
||||
: await runGit(["worktree", "remove", "--force", worktreePath])
|
||||
|
||||
if (
|
||||
result.code !== 0 &&
|
||||
!result.stderr.includes("not a worktree") &&
|
||||
!result.stderr.includes("not a working tree") &&
|
||||
!result.stderr.includes("already removed")
|
||||
) {
|
||||
throw new Error(result.stderr.trim() || "git worktree remove failed")
|
||||
}
|
||||
|
||||
if (rootExitCode === 0 && rootStdout.trim().length > 0) {
|
||||
await runGit(["-C", rootStdout.trim(), "worktree", "prune"])
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOrphanWorktrees(baseDir: string, _config: TeamModeConfig): Promise<string[]> {
|
||||
const orphanWorktrees: string[] = []
|
||||
const worktreesDir = path.join(baseDir, "worktrees")
|
||||
|
||||
let teamRunDirectories: string[]
|
||||
try {
|
||||
teamRunDirectories = await fs.readdir(worktreesDir)
|
||||
} catch {
|
||||
return orphanWorktrees
|
||||
}
|
||||
|
||||
for (const teamRunId of teamRunDirectories) {
|
||||
const teamRunPath = path.join(worktreesDir, teamRunId)
|
||||
const memberNames = await fs.readdir(teamRunPath).catch(() => [])
|
||||
|
||||
for (const memberName of memberNames) {
|
||||
const worktreePath = path.join(teamRunPath, memberName)
|
||||
const statePath = path.join(baseDir, "runtime", teamRunId, "state.json")
|
||||
|
||||
try {
|
||||
const stateContents = await fs.readFile(statePath, "utf8")
|
||||
const state = JSON.parse(stateContents) as { status?: string }
|
||||
|
||||
if (state.status !== "active" && state.status !== "shutdown_requested") {
|
||||
orphanWorktrees.push(worktreePath)
|
||||
}
|
||||
} catch {
|
||||
orphanWorktrees.push(worktreePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphanWorktrees
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GitUnavailableError, createWorktree, isGitAvailable, validateWorktreeSpec } from "./manager"
|
||||
export { findOrphanWorktrees, removeWorktree } from "./cleanup"
|
||||
@@ -0,0 +1,104 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { GitUnavailableError, createWorktree, setGitCommandRunnerForTests, validateWorktreeSpec } from "./manager"
|
||||
import { removeWorktree } from "./cleanup"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function initGitRepo(): Promise<string> {
|
||||
const repositoryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "team-worktree-"))
|
||||
temporaryDirectories.push(repositoryRoot)
|
||||
Bun.spawnSync(["git", "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
await fs.writeFile(path.join(repositoryRoot, "README.md"), "hello\n")
|
||||
Bun.spawnSync(["git", "add", "README.md"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
Bun.spawnSync(["git", "config", "user.email", "test@example.com"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
Bun.spawnSync(["git", "config", "user.name", "Test User"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
Bun.spawnSync(["git", "commit", "-m", "init"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
return repositoryRoot
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const directory of temporaryDirectories) {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("team-worktree manager", () => {
|
||||
test("given tmp git repo when createWorktree then registers detached worktree", async () => {
|
||||
// given
|
||||
const repositoryRoot = await initGitRepo()
|
||||
const worktreePath = `../worktree-${randomUUID()}`
|
||||
const worktreeDirectory = path.resolve(repositoryRoot, worktreePath)
|
||||
|
||||
// when
|
||||
const resultPath = await createWorktree(repositoryRoot, "t1", "m1", worktreePath, {})
|
||||
|
||||
// then
|
||||
expect(resultPath).toBe(worktreeDirectory)
|
||||
await expect(fs.stat(worktreeDirectory)).resolves.toBeDefined()
|
||||
const listResult = Bun.spawnSync(["git", "worktree", "list"], { cwd: repositoryRoot, stdout: "pipe", stderr: "pipe" })
|
||||
expect(new TextDecoder().decode(listResult.stdout)).toContain(worktreeDirectory)
|
||||
const headResult = Bun.spawnSync(["git", "-C", worktreeDirectory, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" })
|
||||
const repoHeadResult = Bun.spawnSync(["git", "-C", repositoryRoot, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" })
|
||||
expect(new TextDecoder().decode(headResult.stdout).trim()).toBe(new TextDecoder().decode(repoHeadResult.stdout).trim())
|
||||
})
|
||||
|
||||
test("validateWorktreeSpec rejects bare name", () => {
|
||||
// given
|
||||
const worktreePath = "feature-x"
|
||||
|
||||
// when
|
||||
const validate = () => validateWorktreeSpec(worktreePath)
|
||||
|
||||
// then
|
||||
expect(validate).toThrow("worktreePath must be a filesystem path (relative './...', '../...' or absolute '/...')")
|
||||
})
|
||||
|
||||
test("given git unavailable when createWorktree then throws unavailable error", async () => {
|
||||
// given
|
||||
const repositoryRoot = await initGitRepo()
|
||||
setGitCommandRunnerForTests(async (args) => {
|
||||
if (args[0] === "--version") {
|
||||
return { code: 1, stderr: "git missing" }
|
||||
}
|
||||
|
||||
return { code: 0, stderr: "" }
|
||||
})
|
||||
|
||||
// when
|
||||
const create = createWorktree(repositoryRoot, "t1", "m1", `../worktree-${randomUUID()}`, {})
|
||||
|
||||
// then
|
||||
await expect(create).rejects.toBeInstanceOf(GitUnavailableError)
|
||||
setGitCommandRunnerForTests(async (args) => {
|
||||
if (args[0] === "--version") {
|
||||
return { code: 0, stderr: "" }
|
||||
}
|
||||
|
||||
return { code: 0, stderr: "" }
|
||||
})
|
||||
})
|
||||
|
||||
test("given created worktree when removeWorktree then directory disappears", async () => {
|
||||
// given
|
||||
const repositoryRoot = await initGitRepo()
|
||||
const worktreePath = await createWorktree(repositoryRoot, "t1", "m1", `../worktree-${randomUUID()}`, {})
|
||||
|
||||
// when
|
||||
await removeWorktree(worktreePath)
|
||||
|
||||
// then
|
||||
await expect(fs.stat(worktreePath)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from "node:path"
|
||||
|
||||
export type TeamModeConfig = {
|
||||
worktreeBaseDir?: string
|
||||
}
|
||||
|
||||
export class GitUnavailableError extends Error {
|
||||
constructor() {
|
||||
super("git required for worktree members")
|
||||
this.name = "GitUnavailableError"
|
||||
}
|
||||
}
|
||||
|
||||
function countParentSegments(spec: string): number {
|
||||
return spec.split("/").filter((segment) => segment === "..").length
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd?: string): Promise<{ code: number; stderr: string }> {
|
||||
const process = Bun.spawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" })
|
||||
const [exitCode, stderrBytes] = await Promise.all([process.exited, new Response(process.stderr).text()])
|
||||
return { code: exitCode, stderr: stderrBytes }
|
||||
}
|
||||
|
||||
let gitCommandRunner = runGit
|
||||
|
||||
export function setGitCommandRunnerForTests(runner: typeof runGit): void {
|
||||
gitCommandRunner = runner
|
||||
}
|
||||
|
||||
export async function isGitAvailable(): Promise<boolean> {
|
||||
const result = await gitCommandRunner(["--version"])
|
||||
return result.code === 0
|
||||
}
|
||||
|
||||
export function validateWorktreeSpec(spec: string): void {
|
||||
if (!/^(\.\.?\/|\/).+/.test(spec) || countParentSegments(spec) > 2) {
|
||||
throw new Error("worktreePath must be a filesystem path (relative './...', '../...' or absolute '/...')")
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorktree(
|
||||
repoRoot: string,
|
||||
_teamRunId: string,
|
||||
_memberName: string,
|
||||
worktreePath: string,
|
||||
_config: TeamModeConfig,
|
||||
): Promise<string> {
|
||||
validateWorktreeSpec(worktreePath)
|
||||
|
||||
if (!(await isGitAvailable())) {
|
||||
throw new GitUnavailableError()
|
||||
}
|
||||
|
||||
const absolutePath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(repoRoot, worktreePath)
|
||||
const result = await gitCommandRunner(["-C", repoRoot, "worktree", "add", "--detach", absolutePath])
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.trim() || "git worktree add failed")
|
||||
}
|
||||
|
||||
return absolutePath
|
||||
}
|
||||
Reference in New Issue
Block a user