feat(adopt): add pando adopt command to take over externally-created worktrees - #63
Open
zpyoung wants to merge 8 commits into
Open
feat(adopt): add pando adopt command to take over externally-created worktrees#63zpyoung wants to merge 8 commits into
zpyoung wants to merge 8 commits into
Conversation
- New `pando adopt [path]` command: runs pando setup (rsync, symlinks, metadata, ports, post-commands) on a worktree pando did not create. - Safety invariants: never removes the worktree on failure, never clobbers user work (skip+warn on symlink conflicts, --replace-existing to override), dirt-tolerant clean-tree check, --dry-run preview. - GitHelper.getWorktreeByPath (realpath match, isMain detection). - worktreeSetup adopt mode (SetupOptions.adopt/dryRun/replaceExistingSymlinks/ preexistingDirtyPaths) + SetupPlan. - Shared trust-gated post-command runner (extracted from add) + adopt->add post-command key fallback. - Shared setup-flag overrides (extracted from add). - Long-lived default kind for adopts (kindOverride on setupLifecycleMetadata). - Unit + E2E coverage.
Findings from a Codex adversarial review: - CRITICAL: adopt-mode rollback could delete the entire worktree. rsync records the worktree root as its rollback target, so transaction.rollback() would fs.remove() it even though the 'worktree' checkpoint was skipped. Add skipRsyncRollback to FileOperationTransaction.rollback() and set it in adopt mode (real-transaction test proves the worktree survives). - HIGH: adopt could overwrite user files via rsync. Force onlyUntracked=true and add --ignore-existing in adopt mode so a full-mirror config can't clobber tracked files and an existing target file (e.g. a hand-made .env) is never overwritten. - MEDIUM: re-adopt silently rewrote lifecycle metadata. Preserve existing kind, createdAt, and sourceBranch unless an explicit lifecycle flag is passed. - MEDIUM: dangling symlink at a target was misclassified as toCreate. Use lstat (not pathExists) in classification and the pre-removal loop. Docs + tech spec updated. 848 tests pass.
…not-found Second-pass review: the dangling-symlink lstat fix wrapped lstat+remove in one try, swallowing real removal failures (EACCES/EPERM/IO) that should fail setup and roll back. Catch only the lstat not-found case; let removal errors propagate.
adopt runs from inside the target worktree, so process.cwd() and git --show-toplevel both resolved to that worktree. Config discovery then parsed the worktree's own files (e.g. a non-JSON package.json placed at a symlink target by the user), throwing a ConfigParseFailureError, and never read the real project config (.pando.toml in the main repo) that governs symlink/rsync patterns. Load config from getMainWorktreePath() instead — the same tree already used as the rsync/symlink source. Fixes the two 'protects user work' E2E tests (symlink conflict skip + --replace-existing).
There was a problem hiding this comment.
Pull request overview
Adds a new pando adopt [path] CLI command to take over externally-created git worktrees and bring them under pando management by reusing the existing setup pipeline with an “adopt-safe” mode (non-destructive rollback, conflict-safe symlinks, dirt-tolerant clean-tree checks) plus shared post-command trust gating and shared setup-flag override logic.
Changes:
- Introduces
pando adopt(with--dry-runand--replace-existing) and associated unit + E2E test coverage. - Extends
WorktreeSetupOrchestratorwith adopt/dry-run planning + safety semantics (rsync hardening, rollback changes, symlink classification). - Extracts shared helpers for post-command trust gating (
runTrustedPostCommands) and setup flag overrides (applySetupFlagOverrides), refactoringaddto use them.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/utils/worktreeSetup.test.ts | Adds adopt-mode unit coverage (rollback safety, symlink classification, dry-run plan, rsync hardening). |
| test/utils/postCommandRunner.test.ts | New unit tests for shared trust-gated post-command runner (run/skip/json/fallback behavior). |
| test/utils/git.test.ts | Adds tests for GitHelper.getWorktreeByPath matching + isMain detection. |
| test/utils/fileOps.test.ts | Verifies skipRsyncRollback preserves rsync destination (critical adopt safety). |
| test/e2e/helpers/cli-runner.ts | Adds E2E helpers to invoke pando adopt (JSON + human). |
| test/e2e/commands/adopt.e2e.test.ts | New E2E suite validating adopt safety invariants and validation failures. |
| test/commands/adopt.test.ts | New command-level unit tests for adopt validation, apply, idempotency, and dry-run behavior. |
| test/commands/add.test.ts | Updates add tests to use shared post-command runner + adds lifecycle kindOverride test. |
| src/utils/worktreeSetup.ts | Implements adopt mode, dry-run planning (SetupPlan), symlink classification, hardened rsync, and rollback behavior changes. |
| src/utils/setupFlags.ts | New shared flag override helper used by both add and adopt. |
| src/utils/postCommandRunner.ts | New shared trust-gated post-command executor (with fallback key support). |
| src/utils/git.ts | Adds canonicalized path matching and getWorktreeByPath. |
| src/utils/fileOps.ts | Adds skipRsyncRollback option to rollback to preserve adopted worktree roots. |
| src/utils/DESIGN.md | Documents new adopt-related utilities and orchestrator mode. |
| src/commands/DESIGN.md | Documents the new adopt command and its behavior/flags. |
| src/commands/adopt.ts | New pando adopt command implementation (validation, config loading, adopt-safe setup, lifecycle, post-commands, output). |
| src/commands/add.ts | Refactors to use shared setup-flag overrides and shared post-command runner; adds kindOverride hook for lifecycle metadata. |
| README.md | Adds user-facing docs for pando adopt, safety semantics, and [postCommands] adopt fallback behavior. |
| docs/quirk/specs/2026-07-23-worktree-adopt/tech.md | Adds detailed tech spec documenting the implementation contract and test plan. |
| docs/quirk/specs/2026-07-23-worktree-adopt/logic.md | Adds logic spec covering command surface, safety invariants, and locked decisions. |
| .pando.toml.example | Documents [postCommands] adopt configuration and fallback behavior. |
Comments suppressed due to low confidence (1)
src/utils/worktreeSetup.ts:810
classifyAdoptSymlinkstreats anylstatfailure as "target does not exist". Iflstatfails for reasons other than ENOENT (permissions, transient IO), this will misclassify the target astoCreateand could lead to confusing behavior/errors. Consider only mapping ENOENT to "missing" and rethrowing other errors.
try {
await fs.lstat(target)
} catch {
exists = false
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+751
to
+755
| try { | ||
| await fs.lstat(targetPath) | ||
| } catch { | ||
| targetExists = false | ||
| } |
Comment on lines
+16
to
+20
| try { | ||
| return await realpath(absolute) | ||
| } catch { | ||
| return resolve(absolute) | ||
| } |
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.
Changes Overview
Adds a new
pando adopt [path]command that takes over a git worktree pando did not create (one made by rawgit worktree add, or by another tool such as Orca) and brings it under pando management — running pando's standard setup: rsync of untracked artifacts, symlinks,skip-worktreemarking, lifecycle metadata, port allocation, and post-commands. After a successful adopt, the worktree is indistinguishable from one created bypando add.Conceptually this is the setup-only half of
add:git worktree addis removed and safety rails are added, because the target may already contain real, uncommitted work. The core design guarantee is that adopt is safe on a worktree that already holds real work — it never removes the worktree and never overwrites your files. Architecturally, the change threads anadoptmode through the existingWorktreeSetupOrchestratorrather than forking the setup pipeline, so the create-time path stays byte-for-byte unchanged (guarded byadd's existing tests).Key Technical Changes
src/commands/adopt.ts(new, ~676 lines) — the command: target resolution, validation, dry-run, adopt-safe setup, lifecycle, post-commands, adopt-specific output.src/utils/worktreeSetup.ts— new adopt mode on the orchestrator (SetupOptions.adopt/dryRun/replaceExistingSymlinks/preexistingDirtyPaths) with three gated behavior changes: (1) non-destructive rollback that never callsgit worktree remove, (2) skip-not-clobber symlink conflicts, (3) dirt-tolerant clean-tree check. Adds aSetupPlan(symlink create/already-linked/conflict buckets + rsync file count/mode) returned for--dry-runand reporting.src/utils/git.ts— newGitHelper.getWorktreeByPath(realpath-based match againstlistWorktrees(), withisMaindetection).src/utils/postCommandRunner.ts(new) — trust-gated post-command runner extracted fromadd, shared by both commands, with anadopt->addconfig-key fallback.src/utils/setupFlags.ts(new) — shared rsync/symlink flag-override logic extracted fromadd.src/utils/fileOps.ts—skipRsyncRollbackoption on the transaction rollback so an adopt failure neverfs.remove()s the synced worktree root.src/commands/add.ts— refactored to consume the shared post-command runner and setup-flag helpers;setupLifecycleMetadatagains akindOverridehook. No observable behavior change.pando adopt [path]with--dry-run,--replace-existing, lifecycle flags (--ephemeral/--long-lived/--ttl/--owner/--ports) and setup flags (--skip-rsync/--skip-symlink/--rsync-flags/--rsync-exclude/--symlink/--absolute-symlinks) mirroringadd, plus--details/--json.[postCommands] adoptkey. No schema migration needed —postCommandsis already a free-formRecord<string, PostCommandScript[]>. Adopt runs itsadoptscripts, falling back toaddwhen unset.Safety semantics (the point of the feature):
rsync.onlyUntracked) and uses--ignore-existing, so a hand-made file already in the worktree (e.g..env) is never overwritten and tracked/modified files are never synced.adoptconverges.setup.symlink.conflicts);--replace-existingopts into replacement.alreadyManaged: true, and preserves existingkind/createdAt/sourceBranchunless an explicit lifecycle flag is passed.worktree.defaultKind. RecordedsourceBranchisworktree.targetBranch(e.g.main), since an adopted worktree has no "branched-from" moment.This shape and the specific hardening (rsync rollback deleting the worktree, full-mirror clobber, re-adopt metadata rewrite, dangling-symlink misclassification) came out of an adversarial Codex review — see the commit history and
docs/quirk/specs/2026-07-23-worktree-adopt/{logic,tech}.md.Testing
Setup
Verification Steps
pnpm lint && pnpm build && pnpm test(848 tests pass at time of writing). E2E (Docker):pnpm vitest run test/e2e/commands/adopt.e2e.test.ts --hookTimeout=60000.git worktree add ../foreign-x, thenpando adopt ../foreign-x. Verify metadata is written, symlinks created, gitignored artifacts synced, andgit statusin the worktree is clean aside from pando symlinks.pando adopt. Verify every uncommitted change is preserved and the command exits 0 (dirty paths reported underpreexistingDirtyin--json).node_moduleswhere pando would symlink, then adopt. Verify it is skipped and warned (real dir preserved); re-run with--replace-existingand verify it is replaced by a symlink.pando adopt ../foreign-x --dry-run(and with--json). Verify nothing changes on disk and the plan (symlinks to create/already-linked/conflicts, rsync file count, metadata) is printed.alreadyManaged.Additional Notes
addpath (orchestrator withadoptunset) is intentionally left byte-identical and is guarded by existing tests.README.md(command + flags + safety semantics),.pando.toml.example([postCommands] adopt),src/commands/DESIGN.md, andsrc/utils/DESIGN.md.docs/quirk/specs/2026-07-23-worktree-adopt/.