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`.
|
- `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.
|
- `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.
|
- 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 "./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