[DEV-90] Isolate CLI task execution in git worktrees to protect the user's current work - #71
Open
danii1 wants to merge 2 commits into
Open
[DEV-90] Isolate CLI task execution in git worktrees to protect the user's current work#71danii1 wants to merge 2 commits into
danii1 wants to merge 2 commits into
Conversation
added 2 commits
August 26, 2026 14:39
…to protect the user's current work
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 '<name>.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
'<name>.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 '<name>.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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Task: DEV-90
Summary: Isolate CLI task execution in git worktrees to protect the user's current work
Implementation Details
I'll start by exploring the codebase to understand the structure and find existing worktree-related logic.
The exploration revealed two existing worktree implementations. Now let me read the key files directly to ground the implementation.
Now let me read the main entry point sections — the CLI options,
main(), and the per-task processing flow.Now let me read
processSingleTaskand the surrounding helpers:Let me read the key Utils functions in utils.ts:
I have a good picture now. Let me verify a few remaining details — config dir walk-up semantics, the test git-state guard, docs layout, and a few Utils signatures:
Important discovery:
findConfigDirstops at the first.gitentry — so from inside ANY worktree, config resolution falls back to<worktree>/.devintern-code. Let me check which durable-state loaders resolve lazily from cwd, to pick the right integration strategy:The queue-db env override (
WEBHOOK_QUEUE_DB) is key — all durable stores route through it. Now let me checkloadProjectSettings/loadEnvironmentin index.ts:loadProjectSettingsreads directly fromprocess.cwd()— confirming the symlink approach is needed. Let me check the docs layout and test runner setup before implementing:All research done. Now I have the complete picture. Let me set up my task list and implement.
Now let me fix two issues I introduced — the undefined
phaseActiveForGuardand the fragile mkdir path inensureStateDirIgnored:Module created. Now wiring into
index.ts— first the import and CLI option:Now the
main()pull-vs-fetch change (a pull would move the user's branch; when isolating we only fetch — the worktree branches from the fetched refs):Now the task loop — enter isolation per task, with explicit finish calls on every path (the catch body may call
process.exit(), which would skip afinally):Let me review the loop I just wrote — I need to fix a dropped
results.successful++and simplify:Now hook cleanup into
gracefulShutdown(runs before tracker I/O so teardown is fast and can't be skipped by the 15s exit timer):Two fixes needed: wire the commander flag (
--no-worktree-isolation) at both call sites, and drop the unused import:Both call sites wired. Now the test suite:
A few fixes needed in the test file (clone path, a helper for failing git commands), plus a module improvement — don't auto-commit on a detached HEAD during interrupt preservation (the commit would be unreachable; prefer the patch):
Now the module fix — reuse the captured state and skip commits on detached HEAD:
Now let's run the new tests to validate the implementation:
All 12 tests pass with the full lifecycle verified. Now typecheck, lint, and the full suite:
The
onoverload works butremoveListenerhits a bun-types quirk. Cleaner fix anyway: install the exit guard once permanently — it self-no-ops when no isolation is active, so removal isn't needed:Now remove the two
removeExitGuard()call sites:Typecheck clean. Now lint and format:
One lint warning is mine (
lstatSyncunused in the module). Removing it:Now the full test suite for the package to check for regressions:
All 897 tests pass, 0 failures. Now the documentation:
Now update
usage.md— flag examples and a pointer section:Now
configuration.md— add the environment variables after the Output Directory section:Now the CLAUDE.md architecture note:
Final verification — format, lint, typecheck, plus a CLI
--helpsmoke test for the new flag:The opt-out flag correctly triggered legacy pull behavior. Let me verify default (isolation on) uses fetch-only sync:
Excellent — fetch-only sync works by default. Now a full end-to-end smoke test with a stub agent to exercise the real CLI wiring through success and failure paths:
End-to-end flow works. Note the isolation correctly anchored at the invocation cwd's repo (the monorepo, since that's where I ran the CLI). Let me verify the smoke repo is untouched and clean up test residue:
Let me verify the smoke repo state cleanly and clean up the monorepo residue from the smoke test:
Smoke repo is pristine. Cleaning monorepo residue from the smoke run:
All clean. Final verification round — full tests, typecheck, lint, and a diff review:
Diff is clean and minimal. Let me do a final review pass of the complete module:
Everything is verified. Completing the task list:
DEV-90 is implemented. Summary of what was built:
Implementation
New module:
packages/code/src/lib/worktree-isolation.ts— task runs now execute inside a disposable git worktree at<repo>/.devintern-code/worktrees/<task>-<pid>-<ts>instead of the user's checkout. It extends the codebase's existing worktree patterns rather than reinventing them: the repo-manager/review-worktree teardown ladder (git worktree remove --force→rmSync→prune), the pipeline's owncreateFeatureBranch(runs safely in the throwaway tree, including all its worktree-conflict handling), and the fleet mode's create→run→remove lifecycle.Key behaviors, mapped to acceptance criteria:
origin/<target>(fallbacks: local branch, HEAD); the user's uncommitted changes, staged files, index, and branch are never touched. A.devintern-codesymlink back to the shared state dir plus pinnedWEBHOOK_QUEUE_DBkeep config/settings/queue.db resolving correctly (config discovery stops at the worktree's.git). Startup sync switches from pull to fetch-only so the user's branch isn't fast-forwarded.feat:on success,wip(devintern):on failure/interrupt; commits survive worktree removal), with a binary-safe patch fallback (worktree-changes.patchin the output dir).--no-auto-commitgets patch-only preservation.process.exit()(exit-event guard), SIGINT/SIGTERM (hooked intogracefulShutdownbefore tracker I/O), and hard kills via pid-liveness orphan sweep with a 7-day age backstop. Teardown is fully synchronous and best-effort.--no-worktree-isolation,DEVINTERN_NO_WORKTREE_ISOLATION=1,DEVINTERN_TASK_WORKTREE_DIR.Wiring (
src/index.ts, +76 lines): CLI flag, per-task enter/finish aroundprocessSingleTaskwith explicit finish calls on exiting paths, shutdown hook.Verification
feature/<key>, zero leftover registrationsThis PR was automatically created by @devintern/code