From 556ca90d8cf51734acb452b9eb4065eff343d491 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:39:32 +0700 Subject: [PATCH 1/2] feat: implement DEV-90 - Isolate CLI task execution in git worktrees to protect the user's current work --- docs/code/configuration.md | 12 + docs/code/usage.md | 17 + docs/code/worktree-isolation.md | 83 +++ packages/code/CLAUDE.md | 1 + packages/code/src/index.ts | 76 +- packages/code/src/lib/worktree-isolation.ts | 674 ++++++++++++++++++ .../code/tests/worktree-isolation.test.ts | 356 +++++++++ 7 files changed, 1209 insertions(+), 10 deletions(-) create mode 100644 docs/code/worktree-isolation.md create mode 100644 packages/code/src/lib/worktree-isolation.ts create mode 100644 packages/code/tests/worktree-isolation.test.ts diff --git a/docs/code/configuration.md b/docs/code/configuration.md index 03017823..20cb9f43 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 988cd4f1..a5fea9e9 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 00000000..3991bc09 --- /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 bda1227c..5825e619 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 2a77141a..3ea5c2b6 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -83,6 +83,11 @@ 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 { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./lib/git-hook-fixer"; import { runAutoReviewLoop } from "./lib/auto-review-loop"; import { isAutomatedEnvironment } from "./lib/env-detector"; @@ -169,6 +174,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 +1497,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 +2427,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 +2667,37 @@ 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. + const isolation = + options.git && options.worktreeIsolation !== false + ? await enterTaskWorktreeIsolation({ + taskKey, + targetBranch: options.prTargetBranch, + autoCommit: options.autoCommit, + patchDir: join(resolveOutputDir(), taskKey.toLowerCase()), + verbose: options.verbose, + }) + : null; + + 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 +2721,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 +4515,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 00000000..93db94a0 --- /dev/null +++ b/packages/code/src/lib/worktree-isolation.ts @@ -0,0 +1,674 @@ +/** + * 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 + * - 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). + * + * 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, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { join, dirname } 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; +} + +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); + 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, + }; +} + +/** + * 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 { + try { + runGitSync(["add", "-A"], worktreePath); + const diff = spawnSync("git", ["diff", "--cached", "--binary"], { + 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; + } +} + +/** + * 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 path.startsWith("/") ? 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. + * + * @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}`); + } + 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; + if (existsSync(this.worktreePath)) { + branchName = captureWorktreeState(this.worktreePath).branchName; + preserved = preserveWorktreeChanges({ + worktreePath: this.worktreePath, + taskKey: this.options.taskKey, + taskSummary: this.options.taskSummary, + outcome, + autoCommit: this.options.autoCommit, + patchDir: this.options.patchDir, + }); + // Unlink the shared-state link before removal so no deletion strategy + // can ever traverse into the real config directory. + try { + unlinkSync(join(this.worktreePath, ".devintern-code")); + } catch { + /* absent or already removed */ + } + teardownWorktreeSync(this.worktreePath, this.repoRoot); + } + + this.report(outcome, preserved, branchName); + } + + private report( + outcome: IsolationOutcome, + preserved: { committed: boolean; patchPath?: string }, + branchName: string | null, + ): void { + 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) { + console.log( + ` Results live on branch '${branchName}' β€” check out with: git checkout ${branchName}`, + ); + } else { + 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 regardless so durable state never lands in a + * directory that teardown deletes. + */ +function linkSharedConfigDir(worktreePath: string, repoRoot: string): void { + const sharedDir = join(repoRoot, ".devintern-code"); + const linkPath = join(worktreePath, ".devintern-code"); + + if (!existsSync(sharedDir)) { + return; + } + + process.env.WEBHOOK_QUEUE_DB = join(sharedDir, "queue.db"); + + try { + symlinkSync(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"; +} + +/** + * 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 resolveBaseRef(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); + + 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 00000000..15b5cb9f --- /dev/null +++ b/packages/code/tests/worktree-isolation.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { 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, + isWorktreeIsolationDisabled, + parseWorktreeName, + sanitizeTaskKeyForPath, + sweepOrphanedTaskWorktrees, +} from "../src/lib/worktree-isolation"; +import type { WorktreeIsolationDeps } from "../src/lib/worktree-isolation"; + +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("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("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("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("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("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); + }); +}); From 5b31105b69b3cf381d20b9a4f3e17fdf9e44a8b5 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:23:05 +0700 Subject: [PATCH 2/2] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all three worktree-isolation review items (iteration 1): 1. [HIGH] Silent data loss on preservation failure: finish() now tracks whether the tree was dirty at teardown entry and, when dirty changes could be neither committed nor patched, prints a loud warning naming the worktree path before removal and never prints 'Your working directory was not modified'. writePatchSnapshot() also retries with a plain 'git diff HEAD --binary' when staging/index reads fail (index lock, non-zero diff exit), recovering tracked changes that the staged path would previously drop. 2. [MEDIUM] Performance: resolveBaseRef() is now memoized per process via resolveBaseRefCached(), keyed by repo root + target branch. Batch mode performs one origin fetch per branch instead of one per task; concurrent entries share the in-flight resolution and rejections evict the cache entry for retry. 3. [MEDIUM] Test coverage: added tests for commit-hook refusal falling back to --no-verify; detached-HEAD teardown patching instead of committing; resolveBaseRef fallback chain (origin missing -> local branch -> HEAD); assertion that .devintern-code never lands in the preserved commit or patch; the process.exit()/exit-guard teardown path (via subprocess); and the new unsalvageable-data-loss warning. Address both worktree-isolation review items (iteration 2): 4. [MEDIUM] Inconsistent entry-path failure handling: the batch loop now wraps enterTaskWorktreeIsolation() in try/catch, so a throwing entry (unwritable DEVINTERN_TASK_WORKTREE_DIR mkdir, rejecting resolveBaseRefCached fetch) degrades to an in-place run with the standard 'Could not isolate' warning instead of aborting all remaining batch tasks. 5. [MEDIUM] Unsalvageable dirty trees are no longer destroyed: when commit and patch preservation both fail, finish() skips teardown and renames the directory to '.unsaved' (outside the parsed worktree-name pattern so the orphan sweep cannot reap it), prunes only the stale registration, prints the recovery path, and reports 'kept for manual recovery' without claiming results live on a branch. Test updated to assert the salvaged copy survives intact. Address all three worktree-isolation review items (iteration 3): 6. [HIGH] Failed 'git status' no longer treated as clean: dirty is now '!success || output.length > 0', so status failures (transient index.lock contention, fs errors) route into commit/patch preservation and, worst case, the '.unsaved' rename instead of teardown deleting uncommitted agent work. 7. [MEDIUM] Windows-safe absolute-path detection in resolveFrom(): uses path.isAbsolute() instead of a leading-slash check, so 'rev-parse --git-path' drive-letter results (C:/...) on Windows are honored and .devintern-code exclude registration works there. 8. [MEDIUM] Failure-path test coverage: new tests assert (a) a failing git status (corrupt linked-worktree index) keeps the tree as '.unsaved' with contents intact and warns loudly; (b) .git/info/exclude gains exactly one .devintern-code entry across repeated enters; (c) an injectable symlink dep that throws engages the settings-copy fallback (real directory + copied settings.json, pinned WEBHOOK_QUEUE_DB) and teardown still removes it safely. Address both worktree-isolation review items (iteration 4): 9. [MEDIUM] State symlink can no longer leak into preservation commits: finish() removes the .devintern-code link (and the settings-copy fallback directory; rmSync never traverses symlinks) BEFORE captureWorktreeState()/preserveWorktreeChanges() run. The agent run has already ended, so config continuity is unneeded, and when the .git/info/exclude registration failed the lingering link previously showed up as untracked in 'git status --porcelain' (always-dirty) and got staged by 'git add -A', committing a machine-specific absolute symlink onto the feature branch. 10. [MEDIUM] Test coverage: (a) a read-only .git/info/exclude (chmod 0o444) simulates silent ensureStateDirIgnored failure and asserts the state link never appears in the preservation commit nor triggers phantom-dirty salvage handling; (b) utimesSync-backdated orphan dirs owned by a live pid are swept via the ORPHAN_MAX_AGE_MS age backstop while fresh live-pid entries survive; (c) a spy on Utils.executeGitCommand proves consecutive batch entries trigger exactly one origin fetch, and that a rejected resolution evicts the baseRefCache entry so the next task retries instead of replaying the cached failure. Address all three worktree-isolation review items (iteration 5): 11. [MEDIUM] First-run queue.db pin: linkSharedConfigDir() now assigns WEBHOOK_QUEUE_DB before the shared-dir existence check, so on a genuinely first run (state dir not yet created) lazy queue.db initialization resolves through the pin and creates the database under the repo root (lazy writers mkdir missing parents) instead of inside the disposable worktree, where teardown previously destroyed the run records/retry bookkeeping. 12. [MEDIUM] Orphan sweep no longer destroys uncommitted work: sweepOrphanedTaskWorktrees() runs captureWorktreeState() before teardown; stale entries holding uncommitted changes (a failed status counts as unknown-dirty) are renamed to '.unsaved' β€” outside the name-pattern check so future sweeps cannot reap them β€” with only the registration pruned, mirroring finish()'s salvage path. Only clean stale entries are removed outright. Tests cover a real crashed dirty worktree being salvaged intact and a clean one still being torn down. 13. [MEDIUM] Test coverage for isWorktreeIsolationActive() and CLI wiring: unit tests cover git disabled, opt-out env values 1/true/yes/on case-insensitive (with fall-through for 0/false/no/off/empty), the marker-env nesting guard, and non-git cwd returning false. End-to-end subprocess tests against src/ index.ts assert the startup sync fetches (never pulls) when isolation will engage, and that --no-worktree-isolation and DEVINTERN_NO_WORKTREE_ISOLATION each route back to the pull path. --- packages/code/src/index.ts | 32 +- packages/code/src/lib/worktree-isolation.ts | 191 +++++- .../code/tests/worktree-isolation.test.ts | 627 +++++++++++++++++- 3 files changed, 802 insertions(+), 48 deletions(-) diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 3ea5c2b6..bc472902 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -88,6 +88,7 @@ import { 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"; @@ -2669,17 +2670,26 @@ async function main(): Promise { // 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. - const isolation = - options.git && options.worktreeIsolation !== false - ? await enterTaskWorktreeIsolation({ - taskKey, - targetBranch: options.prTargetBranch, - autoCommit: options.autoCommit, - patchDir: join(resolveOutputDir(), taskKey.toLowerCase()), - verbose: options.verbose, - }) - : null; + // 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 { diff --git a/packages/code/src/lib/worktree-isolation.ts b/packages/code/src/lib/worktree-isolation.ts index 93db94a0..07236b0c 100644 --- a/packages/code/src/lib/worktree-isolation.ts +++ b/packages/code/src/lib/worktree-isolation.ts @@ -15,7 +15,9 @@ * - 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 + * 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`) * @@ -23,7 +25,8 @@ * `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). + * 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. @@ -47,13 +50,13 @@ import { mkdirSync, readdirSync, readFileSync, + renameSync, rmSync, statSync, symlinkSync, - unlinkSync, writeFileSync, } from "fs"; -import { join, dirname } from "path"; +import { join, dirname, isAbsolute } from "path"; import { Utils } from "./utils"; @@ -89,6 +92,8 @@ interface GitSyncResult { 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 { @@ -193,7 +198,10 @@ function runGitSync(args: string[], cwd?: string): GitSyncResult { function captureWorktreeState(worktreePath: string): { dirty: boolean; branchName: string | null } { const status = runGitSync(["status", "--porcelain"], worktreePath); - const dirty = status.success && status.output.length > 0; + // 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, @@ -201,15 +209,9 @@ function captureWorktreeState(worktreePath: string): { dirty: boolean; branchNam }; } -/** - * 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 { +function writeGitDiff(worktreePath: string, args: string[], patchDir: string): string | null { try { - runGitSync(["add", "-A"], worktreePath); - const diff = spawnSync("git", ["diff", "--cached", "--binary"], { + const diff = spawnSync("git", args, { cwd: worktreePath, encoding: "buffer", maxBuffer: 256 * 1024 * 1024, @@ -226,6 +228,24 @@ function writePatchSnapshot(worktreePath: string, patchDir: string): string | nu } } +/** + * 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. * @@ -327,7 +347,7 @@ function ensureStateDirIgnored(repoRoot: string): void { /** Resolve `path` relative to `from` without depending on process.cwd(). */ function resolveFrom(from: string, path: string): string { - return path.startsWith("/") ? path : join(from, path); + return isAbsolute(path) ? path : join(from, path); } /** @@ -335,7 +355,11 @@ function resolveFrom(from: string, path: string): string { * * 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. + * 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 */ @@ -370,6 +394,29 @@ export function sweepOrphanedTaskWorktrees( 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); @@ -430,8 +477,22 @@ class WorktreeIsolation implements WorktreeIsolationHandle { let preserved: { committed: boolean; patchPath?: string } = { committed: false }; let branchName: string | null = null; + let unsalvageable = false; if (existsSync(this.worktreePath)) { - branchName = captureWorktreeState(this.worktreePath).branchName; + // 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, @@ -440,25 +501,48 @@ class WorktreeIsolation implements WorktreeIsolationHandle { autoCommit: this.options.autoCommit, patchDir: this.options.patchDir, }); - // Unlink the shared-state link before removal so no deletion strategy - // can ever traverse into the real config directory. - try { - unlinkSync(join(this.worktreePath, ".devintern-code")); - } catch { - /* absent or already removed */ + // 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); } - teardownWorktreeSync(this.worktreePath, this.repoRoot); } - this.report(outcome, preserved, branchName); + this.report(outcome, preserved, branchName, unsalvageable); } private report( outcome: IsolationOutcome, preserved: { committed: boolean; patchPath?: string }, branchName: string | null, + unsalvageable: boolean, ): void { - if (outcome === "completed") { + 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"); @@ -466,11 +550,11 @@ class WorktreeIsolation implements WorktreeIsolationHandle { console.log("\n🧠 Task failed: isolated worktree cleaned up"); } - if (branchName) { + if (branchName && !unsalvageable) { console.log( ` Results live on branch '${branchName}' β€” check out with: git checkout ${branchName}`, ); - } else { + } else if (!unsalvageable) { console.log(` Your working directory was not modified.`); } if (preserved.patchPath) { @@ -515,21 +599,28 @@ export function cleanupActiveWorktreeIsolation(): void { * 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 regardless so durable state never lands in a - * directory that teardown deletes. + * `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): void { +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; } - process.env.WEBHOOK_QUEUE_DB = join(sharedDir, "queue.db"); - try { - symlinkSync(sharedDir, linkPath, "dir"); + symlink(sharedDir, linkPath, "dir"); return; } catch { // Symlinks unavailable (Windows without dev mode, restricted fs): fall @@ -569,6 +660,36 @@ async function resolveBaseRef( 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. * @@ -620,7 +741,7 @@ export async function enterTaskWorktreeIsolation( ensureStateDirIgnored(repoRoot); sweepOrphanedTaskWorktrees(worktreeRoot, repoRoot, { verbose: options.verbose }); - const baseRef = await resolveBaseRef(options.targetBranch, repoRoot, options.verbose); + const baseRef = await resolveBaseRefCached(options.targetBranch, repoRoot, options.verbose); const safeKey = sanitizeTaskKeyForPath(options.taskKey); let worktreePath = join( @@ -644,7 +765,7 @@ export async function enterTaskWorktreeIsolation( return null; } - linkSharedConfigDir(worktreePath, repoRoot); + linkSharedConfigDir(worktreePath, repoRoot, deps.symlink ?? symlinkSync); const handle = new WorktreeIsolation(worktreePath, repoRoot, originalCwd, deps, options); activeHandle = handle; diff --git a/packages/code/tests/worktree-isolation.test.ts b/packages/code/tests/worktree-isolation.test.ts index 15b5cb9f..c79a60ed 100644 --- a/packages/code/tests/worktree-isolation.test.ts +++ b/packages/code/tests/worktree-isolation.test.ts @@ -1,7 +1,16 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execSync } from "child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; -import { join } from "path"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "fs"; +import { basename, join } from "path"; import { tmpdir } from "os"; import { @@ -13,12 +22,15 @@ import { 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(); @@ -137,6 +149,43 @@ describe("worktree isolation", () => { 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(); @@ -187,6 +236,82 @@ describe("worktree isolation", () => { 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 }, @@ -237,6 +362,56 @@ describe("worktree isolation", () => { ); }); + 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 }, @@ -260,6 +435,73 @@ describe("worktree isolation", () => { 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 }, @@ -297,6 +539,64 @@ describe("worktree isolation", () => { 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 }, @@ -353,4 +653,327 @@ describe("worktree isolation", () => { 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); });