diff --git a/docs/code/configuration.md b/docs/code/configuration.md index 0301782..20cb9f4 100644 --- a/docs/code/configuration.md +++ b/docs/code/configuration.md @@ -322,6 +322,18 @@ Everything in the output directory is a debug artifact and safe to delete. Retry └── attachments/ # Jira attachments ``` +## Worktree Isolation + +Task runs execute in a disposable git worktree so your current work is never modified. See the [Worktree Isolation](./worktree-isolation.md) guide for behavior and lifecycle. + +```bash +# Opt out and run directly in your working directory (or use --no-worktree-isolation) +DEVINTERN_NO_WORKTREE_ISOLATION=1 + +# Store isolated worktrees somewhere else (default: /.devintern-code/worktrees) +# DEVINTERN_TASK_WORKTREE_DIR=/path/for/worktrees +``` + ## Agent Harness Configure which AI agent runs and how long it can work: diff --git a/docs/code/usage.md b/docs/code/usage.md index 988cd4f..a5fea9e 100644 --- a/docs/code/usage.md +++ b/docs/code/usage.md @@ -46,6 +46,9 @@ devintern TASK-123 --max-turns 1000 # Skip automatic commit after AI agent completes devintern TASK-123 --no-auto-commit +# Run directly in the current directory instead of an isolated git worktree +devintern TASK-123 --no-worktree-isolation + # Create pull request after implementation devintern TASK-123 --create-pr @@ -64,6 +67,20 @@ devintern TASK-123 --skip-clarity-check devintern TASK-123 --force ``` +## Isolated Task Worktrees + +By default, each task runs in a disposable git worktree so your uncommitted changes, staged files, and current branch are never touched. Results land on the task's `feature/` branch; the worktree is removed automatically on success, failure, and interruption. + +```bash +# Default: isolated (recommended) +devintern TASK-123 + +# Legacy behavior: run directly in your working directory +devintern TASK-123 --no-worktree-isolation +``` + +See [Worktree Isolation](./worktree-isolation.md) for lifecycle details, result access, orphan cleanup, and environment overrides. + ## Markdown File Tasks Pass one or more local `.md` files as arguments. No task tracker credentials are needed: diff --git a/docs/code/worktree-isolation.md b/docs/code/worktree-isolation.md new file mode 100644 index 0000000..3991bc0 --- /dev/null +++ b/docs/code/worktree-isolation.md @@ -0,0 +1,83 @@ +--- +title: "Worktree Isolation" +description: "Task runs execute in a disposable git worktree so your current work is never modified or lost" +section: "Code" +order: 4 +dateModified: 2026-08-26 +--- + +# Worktree Isolation + +When you run a task in a git repository, devintern executes it inside a **disposable git worktree** instead of your working directory. Your uncommitted changes, staged files, stashes, and current branch are never modified β€” even though the task pipeline itself runs branch creation and git cleanup steps. + +```bash +cd ~/projects/my-app # dirty working tree? doesn't matter +git status # e.g. modified files, staged work, mid-feature branch +devintern MYAPP-456 # runs in .devintern-code/worktrees/devintern-task-myapp-456-- +``` + +During the run you will see: + +``` +🏝️ Running isolated in worktree: /home/you/projects/my-app/.devintern-code/worktrees/devintern-task-myapp-456-4242-1756... + Based on 'origin/main'. Your working directory stays untouched. +``` + +## What is protected + +| State | Protected how | +| ---------------------------- | -------------------------------------------------------------------------- | +| Uncommitted edits | Never touched; the task operates on a separate checkout | +| Staged files / index | Untouched | +| Current branch | The feature branch for the task is created **in the worktree**, not yours | +| Stashes | Untouched (the legacy in-place flow parks changes in a stash; isolation avoids that entirely) | +| Startup sync | Runs as a fetch-only update of `origin/*` instead of pulling into your branch | + +## Where results go + +The worktree is removed when the task finishes β€” successfully or not β€” but your results survive: + +- **Commits live on the task's `feature/` branch** in your repository. Commits are stored in the shared git object database, so deleting the worktree keeps them. Check out with: + + ```bash + git checkout feature/myapp-456 + ``` + +- **Uncommitted agent work is committed before removal** (`feat: implement ` after successful runs, a `wip(devintern): ...` commit after failed/interrupted runs). If a commit is impossible (e.g. failing git hooks), the remaining diff is saved to `{output-dir}/{task-key}/worktree-changes.patch` and printed β€” apply it with `git apply`. + +With `--no-auto-commit`, isolation respects your intent and never creates automatic commits on teardown; uncommitted worktree changes are preserved as the patch file instead. + +## Lifecycle + +1. **Create** β€” a detached worktree is added from `origin/` (falls back to the local target branch, then `HEAD`), under `/.devintern-code/worktrees/--`. +2. **Run** β€” the process moves into the worktree; feature branch creation, the agent run, commits, and optional PR creation all happen there. +3. **Preserve & remove** β€” pending changes are committed (or patched), then the worktree is removed via `git worktree remove --force` with an `rmSync` fallback and a `prune`. Teardown is best-effort: a cleanup failure never masks the task's actual result. + +Cleanup runs on every exit path: + +- Normal completion and handled failures (per-task in batch mode too) +- `Ctrl+C` / `SIGTERM` β€” synchronous cleanup before tracker reporting +- Crashes and internal `process.exit()` calls β€” via a shutdown guard +- Hard kills (SIGKILL, power loss) β€” the next run **sweeps orphans**: worktree directories whose embedded creator pid is no longer alive (plus an age backstop) are removed automatically + +Concurrent executions are safe: each run gets its own uniquely named directory, and running tasks' worktrees are never swept. + +## Configuration discovery + +The tool's state directory (`.devintern-code`) belongs to your repository, so each worktree gets a `.devintern-code` symlink back to it. Settings, analytics identity, and durable state (`queue.db`: webhook queue, retry bookkeeping, run records) keep resolving to the real thing. The `.devintern-code` pattern is registered in your repository's local `.git/info/exclude` (never pushed), which hides both the state directory and the worktrees from `git status` and keeps them out of `git add -A`. + +## Non-git directories + +Running outside a git repository is supported: devintern prints a notice and processes the task directly in the current directory (no branch creation happens anyway). Nothing is created or cleaned up. + +## Opting out + +| Mechanism | Usage | +| --------------- | --------------------------------------------------------- | +| CLI flag | `--no-worktree-isolation` | +| Environment | `DEVINTERN_NO_WORKTREE_ISOLATION=1` | +| Custom location | `DEVINTERN_TASK_WORKTREE_DIR=/path/for/worktrees` | + +Opting out restores the legacy in-place behavior, including its pre-branch stash/clean of your working tree β€” prefer keeping isolation on. + +`--no-git` skips all git features entirely, so isolation is skipped with it. diff --git a/packages/code/CLAUDE.md b/packages/code/CLAUDE.md index bda1227..5825e61 100644 --- a/packages/code/CLAUDE.md +++ b/packages/code/CLAUDE.md @@ -81,6 +81,7 @@ Everything under the output directory is a write-only debug artifact. Durable st - **Runtime**: Bun (required for bun:sqlite in webhook queue) - **Git branches**: `feature/{task-key-lowercase}` naming convention +- **Task isolation** (`lib/worktree-isolation.ts`): interactive task runs execute in a disposable linked worktree under `.devintern-code/worktrees/--` so the user's checkout is never touched. The process cwd moves into the worktree (injectable `chdir` β€” tests must never really chdir); a `.devintern-code` symlink back to the repo's state dir plus a pinned `WEBHOOK_QUEUE_DB` keep lazy config/db lookups working despite config discovery stopping at the worktree's `.git`. Teardown preserves uncommitted work as commits on the task branch (patch fallback in the output dir), removes the worktree via git-removeβ†’rmSyncβ†’prune, and runs on success/failure/signals (`gracefulShutdown` hook) and `process.exit` (exit-event guard); crashed-run orphans are swept by embedded-pid liveness. Opt out: `--no-worktree-isolation` / `DEVINTERN_NO_WORKTREE_ISOLATION`; location override: `DEVINTERN_TASK_WORKTREE_DIR` - **Claude execution**: Spawns subprocess with `-p --dangerously-skip-permissions` for implementation; internal analysis-only spawns (clarity check, estimation) currently use the same unattended path (`PREFER_READONLY_ANALYSIS` is off in `lib/analysis-mode.ts` β€” harness ask/plan modes often return unusable stdout). The read-only prefer + fallback path is kept for re-enable later - **JIRA integration**: Posts summaries in Atlassian Document Format - **Webhook isolation**: Sequential queue + branch-scoped worktrees at `/tmp/devintern-review-worktree-/` diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 2a77141..bc47290 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -83,6 +83,12 @@ import { shouldSkipRetry } from "./lib/retry-gate"; import { formatProcessingFailureMarkdown } from "./lib/trackers/shared/markdown-comment-formatter"; import { parseGitHubPrUrl, recordAgentPrFromUrl } from "./lib/worker-state"; import { Utils } from "./lib/utils"; +import { + cleanupActiveWorktreeIsolation, + enterTaskWorktreeIsolation, + isWorktreeIsolationActive, +} from "./lib/worktree-isolation"; +import type { WorktreeIsolationHandle } from "./lib/worktree-isolation"; import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./lib/git-hook-fixer"; import { runAutoReviewLoop } from "./lib/auto-review-loop"; import { isAutomatedEnvironment } from "./lib/env-detector"; @@ -169,6 +175,7 @@ interface ProgramOptions { verbose: boolean; maxTurns: string; autoCommit: boolean; + worktreeIsolation: boolean; // --no-worktree-isolation runs in the user's checkout skipClarityCheck: boolean; // New option to skip clarity check createPr: boolean; // New option to create pull request prTargetBranch: string; // Target branch for PR @@ -1491,6 +1498,10 @@ program .option("-v, --verbose", "Verbose output") .option("--max-turns ", "Maximum number of turns for Agent", "500") .option("--no-auto-commit", "Skip automatic git commit after Agent completes") + .option( + "--no-worktree-isolation", + "Run directly in the current directory instead of an isolated git worktree (your uncommitted changes are then subject to the task's git cleanup)", + ) .option("--skip-clarity-check", "Skip running Agent for clarity assessment") .option("--create-pr", "Create pull request after implementation") .option( @@ -2417,18 +2428,36 @@ async function main(): Promise { } } - console.log("\nπŸ“₯ Pulling latest changes from remote..."); - const pullResult = await Utils.pullLatestChanges(options.prTargetBranch, { - verbose: options.verbose, - }); + // With worktree isolation active, tasks never run in this checkout β€” + // fetch only. A pull here would fast-forward the user's own branch, + // which isolation promises not to touch. + if (await isWorktreeIsolationActive(options.git && options.worktreeIsolation !== false)) { + console.log("\nπŸ“₯ Fetching latest changes from remote..."); + const fetchResult = await Utils.executeGitCommand(["fetch", "origin", "--prune"], { + verbose: options.verbose, + timeoutMs: 60_000, + }); - if (pullResult.success) { - console.log(`βœ… ${pullResult.message}`); + if (fetchResult.success) { + console.log("βœ… Fetched latest changes from remote"); + } else { + console.log(`⚠️ Could not fetch from remote: ${fetchResult.error}`); + console.log(" Continuing with locally known state...\n"); + } } else { - // Don't fail the entire workflow if pull fails - just warn the user - console.log(`⚠️ ${pullResult.message}`); - console.log(" Continuing without pulling latest changes..."); - console.log(" You may want to pull manually before processing tasks.\n"); + console.log("\nπŸ“₯ Pulling latest changes from remote..."); + const pullResult = await Utils.pullLatestChanges(options.prTargetBranch, { + verbose: options.verbose, + }); + + if (pullResult.success) { + console.log(`βœ… ${pullResult.message}`); + } else { + // Don't fail the entire workflow if pull fails - just warn the user + console.log(`⚠️ ${pullResult.message}`); + console.log(" Continuing without pulling latest changes..."); + console.log(" You may want to pull manually before processing tasks.\n"); + } } } @@ -2639,15 +2668,46 @@ async function main(): Promise { for (let i = 0; i < tasksToProcess.length; i++) { const taskKey = tasksToProcess[i]; + // Each task runs in its own disposable worktree so the user's checkout + // (uncommitted changes, staged files, current branch) is never touched. + // Non-git directories and opt-outs return null and run in place; a + // thrown entry (unwritable worktree root, failed base-ref fetch) must + // degrade the same way instead of aborting the remaining batch tasks. + let isolation: WorktreeIsolationHandle | null = null; + if (options.git && options.worktreeIsolation !== false) { + try { + isolation = await enterTaskWorktreeIsolation({ + taskKey, + targetBranch: options.prTargetBranch, + autoCommit: options.autoCommit, + patchDir: join(resolveOutputDir(), taskKey.toLowerCase()), + verbose: options.verbose, + }); + } catch (error) { + console.warn( + `⚠️ Could not isolate β€” running in your working directory (${(error as Error).message}).`, + ); + console.warn(" Commit or stash to be safe."); + } + } + + let succeeded = false; try { await processSingleTask(taskKey, i, tasksToProcess.length); results.successful++; + succeeded = true; if (i < tasksToProcess.length - 1) { console.log("\n" + "=".repeat(80)); console.log("⏭️ Moving to next task...\n"); } } catch (error) { + // Finish explicitly: the handling below may process.exit(), which + // would never reach the cleanup after this catch block. finish() is + // idempotent, so the call after this block stays safe for the + // non-exiting paths too. + isolation?.finish("failed"); + // Usage limit is account-global: abort the remaining batch instead of // hammering tasks that would all fail. Exit 0 so the scheduler retries // next window without marking the run failed. @@ -2671,6 +2731,9 @@ async function main(): Promise { console.log("⚠️ Continuing with remaining tasks...\n"); } + + // No-op when the catch block already finished this isolation. + isolation?.finish(succeeded ? "completed" : "failed"); } // Print summary for batch operations @@ -4462,6 +4525,9 @@ async function gracefulShutdown(signal: "SIGINT" | "SIGTERM", exitCode: number): if (shuttingDown) return; shuttingDown = true; console.log(`\n\n⚠️ Received ${signal}, cleaning up...`); + // Remove the isolated task worktree first (synchronous, local git ops) so a + // slow tracker round-trip can never leave it orphaned behind the exit timer. + cleanupActiveWorktreeIsolation(); // If a task is mid-flight, tell the tracker it was interrupted instead of // leaving it silently in "In Progress" with no PR and no feedback. const context = activeTaskContext; diff --git a/packages/code/src/lib/worktree-isolation.ts b/packages/code/src/lib/worktree-isolation.ts new file mode 100644 index 0000000..07236b0 --- /dev/null +++ b/packages/code/src/lib/worktree-isolation.ts @@ -0,0 +1,795 @@ +/** + * Task-execution isolation in disposable git worktrees (DEV-90). + * + * When the CLI processes a task in a git repository, the whole per-task + * pipeline runs with the process cwd moved into a throwaway linked worktree + * under `/.devintern-code/worktrees/--` instead of the + * user's checkout. The user's uncommitted changes, staged files, index, and + * current branch are never touched; `createFeatureBranch`'s destructive + * cleanup (`reset --hard`, `clean -fd`) happens in the throwaway tree, which + * is exactly why fleet mode already runs tasks in worktrees. + * + * Lifecycle: + * - create: detached worktree from `origin/` (fallback ``, + * then HEAD), branched later by the pipeline's own `createFeatureBranch` + * - preserve: before removal, uncommitted worktree changes are committed to + * the feature branch (commits survive worktree removal β€” they live in the + * shared object store); if a commit is impossible a patch file is written + * to the task output directory instead; when both fail the directory is + * renamed to `.unsaved` and left for manual recovery instead of + * being destroyed + * - teardown: `git worktree remove --force` β†’ `rmSync` fallback β†’ `prune` + * (the same ladder as `removeReviewWorktree` / `removeTaskWorktree`) + * + * Teardown runs on success, failure, and interruption: the pipeline's + * `process.exit()` calls and fatal signals bypass `finally` blocks, so an + * exit-event guard plus a hook in `gracefulShutdown` perform the same + * synchronous cleanup. Worktrees orphaned by SIGKILL/power loss are swept by + * PID-liveness on the next run (dir names embed the creating pid); dirty + * orphans are kept as `.unsaved` rather than destroyed. + * + * Config continuity: config discovery walks up from cwd but stops at the + * first `.git` entry β€” which includes every linked worktree's `.git` file. + * A `.devintern-code` symlink inside the worktree points back at the repo's + * shared state directory so settings.json/analytics resolve unchanged, and + * `WEBHOOK_QUEUE_DB` pins queue.db so run records/retry state stay durable. + * The pattern `.devintern-code` is registered in `.git/info/exclude` so the + * symlink and worktree dirs never show up in `git status` or get swept into + * `git add -A`. + * + * All git calls here take explicit `{ cwd }` arguments (never rely on the + * ambient cwd) so tests can exercise the module without `process.chdir`, + * which is banned by tests/setup/guard-git-state.ts. + */ + +import { spawnSync } from "child_process"; +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { join, dirname, isAbsolute } from "path"; + +import { Utils } from "./utils"; + +/** Base directory override for isolated task worktrees (tests / power users). */ +export const WORKTREE_ISOLATION_DIR_ENV = "DEVINTERN_TASK_WORKTREE_DIR"; + +/** Set to disable isolation entirely; tasks then run in the user's checkout. */ +export const WORKTREE_ISOLATION_DISABLE_ENV = "DEVINTERN_NO_WORKTREE_ISOLATION"; + +/** While a task runs isolated, holds the active worktree path (recursion guard). */ +export const WORKTREE_ISOLATION_MARKER_ENV = "DEVINTERN_TASK_WORKTREE"; + +const WORKTREE_NAME_PREFIX = "devintern-task"; + +/** + * Orphan backstop: entries older than this are swept even when their embedded + * pid happens to look alive (pid reuse). Far above any legitimate agent run. + */ +const ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +/** Patch file written to the task output dir when changes can't be committed. */ +export const WORKTREE_PATCH_FILE_NAME = "worktree-changes.patch"; + +export type IsolationOutcome = "completed" | "failed" | "interrupted"; + +interface GitSyncResult { + success: boolean; + output: string; + error?: string; +} + +/** Injectable process seam so tests never need real `process.chdir`. */ +export interface WorktreeIsolationDeps { + chdir: (directory: string) => void; + cwd: () => string; + /** Injectable symlink so tests can simulate environments without symlink support. */ + symlink?: typeof symlinkSync; +} + +export interface EnterTaskWorktreeOptions { + /** Task key; used for the worktree dir name and commit messages */ + taskKey: string; + /** Optional task summary for commit messages */ + taskSummary?: string; + /** Branch to base the worktree on (defaults to HEAD) */ + targetBranch?: string; + /** + * Mirrors --no-auto-commit: when false, uncommitted worktree changes are + * preserved as a patch file instead of an automatic commit. + */ + autoCommit: boolean; + /** Task output directory; receives the fallback patch file */ + patchDir: string; + verbose?: boolean; +} + +export interface WorktreeIsolationHandle { + readonly worktreePath: string; + readonly repoRoot: string; + readonly originalCwd: string; + /** + * Preserve results and remove the worktree. Idempotent; safe to call from + * both normal control flow and signal/exit paths. + */ + finish(outcome: IsolationOutcome): void; +} + +/** Currently-active handle, for cleanup from signal handlers. */ +let activeHandle: WorktreeIsolation | null = null; + +const defaultDeps: WorktreeIsolationDeps = { + chdir: (directory: string) => process.chdir(directory), + cwd: () => process.cwd(), +}; + +/** Filesystem-safe path segment for a task key (repo-manager convention). */ +export function sanitizeTaskKeyForPath(taskKey: string): string { + return ( + taskKey + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "task" + ); +} + +export interface ParsedWorktreeName { + pid: number; + createdAt: number; +} + +/** + * Parse `---` worktree dir names. + * + * Returns null for anything else (user dirs, other tooling) β€” those are never + * touched by the sweep. + */ +export function parseWorktreeName(name: string): ParsedWorktreeName | null { + const pattern = new RegExp(`^${WORKTREE_NAME_PREFIX}-(?:[a-z0-9._-]+)-(\\d+)-(\\d{13,})$`); + const match = name.match(pattern); + if (!match) { + return null; + } + const pid = Number.parseInt(match[1], 10); + const createdAt = Number.parseInt(match[2], 10); + if (!Number.isFinite(pid) || !Number.isFinite(createdAt)) { + return null; + } + return { pid, createdAt }; +} + +/** Liveness probe shared with LockManager semantics (EPERM counts as alive). */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function runGitSync(args: string[], cwd?: string): GitSyncResult { + try { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.error) { + return { success: false, output: "", error: result.error.message }; + } + if (result.status !== 0) { + return { + success: false, + output: (result.stdout ?? "").trim(), + error: ((result.stderr ?? "") || `git exited with code ${result.status}`).trim(), + }; + } + return { success: true, output: (result.stdout ?? "").trim() }; + } catch (error) { + return { success: false, output: "", error: (error as Error).message }; + } +} + +function captureWorktreeState(worktreePath: string): { dirty: boolean; branchName: string | null } { + const status = runGitSync(["status", "--porcelain"], worktreePath); + // A failed status (index.lock contention, fs error) is unknown state, not a + // clean tree: treat it as dirty so preservation runs instead of teardown + // destroying uncommitted work. + const dirty = !status.success || status.output.length > 0; + const branch = runGitSync(["branch", "--show-current"], worktreePath); + return { + dirty, + branchName: branch.success && branch.output ? branch.output : null, + }; +} + +function writeGitDiff(worktreePath: string, args: string[], patchDir: string): string | null { + try { + const diff = spawnSync("git", args, { + cwd: worktreePath, + encoding: "buffer", + maxBuffer: 256 * 1024 * 1024, + }); + if (diff.status !== 0 || !diff.stdout || diff.stdout.length === 0) { + return null; + } + mkdirSync(patchDir, { recursive: true }); + const patchPath = join(patchDir, WORKTREE_PATCH_FILE_NAME); + writeFileSync(patchPath, diff.stdout); + return patchPath; + } catch { + return null; + } +} + +/** + * Best-effort snapshot of uncommitted changes as a binary-safe patch. + * + * @returns Patch path, or null when nothing could be captured + */ +function writePatchSnapshot(worktreePath: string, patchDir: string): string | null { + // Primary: stage everything (captures untracked files) and diff the index. + runGitSync(["add", "-A"], worktreePath); + const staged = writeGitDiff(worktreePath, ["diff", "--cached", "--binary"], patchDir); + if (staged) { + return staged; + } + // Last resort when staging or reading the index failed (index lock held by + // another process, non-zero diff exit): tracked changes against HEAD, + // which needs no index writes at all. + return writeGitDiff(worktreePath, ["diff", "HEAD", "--binary"], patchDir); +} + +/** + * Preserve uncommitted worktree changes so they survive worktree removal. + * + * Policy by outcome: + * - completed: implementation commit with hooks (falls back to --no-verify) + * - failed/interrupted: WIP commit with --no-verify (hooks may hang or fail + * mid-interrupt; no arbitrary hook code on the teardown path) + * - autoCommit=false: no automatic commits at all β€” patch only + * + * Anything still dirty afterwards (hook mutations, refused commits) ends up + * in the patch snapshot. + */ +function preserveWorktreeChanges(options: { + worktreePath: string; + taskKey: string; + taskSummary?: string; + outcome: IsolationOutcome; + autoCommit: boolean; + patchDir: string; +}): { committed: boolean; patchPath?: string } { + const { worktreePath } = options; + const state = captureWorktreeState(worktreePath); + if (!state.dirty) { + return { committed: false }; + } + + // A detached HEAD (interrupt before the pipeline created the feature + // branch) has nowhere durable to receive a commit β€” patch instead. + let mayCommit = options.autoCommit && state.branchName !== null; + if (mayCommit) { + const message = + options.outcome === "completed" + ? `feat: implement ${options.taskKey}${ + options.taskSummary ? ` - ${options.taskSummary}` : "" + }` + : `wip(devintern): preserve incomplete work on ${options.taskKey}`; + runGitSync(["add", "-A"], worktreePath); + let commit = runGitSync(["commit", "-m", message], worktreePath); + if (!commit.success) { + commit = runGitSync(["commit", "--no-verify", "-m", message], worktreePath); + } + if (commit.success && !captureWorktreeState(worktreePath).dirty) { + return { committed: true }; + } + } + + const patchPath = writePatchSnapshot(worktreePath, options.patchDir); + return { committed: false, ...(patchPath ? { patchPath } : {}) }; +} + +/** + * Remove a worktree directory and its registration: git remove β†’ rmSync + * fallback β†’ prune. Never throws; a failed cleanup must not mask the task's + * result. + */ +function teardownWorktreeSync(worktreePath: string, repoCwd: string): void { + if (existsSync(worktreePath)) { + const removed = runGitSync(["worktree", "remove", "--force", worktreePath], repoCwd); + if (!removed.success) { + try { + rmSync(worktreePath, { recursive: true, force: true }); + } catch { + /* best-effort: prune below drops the dangling registration */ + } + } + } + runGitSync(["worktree", "prune"], repoCwd); +} + +/** + * Register `.devintern-code` in the repo's local `.git/info/exclude` so the + * shared-state symlink inside each worktree (and the worktrees themselves, + * which live under `.devintern-code/worktrees/`) stay out of `git status` + * and `git add -A`. Applies to every linked worktree via the common git dir. + */ +function ensureStateDirIgnored(repoRoot: string): void { + try { + const excludePath = runGitSync(["rev-parse", "--git-path", "info/exclude"], repoRoot); + if (!excludePath.success || !excludePath.output) { + return; + } + const absoluteExcludePath = resolveFrom(repoRoot, excludePath.output); + const existing = existsSync(absoluteExcludePath) + ? readFileSync(absoluteExcludePath, "utf8") + : ""; + if (existing.split("\n").some((line) => line.trim() === ".devintern-code")) { + return; + } + mkdirSync(dirname(absoluteExcludePath), { recursive: true }); + const separator = existing && !existing.endsWith("\n") ? "\n" : ""; + appendFileSync( + absoluteExcludePath, + `${separator}\n# @devintern/code local state (not committed)\n.devintern-code\n`, + ); + } catch { + // Hiding state is a convenience, never a requirement. + } +} + +/** Resolve `path` relative to `from` without depending on process.cwd(). */ +function resolveFrom(from: string, path: string): string { + return isAbsolute(path) ? path : join(from, path); +} + +/** + * Remove worktrees left behind by crashed runs (SIGKILL, power loss). + * + * An entry is stale when its embedded pid is dead or it predates the orphan + * age backstop. Entries belonging to live processes (concurrent runs) and + * unrecognized names are never touched. A stale entry holding uncommitted + * changes (a failed status counts as unknown-dirty) is renamed to + * `.unsaved` for manual recovery instead of being destroyed β€” a run + * killed minutes ago can hold hours of agent work; only clean stale entries + * are removed outright. + * + * @returns Paths that were removed + */ +export function sweepOrphanedTaskWorktrees( + worktreeRoot: string, + repoCwd: string, + options?: { verbose?: boolean }, +): string[] { + let entries: string[]; + try { + entries = readdirSync(worktreeRoot); + } catch { + return []; + } + + const removed: string[] = []; + for (const entry of entries) { + const parsed = parseWorktreeName(entry); + if (!parsed) { + continue; + } + let ageTooOld = false; + try { + ageTooOld = Date.now() - statSync(join(worktreeRoot, entry)).mtimeMs > ORPHAN_MAX_AGE_MS; + } catch { + continue; + } + if (isPidAlive(parsed.pid) && !ageTooOld) { + continue; + } + const stalePath = join(worktreeRoot, entry); + if (options?.verbose) { + console.log(` 🧹 Sweeping orphaned task worktree: ${stalePath}`); + } + // No preservation step ever ran for a crashed run, so the sweep must not + // assume a dead-pid tree is disposable. Mirror finish()'s salvage policy: + // unknown or dirty trees are renamed aside β€” the suffix also escapes the + // name-pattern check, so future sweeps cannot rematch them β€” with only + // the registration pruned; teardown deletes clean trees only. + if (captureWorktreeState(stalePath).dirty) { + const salvagedPath = `${stalePath}.unsaved`; + try { + renameSync(stalePath, salvagedPath); + console.warn(`\n⚠️ Orphaned task worktree had uncommitted changes; kept for recovery:`); + console.warn(` ${salvagedPath}`); + console.warn(" Recover your files from it manually, then delete it."); + } catch { + console.warn(`⚠️ Could not move dirty orphan aside; left intact at: ${stalePath}`); + } + // The salvaged copy no longer sits at its registered path; drop only + // the stale registration (prune never touches directories that exist). + runGitSync(["worktree", "prune"], repoCwd); + if (!existsSync(stalePath)) { + removed.push(stalePath); + } + continue; + } + teardownWorktreeSync(stalePath, repoCwd); + if (!existsSync(stalePath)) { + removed.push(stalePath); + } + } + return removed; +} + +/** True when the user/environment opted out of worktree isolation. */ +export function isWorktreeIsolationDisabled(): boolean { + return /^(1|true|yes|on)$/i.test(process.env[WORKTREE_ISOLATION_DISABLE_ENV] ?? ""); +} + +/** + * Whether a task run started right now would be isolated. + * + * Used by startup to swap the user-checkout `git pull` for a fetch-only sync + * (a pull would move the user's branch; a fetch only updates origin/*). + */ +export async function isWorktreeIsolationActive(gitEnabled: boolean): Promise { + if (!gitEnabled || isWorktreeIsolationDisabled()) { + return false; + } + if (process.env[WORKTREE_ISOLATION_MARKER_ENV]) { + return false; + } + return Utils.isGitRepository(); +} + +class WorktreeIsolation implements WorktreeIsolationHandle { + private phase: "active" | "finished" = "active"; + + constructor( + readonly worktreePath: string, + readonly repoRoot: string, + readonly originalCwd: string, + private readonly deps: WorktreeIsolationDeps, + private readonly options: EnterTaskWorktreeOptions, + ) {} + + finish(outcome: IsolationOutcome): void { + if (this.phase === "finished") { + return; + } + this.phase = "finished"; + if (activeHandle === this) { + activeHandle = null; + } + delete process.env[WORKTREE_ISOLATION_MARKER_ENV]; + + // Move out of the tree before deleting it; all git calls use explicit + // cwds, this only protects incidental cwd-relative work downstream. + try { + this.deps.chdir(this.originalCwd); + } catch { + /* original cwd deleted underneath us β€” nothing sensible to restore */ + } + + let preserved: { committed: boolean; patchPath?: string } = { committed: false }; + let branchName: string | null = null; + let unsalvageable = false; + if (existsSync(this.worktreePath)) { + // Drop the shared-state link before anything inspects the tree: the + // agent run has ended, so config continuity is no longer needed, and a + // link surviving into `git status --porcelain` / `git add -A` whenever + // the .git/info/exclude registration failed would commit a + // machine-specific absolute symlink onto the feature branch. rmSync + // also covers the copied-settings fallback directory and never + // traverses a symlink, so the real state dir stays intact either way. + try { + rmSync(join(this.worktreePath, ".devintern-code"), { force: true, recursive: true }); + } catch { + /* best-effort: absent, already removed, or unwritable */ + } + const entryState = captureWorktreeState(this.worktreePath); + branchName = entryState.branchName; + preserved = preserveWorktreeChanges({ + worktreePath: this.worktreePath, + taskKey: this.options.taskKey, + taskSummary: this.options.taskSummary, + outcome, + autoCommit: this.options.autoCommit, + patchDir: this.options.patchDir, + }); + // Dirty on entry but neither a commit nor a patch captured it: tearing + // down would destroy that work. Keep the raw directory instead β€” the + // failure modes here (unwritable patch dir, refused commits, index + // lock) correlate with unusual environments where the user most needs + // the bytes. Renaming outside the parsed worktree-name pattern also + // keeps the orphan sweep from reaping it later; recovery costs one + // leftover directory, deletion loses the work permanently. + unsalvageable = entryState.dirty && !preserved.committed && !preserved.patchPath; + if (unsalvageable) { + console.warn(`\n⚠️ WARNING: uncommitted changes could NOT be saved from:`); + console.warn(` ${this.worktreePath}`); + console.warn(" Commit and patch preservation both failed."); + } + if (unsalvageable) { + const salvagedPath = `${this.worktreePath}.unsaved`; + try { + renameSync(this.worktreePath, salvagedPath); + console.warn(` Directory kept intact at: ${salvagedPath}`); + console.warn(" Recover your files from it manually, then delete it."); + } catch { + console.warn(` Directory left intact at: ${this.worktreePath}`); + } + // The salvaged copy is unregistered; drop only the stale registration + // (prune never touches directories that still exist). + runGitSync(["worktree", "prune"], this.repoRoot); + } else { + teardownWorktreeSync(this.worktreePath, this.repoRoot); + } + } + + this.report(outcome, preserved, branchName, unsalvageable); + } + + private report( + outcome: IsolationOutcome, + preserved: { committed: boolean; patchPath?: string }, + branchName: string | null, + unsalvageable: boolean, + ): void { + if (unsalvageable) { + console.log("\n🧹 Isolated worktree kept for manual recovery"); + } else if (outcome === "completed") { + console.log("\n🧹 Isolated worktree cleaned up"); + } else if (outcome === "interrupted") { + console.log("\n🧹 Interrupted run: isolated worktree cleaned up"); + } else { + console.log("\n🧠 Task failed: isolated worktree cleaned up"); + } + + if (branchName && !unsalvageable) { + console.log( + ` Results live on branch '${branchName}' β€” check out with: git checkout ${branchName}`, + ); + } else if (!unsalvageable) { + console.log(` Your working directory was not modified.`); + } + if (preserved.patchPath) { + console.log( + ` Uncommitted changes saved to: ${preserved.patchPath} (apply with: git apply )`, + ); + } + if (preserved.committed && branchName) { + console.log(" The worktree itself was removed after preserving your results."); + } + } +} + +// Synchronous teardown for paths that bypass finally blocks (process.exit, +// fatal signals). Installed once per process; the guard is a no-op whenever +// no isolated run is active, so it never needs to be unregistered. +let exitGuardInstalled = false; + +function installExitGuard(): void { + if (exitGuardInstalled) { + return; + } + exitGuardInstalled = true; + process.on("exit", () => { + if (activeHandle) { + console.log("\n🧹 Cleaning up isolated task worktree..."); + activeHandle.finish("interrupted"); + } + }); +} + +/** Cleanup entry point for gracefulShutdown (SIGINT/SIGTERM). */ +export function cleanupActiveWorktreeIsolation(): void { + if (activeHandle) { + activeHandle.finish("interrupted"); + } +} + +/** + * Link the repo's shared state directory into the worktree so lazy lookups + * (settings.json, analytics id, queue.db) resolve to the real thing despite + * config discovery stopping at the worktree's `.git` entry. + * + * Falls back to copying settings.json when symlinks are unavailable, and + * `WEBHOOK_QUEUE_DB` is pinned before any early return β€” including on a + * genuinely first run where the shared dir does not exist yet. Lazy queue.db + * writers create missing parent directories, so an unpinned first run would + * create the database inside the disposable worktree and teardown would + * destroy the run records/retry bookkeeping in it. + */ +function linkSharedConfigDir( + worktreePath: string, + repoRoot: string, + symlink: typeof symlinkSync, +): void { + const sharedDir = join(repoRoot, ".devintern-code"); + const linkPath = join(worktreePath, ".devintern-code"); + + process.env.WEBHOOK_QUEUE_DB = join(sharedDir, "queue.db"); + + if (!existsSync(sharedDir)) { + return; + } + + try { + symlink(sharedDir, linkPath, "dir"); + return; + } catch { + // Symlinks unavailable (Windows without dev mode, restricted fs): fall + // back to copying the small settings file so behavior stays consistent. + } + + try { + mkdirSync(linkPath, { recursive: true }); + const settingsPath = join(sharedDir, "settings.json"); + if (existsSync(settingsPath)) { + cpSync(settingsPath, join(linkPath, "settings.json")); + } + } catch { + /* degraded-but-working: env-based lookups still resolve */ + } +} + +async function resolveBaseRef( + targetBranch: string | undefined, + repoRoot: string, + verbose?: boolean, +): Promise { + if (targetBranch) { + // Best-effort refresh; offline repos fall back to whatever refs exist. + await Utils.executeGitCommand(["fetch", "origin", targetBranch], { + cwd: repoRoot, + timeoutMs: 15_000, + verbose, + }); + if (await Utils.gitRefExists(`refs/remotes/origin/${targetBranch}`, { cwd: repoRoot })) { + return `origin/${targetBranch}`; + } + if (await Utils.gitRefExists(`refs/heads/${targetBranch}`, { cwd: repoRoot })) { + return targetBranch; + } + } + return "HEAD"; +} + +/** + * Per-process cache over {@link resolveBaseRef}, keyed by repo root + branch. + * + * Batch mode resolves the same base for every task; without this each entry + * pays a network fetch round-trip against an unchanged remote. The promise is + * cached (not the value) so concurrent entries share one in-flight resolution, + * and a rejection evicts the entry so a later task can retry. + */ +const baseRefCache = new Map>(); + +async function resolveBaseRefCached( + targetBranch: string | undefined, + repoRoot: string, + verbose?: boolean, +): Promise { + const key = `${repoRoot}\u0000${targetBranch ?? ""}`; + let resolved = baseRefCache.get(key); + if (!resolved) { + resolved = resolveBaseRef(targetBranch, repoRoot, verbose); + baseRefCache.set(key, resolved); + try { + await resolved; + } catch (error) { + baseRefCache.delete(key); + throw error; + } + } + return resolved; +} + +/** + * Put the current task run into an isolated git worktree. + * + * Moves the process cwd into a fresh disposable worktree (via the injected + * {@link WorktreeIsolationDeps.chdir}) and returns a handle whose `finish()` + * preserves results and removes the worktree. Returns null β€” leaving the cwd + * untouched β€” when isolation does not apply: + * - disabled via flag/env (`--no-worktree-isolation`, DEVINTERN_NO_WORKTREE_ISOLATION) + * - already inside an isolated run (marker env var) + * - not a git repository (graceful fallback with a notice) + * - worktree creation fails (loud warning, legacy in-place run) + * + * Callers MUST call `finish()` exactly once per returned handle; see the + * lifecycle notes at the top of this file. + */ +export async function enterTaskWorktreeIsolation( + options: EnterTaskWorktreeOptions, + deps: WorktreeIsolationDeps = defaultDeps, +): Promise { + if (isWorktreeIsolationDisabled()) { + return null; + } + if (process.env[WORKTREE_ISOLATION_MARKER_ENV]) { + return null; + } + + const originalCwd = deps.cwd(); + + if (!(await Utils.isGitRepository(originalCwd))) { + console.log( + `ℹ️ ${originalCwd} is not a git repository β€” running directly without worktree isolation.`, + ); + return null; + } + + const toplevel = await Utils.executeGitCommand(["rev-parse", "--show-toplevel"], { + cwd: originalCwd, + }); + if (!toplevel.success || !toplevel.output) { + console.log("⚠️ Could not locate the repository root β€” running directly without isolation."); + return null; + } + const repoRoot = toplevel.output; + + const worktreeRoot = + process.env[WORKTREE_ISOLATION_DIR_ENV] || join(repoRoot, ".devintern-code", "worktrees"); + mkdirSync(worktreeRoot, { recursive: true }); + + ensureStateDirIgnored(repoRoot); + sweepOrphanedTaskWorktrees(worktreeRoot, repoRoot, { verbose: options.verbose }); + + const baseRef = await resolveBaseRefCached(options.targetBranch, repoRoot, options.verbose); + + const safeKey = sanitizeTaskKeyForPath(options.taskKey); + let worktreePath = join( + worktreeRoot, + `${WORKTREE_NAME_PREFIX}-${safeKey}-${process.pid}-${Date.now()}`, + ); + while (existsSync(worktreePath)) { + worktreePath = join( + worktreeRoot, + `${WORKTREE_NAME_PREFIX}-${safeKey}-${process.pid}-${Date.now() + 1}`, + ); + } + + const added = await Utils.executeGitCommand( + ["worktree", "add", "--detach", worktreePath, baseRef], + { cwd: repoRoot, verbose: options.verbose }, + ); + if (!added.success) { + console.warn(`⚠️ Could not create an isolated worktree (${added.error}).`); + console.warn(" Running directly in your working directory β€” commit or stash to be safe."); + return null; + } + + linkSharedConfigDir(worktreePath, repoRoot, deps.symlink ?? symlinkSync); + + const handle = new WorktreeIsolation(worktreePath, repoRoot, originalCwd, deps, options); + activeHandle = handle; + installExitGuard(); + process.env[WORKTREE_ISOLATION_MARKER_ENV] = worktreePath; + + try { + deps.chdir(worktreePath); + } catch (error) { + delete process.env[WORKTREE_ISOLATION_MARKER_ENV]; + activeHandle = null; + handle.finish("failed"); + console.warn(`⚠️ Could not enter the isolated worktree: ${(error as Error).message}`); + console.warn(" Running directly in your working directory."); + return null; + } + + console.log(`🏝️ Running isolated in worktree: ${worktreePath}`); + console.log(` Based on '${baseRef}'. Your working directory stays untouched.`); + + return handle; +} + +/** Test seam: whether an isolated run is currently active in this process. */ +export function hasActiveWorktreeIsolation(): boolean { + return activeHandle !== null; +} diff --git a/packages/code/tests/worktree-isolation.test.ts b/packages/code/tests/worktree-isolation.test.ts new file mode 100644 index 0000000..c79a60e --- /dev/null +++ b/packages/code/tests/worktree-isolation.test.ts @@ -0,0 +1,979 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "fs"; +import { basename, join } from "path"; +import { tmpdir } from "os"; + +import { + WORKTREE_ISOLATION_DISABLE_ENV, + WORKTREE_ISOLATION_DIR_ENV, + WORKTREE_ISOLATION_MARKER_ENV, + WORKTREE_PATCH_FILE_NAME, + cleanupActiveWorktreeIsolation, + enterTaskWorktreeIsolation, + hasActiveWorktreeIsolation, + isPidAlive, + isWorktreeIsolationActive, + isWorktreeIsolationDisabled, + parseWorktreeName, + sanitizeTaskKeyForPath, + sweepOrphanedTaskWorktrees, +} from "../src/lib/worktree-isolation"; +import type { WorktreeIsolationDeps } from "../src/lib/worktree-isolation"; +import { prepareQueueDbDirectory } from "../src/lib/webhook-queue"; +import { Utils } from "../src/lib/utils"; + +function git(cwd: string, command: string): string { + return execSync(`git ${command}`, { cwd, encoding: "utf8" }).trim(); +} + +/** Like {@link git} but returns null instead of throwing on non-zero exit. */ +function gitOk(cwd: string, command: string): string | null { + try { + return execSync(`git ${command}`, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +describe("worktree isolation", () => { + let rootDir: string; + let originDir: string; + let repoDir: string; + let patchDir: string; + let worktreeRoot: string; + + // Environment is process-global; save and restore around every test. + const savedEnv: Record = {}; + + const chdirs: string[] = []; + const deps: WorktreeIsolationDeps = { + chdir: (directory: string) => { + chdirs.push(directory); + }, + cwd: () => repoDir, + }; + + beforeEach(() => { + rootDir = join(tmpdir(), `wt-iso-${Date.now()}-${Math.random().toString(36).slice(2)}`); + originDir = join(rootDir, "origin"); + repoDir = join(rootDir, "repo"); + patchDir = join(rootDir, "output", "dev-90"); + worktreeRoot = join(rootDir, "worktrees"); + mkdirSync(originDir, { recursive: true }); + mkdirSync(repoDir, { recursive: true }); + chdirs.length = 0; + + git(originDir, "init -b main"); + git(originDir, 'config user.name "Fixture User"'); + git(originDir, 'config user.email "fixture@example.com"'); + writeFileSync(join(originDir, "README.md"), "# Fixture\n"); + git(originDir, "add ."); + git(originDir, 'commit -m "Initial commit"'); + + git(repoDir, `clone file://${originDir} .`); + git(repoDir, 'config user.name "Local User"'); + git(repoDir, 'config user.email "local@example.com"'); + + for (const key of [ + WORKTREE_ISOLATION_DIR_ENV, + WORKTREE_ISOLATION_DISABLE_ENV, + WORKTREE_ISOLATION_MARKER_ENV, + "WEBHOOK_QUEUE_DB", + ]) { + savedEnv[key] = process.env[key]; + } + process.env[WORKTREE_ISOLATION_DIR_ENV] = worktreeRoot; + delete process.env[WORKTREE_ISOLATION_DISABLE_ENV]; + delete process.env[WORKTREE_ISOLATION_MARKER_ENV]; + delete process.env.WEBHOOK_QUEUE_DB; + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + function dirtyUserTree(): void { + writeFileSync(join(repoDir, "README.md"), "# Locally modified\n"); + writeFileSync(join(repoDir, "staged.txt"), "staged by user\n"); + writeFileSync(join(repoDir, "untracked.txt"), "untracked user file\n"); + git(repoDir, "add staged.txt"); + } + + function snapshotUserTree(): { branch: string; head: string; status: string } { + return { + branch: git(repoDir, "branch --show-current"), + head: git(repoDir, "rev-parse HEAD"), + status: git(repoDir, "status --porcelain"), + }; + } + + test("unit helpers: sanitize, parse, pid liveness", () => { + expect(sanitizeTaskKeyForPath("DEV-90")).toBe("dev-90"); + expect(sanitizeTaskKeyForPath("Feature/Big Thing!")).toBe("feature-big-thing"); + expect(sanitizeTaskKeyForPath("///")).toBe("task"); + + const parsed = parseWorktreeName(`devintern-task-dev-90-4242-${Date.now()}`); + expect(parsed?.pid).toBe(4242); + expect(parseWorktreeName("some-other-dir")).toBeNull(); + expect(parseWorktreeName("devintern-task-dev-90-notanumber")).toBeNull(); + + expect(isPidAlive(process.pid)).toBe(true); + const exited = Bun.spawnSync(["true"]); + expect(isPidAlive(exited.pid)).toBe(false); + }); + + test("isWorktreeIsolationDisabled honors the opt-out env var", () => { + expect(isWorktreeIsolationDisabled()).toBe(false); + process.env[WORKTREE_ISOLATION_DISABLE_ENV] = "1"; + expect(isWorktreeIsolationDisabled()).toBe(true); + }); + + test("isWorktreeIsolationActive gates on git, opt-out env values, and nesting", async () => { + const originalIsGitRepository = Utils.isGitRepository; + try { + // Git disabled wins over everything, even inside a repository. + Utils.isGitRepository = async () => true; + expect(await isWorktreeIsolationActive(false)).toBe(false); + + // Enabled git + repository + no opt-out: isolation will engage. + expect(await isWorktreeIsolationActive(true)).toBe(true); + + // Opt-out env values are case-insensitive. + for (const value of ["1", "true", "yes", "on", "TRUE", "Yes", "ON", "oN"]) { + process.env[WORKTREE_ISOLATION_DISABLE_ENV] = value; + expect(await isWorktreeIsolationActive(true)).toBe(false); + } + delete process.env[WORKTREE_ISOLATION_DISABLE_ENV]; + + // Anything else is not an opt-out and falls through to the repo check. + for (const value of ["0", "false", "no", "off", "", "enabled"]) { + process.env[WORKTREE_ISOLATION_DISABLE_ENV] = value; + expect(await isWorktreeIsolationActive(true)).toBe(true); + } + delete process.env[WORKTREE_ISOLATION_DISABLE_ENV]; + + // A nested isolated run never reports isolation as active again. + process.env[WORKTREE_ISOLATION_MARKER_ENV] = join(rootDir, "nested"); + expect(await isWorktreeIsolationActive(true)).toBe(false); + delete process.env[WORKTREE_ISOLATION_MARKER_ENV]; + + // Outside a repository there is nothing to isolate. + Utils.isGitRepository = async () => false; + expect(await isWorktreeIsolationActive(true)).toBe(false); + } finally { + Utils.isGitRepository = originalIsGitRepository; + } + }); + + test("enter creates a detached worktree and leaves the user's tree untouched", async () => { + dirtyUserTree(); + const before = snapshotUserTree(); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + + expect(handle).not.toBeNull(); + const worktreePath = handle!.worktreePath; + expect(worktreePath.startsWith(worktreeRoot)).toBe(true); + expect(existsSync(join(worktreePath, ".git"))).toBe(true); + + // Detached at the fetched base ref, not the user's (dirty) state. + expect(git(worktreePath, "rev-parse HEAD")).toBe(git(repoDir, "rev-parse origin/main")); + expect(gitOk(worktreePath, "symbolic-ref -q HEAD")).toBeNull(); + + // The user's checkout: same branch, same HEAD, identical status. + expect(snapshotUserTree()).toEqual(before); + + // The pipeline cwd was moved into the worktree via the injected seam. + expect(chdirs).toEqual([worktreePath]); + expect(process.env[WORKTREE_ISOLATION_MARKER_ENV]).toBe(worktreePath); + expect(hasActiveWorktreeIsolation()).toBe(true); + + handle!.finish("completed"); + }); + + test("shared config dir is reachable inside the worktree", async () => { + mkdirSync(join(repoDir, ".devintern-code"), { recursive: true }); + writeFileSync( + join(repoDir, ".devintern-code", "settings.json"), + JSON.stringify({ jira: { projects: { DEV: {} } } }), + ); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const linkPath = join(handle!.worktreePath, ".devintern-code"); + + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(readFileSync(join(linkPath, "settings.json"), "utf8")).toContain("DEV"); + // Durable state must never resolve inside the disposable worktree. + expect(process.env.WEBHOOK_QUEUE_DB).toBe(join(repoDir, ".devintern-code", "queue.db")); + + handle!.finish("completed"); + }); + + test("settings-copy fallback engages when symlink creation fails", async () => { + mkdirSync(join(repoDir, ".devintern-code"), { recursive: true }); + writeFileSync( + join(repoDir, ".devintern-code", "settings.json"), + JSON.stringify({ jira: { projects: { DEV: {} } } }), + ); + + const failingSymlinkDeps: WorktreeIsolationDeps = { + ...deps, + symlink: () => { + throw new Error("simulated: symlinks unavailable"); + }, + }; + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + failingSymlinkDeps, + ); + const linkPath = join(handle!.worktreePath, ".devintern-code"); + + // A real directory holding a copied settings file, not a link. + expect(lstatSync(linkPath).isSymbolicLink()).toBe(false); + expect(lstatSync(linkPath).isDirectory()).toBe(true); + expect(readFileSync(join(linkPath, "settings.json"), "utf8")).toContain("DEV"); + // Durable state stays pinned outside the disposable directory regardless. + expect(process.env.WEBHOOK_QUEUE_DB).toBe(join(repoDir, ".devintern-code", "queue.db")); + + handle!.finish("completed"); + + // Teardown removes the copied-settings directory safely. + expect(existsSync(handle!.worktreePath)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(handle!.worktreePath); + }); + + test("first run pins WEBHOOK_QUEUE_DB even when the shared state dir does not exist yet", async () => { + // No .devintern-code in the repo: a genuinely first run. The pin must + // engage before the shared-dir existence check, or lazy queue.db + // initialization would create the database inside the disposable + // worktree and teardown would destroy it. + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-FIRST", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + expect(handle).not.toBeNull(); + const worktreePath = handle!.worktreePath; + + expect(process.env.WEBHOOK_QUEUE_DB).toBe(join(repoDir, ".devintern-code", "queue.db")); + + // A lazy durable-state writer resolves through the pinned env and creates + // missing parent directories under the repo β€” never inside the worktree. + prepareQueueDbDirectory(process.env.WEBHOOK_QUEUE_DB!); + expect(existsSync(join(repoDir, ".devintern-code"))).toBe(true); + expect(existsSync(join(worktreePath, ".devintern-code"))).toBe(false); + + handle!.finish("completed"); + }); + + test(".devintern-code is registered in .git/info/exclude exactly once across repeated enters", async () => { + const first = await enterTaskWorktreeIsolation( + { taskKey: "DEV-EXCL-A", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + first!.finish("completed"); + + const second = await enterTaskWorktreeIsolation( + { taskKey: "DEV-EXCL-B", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + second!.finish("completed"); + + const exclude = readFileSync(join(repoDir, ".git", "info", "exclude"), "utf8"); + const entries = exclude.split("\n").filter((line) => line.trim() === ".devintern-code"); + expect(entries).toHaveLength(1); + expect(exclude).toContain("# @devintern/code local state (not committed)"); + }); + + test("finish(completed) commits pending changes to the feature branch and removes the worktree", async () => { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + // Simulate what the pipeline does inside the worktree. + git(worktreePath, "checkout -b feature/dev-90"); + writeFileSync(join(worktreePath, "feature.txt"), "implemented\n"); + + handle!.finish("completed"); + + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(worktreePath); + expect(git(repoDir, "rev-parse --verify refs/heads/feature/dev-90")).toBeTruthy(); + expect(git(repoDir, "show --name-only --format= refs/heads/feature/dev-90")).toContain( + "feature.txt", + ); + expect(git(repoDir, "log -1 --format=%s refs/heads/feature/dev-90")).toBe( + "feat: implement DEV-90", + ); + + // cwd restored through the injected seam; marker cleared. + expect(chdirs[chdirs.length - 1]).toBe(repoDir); + expect(process.env[WORKTREE_ISOLATION_MARKER_ENV]).toBeUndefined(); + expect(hasActiveWorktreeIsolation()).toBe(false); + }); + + test("finish(failed) preserves WIP with a no-verify commit and removes the worktree", async () => { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + git(worktreePath, "checkout -b feature/dev-90"); + writeFileSync(join(worktreePath, "partial.txt"), "half done\n"); + + handle!.finish("failed"); + + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "log -1 --format=%s refs/heads/feature/dev-90")).toBe( + "wip(devintern): preserve incomplete work on DEV-90", + ); + expect(git(repoDir, "show --name-only --format= refs/heads/feature/dev-90")).toContain( + "partial.txt", + ); + }); + + test("commit-hook refusal falls back to a --no-verify preservation commit", async () => { + mkdirSync(join(repoDir, ".git", "hooks"), { recursive: true }); + const hookPath = join(repoDir, ".git", "hooks", "pre-commit"); + writeFileSync(hookPath, "#!/bin/sh\necho 'hook refuses' >&2\nexit 1\n"); + chmodSync(hookPath, 0o755); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + git(worktreePath, "checkout -b feature/dev-90"); + writeFileSync(join(worktreePath, "hooked.txt"), "implemented despite hooks\n"); + + handle!.finish("completed"); + + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "log -1 --format=%s refs/heads/feature/dev-90")).toBe( + "feat: implement DEV-90", + ); + expect(git(repoDir, "show --name-only --format= refs/heads/feature/dev-90")).toContain( + "hooked.txt", + ); + }); + + test("detached-head teardown patches instead of committing (mayCommit=false)", async () => { + const headsBefore = git(repoDir, 'for-each-ref refs/heads --format="%(refname)"'); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + // Interrupt landed before the pipeline created the feature branch. + expect(gitOk(worktreePath, "symbolic-ref -q HEAD")).toBeNull(); + writeFileSync(join(worktreePath, "stranded.txt"), "never got a branch\n"); + + handle!.finish("completed"); + + expect(existsSync(worktreePath)).toBe(false); + // Nowhere durable to receive a commit: branch list unchanged... + expect(git(repoDir, 'for-each-ref refs/heads --format="%(refname)"')).toBe(headsBefore); + // ...and the stranded work is recoverable from the patch instead. + const patchPath = join(patchDir, WORKTREE_PATCH_FILE_NAME); + expect(existsSync(patchPath)).toBe(true); + expect(readFileSync(patchPath, "utf8")).toContain("stranded.txt"); + }); + + test("autoCommit=false preserves uncommitted work as a patch instead of committing", async () => { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: false, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + git(worktreePath, "checkout -b feature/dev-90"); + const baseSha = git(worktreePath, "rev-parse HEAD"); + writeFileSync(join(worktreePath, "manual.txt"), "user wants manual control\n"); + + handle!.finish("completed"); + + expect(existsSync(worktreePath)).toBe(false); + // No automatic commit: branch tip unchanged... + expect(git(repoDir, "rev-parse refs/heads/feature/dev-90")).toBe(baseSha); + // ...but the work is recoverable from the patch file. + const patchPath = join(patchDir, WORKTREE_PATCH_FILE_NAME); + expect(existsSync(patchPath)).toBe(true); + expect(readFileSync(patchPath, "utf8")).toContain("diff --git"); + expect(readFileSync(patchPath, "utf8")).toContain("manual.txt"); + }); + + test("preserved commits and patches never include the .devintern-code link", async () => { + // The shared-state dir must exist so the worktree gets the symlink; the + // link is dropped before preservation inspects the tree, and the + // .git/info/exclude registration is the second layer that also keeps the + // worktrees themselves out of status/add. + mkdirSync(join(repoDir, ".devintern-code"), { recursive: true }); + writeFileSync(join(repoDir, ".devintern-code", "settings.json"), "{}"); + + const committed = await enterTaskWorktreeIsolation( + { taskKey: "DEV-LINK-A", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + git(committed!.worktreePath, "checkout -b feature/dev-link-a"); + writeFileSync(join(committed!.worktreePath, "code-a.txt"), "x\n"); + committed!.finish("completed"); + + const names = git(repoDir, "show --name-only --format= refs/heads/feature/dev-link-a"); + expect(names).toContain("code-a.txt"); + expect(names).not.toContain(".devintern-code"); + + const patched = await enterTaskWorktreeIsolation( + { taskKey: "DEV-LINK-B", targetBranch: "main", autoCommit: false, patchDir }, + deps, + ); + writeFileSync(join(patched!.worktreePath, "code-b.txt"), "y\n"); + patched!.finish("completed"); + + const patch = readFileSync(join(patchDir, WORKTREE_PATCH_FILE_NAME), "utf8"); + expect(patch).toContain("code-b.txt"); + expect(patch).not.toContain(".devintern-code"); + }); + + test("a failed exclude registration never stages or commits the state link", async () => { + // The shared-state dir must exist so the worktree gets the symlink; a + // read-only info/exclude makes ensureStateDirIgnored fail silently, so + // git sees the link as untracked unless finish() removes it up front. + mkdirSync(join(repoDir, ".devintern-code"), { recursive: true }); + writeFileSync(join(repoDir, ".devintern-code", "settings.json"), "{}"); + + const excludePath = join(repoDir, ".git", "info", "exclude"); + mkdirSync(join(repoDir, ".git", "info"), { recursive: true }); + writeFileSync(excludePath, "# user patterns\n"); + chmodSync(excludePath, 0o444); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-NOEXCL", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + expect(handle).not.toBeNull(); + const worktreePath = handle!.worktreePath; + expect(lstatSync(join(worktreePath, ".devintern-code")).isSymbolicLink()).toBe(true); + + git(worktreePath, "checkout -b feature/dev-noexcl"); + writeFileSync(join(worktreePath, "work.txt"), "real work\n"); + + handle!.finish("completed"); + + // Normal preservation flow β€” no phantom dirtiness, no salvage detour. + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "log -1 --format=%s refs/heads/feature/dev-noexcl")).toBe( + "feat: implement DEV-NOEXCL", + ); + const names = git(repoDir, "show --name-only --format= refs/heads/feature/dev-noexcl"); + expect(names).toContain("work.txt"); + expect(names).not.toContain(".devintern-code"); + }); + + test("interrupted runs clean up synchronously and finish stays idempotent", async () => { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + writeFileSync(join(worktreePath, "torn.txt"), "mid-write when Ctrl+C landed\n"); + + cleanupActiveWorktreeIsolation(); + + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(worktreePath); + expect(hasActiveWorktreeIsolation()).toBe(false); + + // A late finish (e.g. a finally block after the signal handler) is a no-op. + expect(() => handle!.finish("completed")).not.toThrow(); + expect(existsSync(worktreePath)).toBe(false); + }); + + test("sweepOrphanedTaskWorktrees removes crashed-run entries but never live ones", () => { + const exited = Bun.spawnSync(["true"]); + const orphan = join(worktreeRoot, `devintern-task-dev-90-${exited.pid}-${Date.now()}`); + const live = join(worktreeRoot, `devintern-task-dev-91-${process.pid}-${Date.now()}`); + const stranger = join(worktreeRoot, "my-own-notes"); + mkdirSync(orphan, { recursive: true }); + mkdirSync(live, { recursive: true }); + mkdirSync(stranger, { recursive: true }); + writeFileSync(join(orphan, "leftover.txt"), "from a killed run\n"); + + const removed = sweepOrphanedTaskWorktrees(worktreeRoot, repoDir); + + expect(removed).toEqual([orphan]); + expect(existsSync(orphan)).toBe(false); + expect(existsSync(live)).toBe(true); + expect(existsSync(stranger)).toBe(true); + }); + + test("sweep reaps ancient entries even when their embedded pid is still alive", () => { + // pid reuse can make a crashed run's entry look live; entries older than + // the orphan-age backstop are swept regardless. Backdate past the + // 7-day ORPHAN_MAX_AGE_MS via mtimes. + const ancient = join(worktreeRoot, `devintern-task-dev-92-${process.pid}-${Date.now()}`); + mkdirSync(ancient, { recursive: true }); + writeFileSync(join(ancient, "leftover.txt"), "older than any legitimate run\n"); + const longGone = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + utimesSync(ancient, longGone, longGone); + + const freshLive = join(worktreeRoot, `devintern-task-dev-93-${process.pid}-${Date.now()}`); + mkdirSync(freshLive, { recursive: true }); + + const removed = sweepOrphanedTaskWorktrees(worktreeRoot, repoDir); + + expect(removed).toEqual([ancient]); + expect(existsSync(ancient)).toBe(false); + expect(existsSync(freshLive)).toBe(true); + }); + + test("sweep keeps dirty crashed-run worktrees as .unsaved instead of destroying them", () => { + // A real linked worktree left behind by a SIGKILLed run, holding + // uncommitted agent work: the sweep must salvage it, not tear it down. + const exited = Bun.spawnSync(["true"]); + mkdirSync(worktreeRoot, { recursive: true }); + const orphan = join(worktreeRoot, `devintern-task-dev-94-${exited.pid}-${Date.now()}`); + git(repoDir, `worktree add --detach ${orphan} main`); + writeFileSync(join(orphan, "hours-of-work.txt"), "uncommitted\n"); + + const removed = sweepOrphanedTaskWorktrees(worktreeRoot, repoDir); + + const salvagedPath = `${orphan}.unsaved`; + expect(removed).toEqual([orphan]); + expect(existsSync(orphan)).toBe(false); + expect(existsSync(salvagedPath)).toBe(true); + expect(readFileSync(join(salvagedPath, "hours-of-work.txt"), "utf8")).toBe("uncommitted\n"); + // The suffix escapes the name-pattern check: future sweeps cannot reap it. + expect(parseWorktreeName(basename(salvagedPath))).toBeNull(); + // Only the stale registration was dropped. + expect(git(repoDir, "worktree list")).not.toContain(orphan); + + rmSync(salvagedPath, { recursive: true, force: true }); + }); + + test("sweep still tears down clean crashed-run worktrees", () => { + const exited = Bun.spawnSync(["true"]); + mkdirSync(worktreeRoot, { recursive: true }); + const orphan = join(worktreeRoot, `devintern-task-dev-95-${exited.pid}-${Date.now()}`); + git(repoDir, `worktree add --detach ${orphan} main`); + + const removed = sweepOrphanedTaskWorktrees(worktreeRoot, repoDir); + + expect(removed).toEqual([orphan]); + expect(existsSync(orphan)).toBe(false); + expect(existsSync(`${orphan}.unsaved`)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(orphan); + }); + + test("sequential task runs get distinct worktrees", async () => { + const first = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + first!.finish("completed"); + + const second = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + second!.finish("completed"); + + expect(first!.worktreePath).not.toBe(second!.worktreePath); + expect(existsSync(first!.worktreePath)).toBe(false); + expect(existsSync(second!.worktreePath)).toBe(false); + }); + + test("non-git directories fall back gracefully to an in-place run", async () => { + const plainDir = join(rootDir, "plain"); + mkdirSync(plainDir, { recursive: true }); + deps.cwd = () => plainDir; + try { + const consoleLogs: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + consoleLogs.push(args.join(" ")); + return; + }; + try { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", autoCommit: true, patchDir }, + deps, + ); + expect(handle).toBeNull(); + expect(consoleLogs.some((line) => line.includes("not a git repository"))).toBe(true); + } finally { + console.log = originalLog; + } + expect(existsSync(worktreeRoot)).toBe(false); + expect(chdirs).toEqual([]); + } finally { + deps.cwd = () => repoDir; + } + }); + + test("opted-out runs skip isolation entirely", async () => { + process.env[WORKTREE_ISOLATION_DISABLE_ENV] = "1"; + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + expect(handle).toBeNull(); + expect(chdirs).toEqual([]); + expect(existsSync(worktreeRoot)).toBe(false); + }); + + test("base ref resolution falls back to the local branch, then HEAD", async () => { + // A branch that origin has never seen: the fetch is a silent no-op and + // resolution must land on refs/heads/topic. + git(repoDir, "checkout -b topic"); + writeFileSync(join(repoDir, "topic-only.txt"), "local ahead\n"); + git(repoDir, "add ."); + git(repoDir, "commit -m local-only-topic"); + git(repoDir, "checkout main"); + const topicSha = git(repoDir, "rev-parse refs/heads/topic"); + + const localFallback = await enterTaskWorktreeIsolation( + { taskKey: "DEV-FALLBACK", targetBranch: "topic", autoCommit: true, patchDir }, + deps, + ); + expect(localFallback).not.toBeNull(); + expect(git(localFallback!.worktreePath, "rev-parse HEAD")).toBe(topicSha); + localFallback!.finish("completed"); + + // Neither origin nor the local repo has this branch: fall back to HEAD. + const headSha = git(repoDir, "rev-parse HEAD"); + const ghostFallback = await enterTaskWorktreeIsolation( + { taskKey: "DEV-GHOST", targetBranch: "ghost", autoCommit: true, patchDir }, + deps, + ); + expect(ghostFallback).not.toBeNull(); + expect(git(ghostFallback!.worktreePath, "rev-parse HEAD")).toBe(headSha); + ghostFallback!.finish("completed"); + }); + + test("consecutive batch entries share one base-ref resolution (single fetch)", async () => { + const originalExecute = Utils.executeGitCommand; + let fetchCount = 0; + Utils.executeGitCommand = async (args, options) => { + if (args[0] === "fetch") { + fetchCount++; + } + return originalExecute(args, options); + }; + + try { + const first = await enterTaskWorktreeIsolation( + { taskKey: "DEV-CACHE-A", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + first!.finish("completed"); + + const second = await enterTaskWorktreeIsolation( + { taskKey: "DEV-CACHE-B", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + second!.finish("completed"); + + // Same repo root + branch: the second entry reuses the cached + // resolution instead of paying another network round-trip. + expect(fetchCount).toBe(1); + expect(first!.worktreePath).not.toBe(second!.worktreePath); + } finally { + Utils.executeGitCommand = originalExecute; + } + }); + + test("a rejected base-ref resolution evicts the cache so the next entry retries", async () => { + const originalExecute = Utils.executeGitCommand; + let fetchAttempts = 0; + Utils.executeGitCommand = async (args, options) => { + if (args[0] === "fetch") { + fetchAttempts++; + if (fetchAttempts === 1) { + throw new Error("simulated network outage"); + } + } + return originalExecute(args, options); + }; + + try { + await expect( + enterTaskWorktreeIsolation( + { taskKey: "DEV-EVICT-A", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ), + ).rejects.toThrow("simulated network outage"); + expect(hasActiveWorktreeIsolation()).toBe(false); + + // The failed promise was evicted: this entry resolves afresh and succeeds. + const retried = await enterTaskWorktreeIsolation( + { taskKey: "DEV-EVICT-B", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + expect(retried).not.toBeNull(); + expect(fetchAttempts).toBe(2); + retried!.finish("completed"); + } finally { + Utils.executeGitCommand = originalExecute; + } + }); + + test("unsalvageable dirty trees warn loudly and keep the directory for manual recovery", async () => { + // autoCommit=false skips commits; a regular file where the patch + // directory belongs makes every patch write fail too. + mkdirSync(join(rootDir, "output"), { recursive: true }); + writeFileSync(patchDir, "blocks mkdirSync\n"); + + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: false, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + writeFileSync(join(worktreePath, "doomed.txt"), "cannot be saved\n"); + + const logs: string[] = []; + const originalLog = console.log; + const originalWarn = console.warn; + console.log = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + console.warn = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + try { + handle!.finish("completed"); + } finally { + console.log = originalLog; + console.warn = originalWarn; + } + + // Nothing is destroyed: the tree is moved aside under a name the orphan + // sweep cannot match, its registration pruned, contents intact. + const salvagedPath = `${worktreePath}.unsaved`; + expect(existsSync(worktreePath)).toBe(false); + expect(existsSync(salvagedPath)).toBe(true); + expect(readFileSync(join(salvagedPath, "doomed.txt"), "utf8")).toBe("cannot be saved\n"); + expect(git(repoDir, "worktree list")).not.toContain(worktreePath); + expect(parseWorktreeName(basename(salvagedPath))).toBeNull(); + + const output = logs.join("\n"); + expect(output).toContain("could NOT be saved"); + expect(output).toContain(worktreePath); + expect(output).toContain(salvagedPath); + expect(output).not.toContain("Your working directory was not modified"); + }); + + test("a failed git status is treated as dirty: uncommitted work is never destroyed", async () => { + const handle = await enterTaskWorktreeIsolation( + { taskKey: "DEV-90", targetBranch: "main", autoCommit: true, patchDir }, + deps, + ); + const worktreePath = handle!.worktreePath; + + git(worktreePath, "checkout -b feature/dev-90"); + writeFileSync(join(worktreePath, "precious.txt"), "must survive\n"); + + // Corrupt the linked worktree's index so `git status --porcelain` fails + // (models transient index.lock / fs contention): unknown state must route + // into the salvage path, never a clean-tree teardown. + const gitdir = readFileSync(join(worktreePath, ".git"), "utf8") + .trim() + .replace(/^gitdir:\s*/, ""); + writeFileSync(join(gitdir, "index"), Buffer.from("not a real index file")); + + const logs: string[] = []; + const originalLog = console.log; + const originalWarn = console.warn; + console.log = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + console.warn = (...args: unknown[]) => { + logs.push(args.join(" ")); + }; + try { + handle!.finish("completed"); + } finally { + console.log = originalLog; + console.warn = originalWarn; + } + + // Commit and patch both fail against the corrupt index, so the raw tree + // is kept for manual recovery β€” nothing is deleted. + expect(existsSync(`${worktreePath}.unsaved`)).toBe(true); + expect(readFileSync(join(`${worktreePath}.unsaved`, "precious.txt"), "utf8")).toBe( + "must survive\n", + ); + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(worktreePath); + expect(logs.join("\n")).toContain("could NOT be saved"); + }); + + test("process.exit mid-run triggers synchronous teardown via the exit guard", async () => { + const childWorktreeRoot = join(rootDir, "child-worktrees"); + const childPatchDir = join(rootDir, "child-output", "dev-exit"); + const scriptPath = join(rootDir, "exit-guard-child.ts"); + const modulePath = join(import.meta.dir, "..", "src", "lib", "worktree-isolation.ts"); + writeFileSync( + scriptPath, + [ + `import { execSync } from "child_process";`, + `import { mkdirSync, writeFileSync } from "fs";`, + `import { join } from "path";`, + `import { enterTaskWorktreeIsolation } from ${JSON.stringify(modulePath)};`, + ``, + `const [childWorktreeRoot, childPatchDir] = process.argv.slice(2);`, + `process.env.DEVINTERN_TASK_WORKTREE_DIR = childWorktreeRoot;`, + `const handle = await enterTaskWorktreeIsolation(`, + ` { taskKey: "DEV-EXIT", targetBranch: "main", autoCommit: true, patchDir: childPatchDir },`, + ` { chdir: (directory: string) => process.chdir(directory), cwd: () => process.cwd() },`, + `);`, + `if (!handle) {`, + ` throw new Error("isolation did not engage");`, + `}`, + `mkdirSync(childPatchDir, { recursive: true });`, + `writeFileSync(join(childPatchDir, "child-worktree-path.txt"), handle.worktreePath);`, + `execSync("git checkout -b feature/dev-exit", {`, + ` cwd: handle.worktreePath,`, + ` stdio: "ignore",`, + `});`, + `writeFileSync(join(handle.worktreePath, "exit-guard.txt"), "survives\\n");`, + `process.exit(0);`, + ``, + ].join("\n"), + ); + + const proc = Bun.spawnSync([process.execPath, scriptPath, childWorktreeRoot, childPatchDir], { + cwd: repoDir, + stdout: "pipe", + stderr: "pipe", + }); + if (proc.exitCode !== 0) { + console.error(proc.stderr.toString()); + } + expect(proc.exitCode).toBe(0); + + const worktreePath = readFileSync( + join(childPatchDir, "child-worktree-path.txt"), + "utf8", + ).trim(); + expect(parseWorktreeName(basename(worktreePath))).not.toBeNull(); + + // Exit handlers run synchronously before the process dies: no finish() + // call in the child, yet the tree is gone, the registration pruned, and + // the WIP committed. + expect(existsSync(worktreePath)).toBe(false); + expect(git(repoDir, "worktree list")).not.toContain(worktreePath); + expect(git(repoDir, "log -1 --format=%s refs/heads/feature/dev-exit")).toBe( + "wip(devintern): preserve incomplete work on DEV-EXIT", + ); + expect(git(repoDir, "show --name-only --format= refs/heads/feature/dev-exit")).toContain( + "exit-guard.txt", + ); + }, 30_000); + + // End-to-end wiring of the startup sync decision (index.ts): when + // isWorktreeIsolationActive() says a run will be isolated, the user's + // checkout must only ever be fetched β€” never pulled. Tracker I/O is aimed + // at a closed port with retries disabled so each run fails fast right + // after the sync step under assertion. + const CLI_PATH = join(import.meta.dir, "..", "src", "index.ts"); + + /** Process env for the CLI child: tracker stubs, plus explicit isolation vars. */ + function cliChildEnv(extra: Record = {}): Record { + const env: Record = { ...process.env }; + for (const key of [ + WORKTREE_ISOLATION_DIR_ENV, + WORKTREE_ISOLATION_DISABLE_ENV, + WORKTREE_ISOLATION_MARKER_ENV, + "WEBHOOK_QUEUE_DB", + ]) { + delete env[key]; + } + return { + ...env, + JIRA_BASE_URL: "http://127.0.0.1:1", + JIRA_EMAIL: "test@example.com", + JIRA_API_TOKEN: "test-token", + DEVINTERN_FETCH_MAX_RETRIES: "0", + DEVINTERN_SKIP_LICENSE_CHECK: "1", + DEVINTERN_NO_UPDATE: "1", + DEVINTERN_TELEMETRY_DISABLED: "1", + GIT_TERMINAL_PROMPT: "0", + ...extra, + }; + } + + async function runCli(args: string[], extraEnv: Record = {}) { + const signal = AbortSignal.timeout(30_000); + const proc = Bun.spawn({ + cmd: [process.execPath, CLI_PATH, ...args], + cwd: repoDir, + env: cliChildEnv(extraEnv), + stdout: "pipe", + stderr: "pipe", + signal, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited.then((code) => code ?? 0), + ]); + return { stdout, stderr, exitCode, timedOut: signal.aborted }; + } + + test("CLI startup fetches instead of pulling when isolation will engage", async () => { + const result = await runCli(["TEST-123"]); + expect(result.timedOut).toBe(false); + const output = result.stdout + result.stderr; + expect(output).toContain("Fetching latest changes from remote"); + expect(output).not.toContain("Pulling latest changes"); + }, 30_000); + + test("--no-worktree-isolation routes CLI startup back to the pull path", async () => { + const result = await runCli(["TEST-123", "--no-worktree-isolation"]); + expect(result.timedOut).toBe(false); + const output = result.stdout + result.stderr; + expect(output).toContain("Pulling latest changes from remote"); + expect(output).not.toContain("Fetching latest changes"); + }, 30_000); + + test("DEVINTERN_NO_WORKTREE_ISOLATION routes CLI startup back to the pull path", async () => { + const result = await runCli(["TEST-123"], { [WORKTREE_ISOLATION_DISABLE_ENV]: "1" }); + expect(result.timedOut).toBe(false); + const output = result.stdout + result.stderr; + expect(output).toContain("Pulling latest changes from remote"); + expect(output).not.toContain("Fetching latest changes"); + }, 30_000); +});