Skip to content

feat(adopt): add pando adopt command to take over externally-created worktrees - #63

Open
zpyoung wants to merge 8 commits into
mainfrom
zpyoung/worktree-takeover-adoption
Open

feat(adopt): add pando adopt command to take over externally-created worktrees#63
zpyoung wants to merge 8 commits into
mainfrom
zpyoung/worktree-takeover-adoption

Conversation

@zpyoung

@zpyoung zpyoung commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Changes Overview

Adds a new pando adopt [path] command that takes over a git worktree pando did not create (one made by raw git 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-worktree marking, lifecycle metadata, port allocation, and post-commands. After a successful adopt, the worktree is indistinguishable from one created by pando add.

Conceptually this is the setup-only half of add: git worktree add is 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 an adopt mode through the existing WorktreeSetupOrchestrator rather than forking the setup pipeline, so the create-time path stays byte-for-byte unchanged (guarded by add's existing tests).

Key Technical Changes

  • Components:
    • 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 calls git worktree remove, (2) skip-not-clobber symlink conflicts, (3) dirt-tolerant clean-tree check. Adds a SetupPlan (symlink create/already-linked/conflict buckets + rsync file count/mode) returned for --dry-run and reporting.
    • src/utils/git.ts — new GitHelper.getWorktreeByPath (realpath-based match against listWorktrees(), with isMain detection).
    • src/utils/postCommandRunner.ts (new) — trust-gated post-command runner extracted from add, shared by both commands, with an adopt -> add config-key fallback.
    • src/utils/setupFlags.ts (new) — shared rsync/symlink flag-override logic extracted from add.
    • src/utils/fileOps.tsskipRsyncRollback option on the transaction rollback so an adopt failure never fs.remove()s the synced worktree root.
    • src/commands/add.ts — refactored to consume the shared post-command runner and setup-flag helpers; setupLifecycleMetadata gains a kindOverride hook. No observable behavior change.
  • APIs (CLI surface): New 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) mirroring add, plus --details/--json.
  • Config: New [postCommands] adopt key. No schema migration needed — postCommands is already a free-form Record<string, PostCommandScript[]>. Adopt runs its adopt scripts, falling back to add when unset.
  • Database: None.
  • Dependencies: None added.

Safety semantics (the point of the feature):

  • Your work is never touched. Rsync is forced to untracked-only (regardless of 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.
  • A failed adopt never deletes your worktree. A mid-setup failure rolls back only pando's own newly-created symlinks; it never removes the worktree directory or the synced artifacts. Re-running adopt converges.
  • Symlink conflicts skip by default. A real file/dir where pando would create a symlink is left alone and warned (setup.symlink.conflicts); --replace-existing opts into replacement.
  • Idempotent. Re-adopting an already-managed worktree re-applies setup, reports alreadyManaged: true, and preserves existing kind/createdAt/sourceBranch unless an explicit lifecycle flag is passed.
  • Lifecycle default is long-lived (a hand-made worktree with real work should not be auto-reaped), ignoring worktree.defaultKind. Recorded sourceBranch is worktree.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

git checkout zpyoung/worktree-takeover-adoption
pnpm install
pnpm build

Verification Steps

  1. Automated suitepnpm 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.
  2. Adopt a clean foreign worktreegit worktree add ../foreign-x, then pando adopt ../foreign-x. Verify metadata is written, symlinks created, gitignored artifacts synced, and git status in the worktree is clean aside from pando symlinks.
  3. Adopt a dirty worktree — make an uncommitted tracked change plus an untracked file in the target, then pando adopt. Verify every uncommitted change is preserved and the command exits 0 (dirty paths reported under preexistingDirty in --json).
  4. Symlink conflict — place a real node_modules where pando would symlink, then adopt. Verify it is skipped and warned (real dir preserved); re-run with --replace-existing and verify it is replaced by a symlink.
  5. Dry-runpando 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.
  6. Guards — adopting the main worktree, a non-worktree dir, or outside a repo each fail validation with a clear message and no mutation.
  7. Idempotency — adopt the same worktree twice; the second run converges with no errors and reports alreadyManaged.

Additional Notes

  • Rollback / safety: the top invariant is that adopt never removes a worktree it did not create and never clobbers user files; the create-time add path (orchestrator with adopt unset) is intentionally left byte-identical and is guarded by existing tests.
  • Docs updated: README.md (command + flags + safety semantics), .pando.toml.example ([postCommands] adopt), src/commands/DESIGN.md, and src/utils/DESIGN.md.
  • Specs: approved logic + tech specs live at docs/quirk/specs/2026-07-23-worktree-adopt/.

zpyoung added 8 commits July 23, 2026 08:24
- 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).
@zpyoung
zpyoung marked this pull request as ready for review July 25, 2026 13:37
Copilot AI review requested due to automatic review settings July 25, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-run and --replace-existing) and associated unit + E2E test coverage.
  • Extends WorktreeSetupOrchestrator with 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), refactoring add to 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

  • classifyAdoptSymlinks treats any lstat failure as "target does not exist". If lstat fails for reasons other than ENOENT (permissions, transient IO), this will misclassify the target as toCreate and 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 thread src/utils/git.ts
Comment on lines +16 to +20
try {
return await realpath(absolute)
} catch {
return resolve(absolute)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants