diff --git a/.pando.toml.example b/.pando.toml.example index 22ce885..c6a14b8 100644 --- a/.pando.toml.example +++ b/.pando.toml.example @@ -226,8 +226,9 @@ dbBaseName = "dev" # Post-command Scripts # ============================================================================ # Shell commands to run after a pando command succeeds. Keys are command ids; -# the only supported hook today is "add". For `pando add`, scripts run from the -# newly created worktree once rsync/symlink setup has finished. +# the supported hooks are "add" and "adopt". Scripts run from the worktree once +# rsync/symlink setup has finished. `pando adopt` uses the "adopt" scripts and +# falls back to the "add" scripts when no "adopt" key is set. # # SECURITY — trust prompt: post-commands run with a shell, so the FIRST time a # config file would run them, Pando shows the commands and asks you to trust the @@ -238,6 +239,7 @@ dbBaseName = "dev" # # [postCommands] # add = ["pnpm install", "pnpm run prepare"] +# adopt = ["pnpm install"] # optional; omit to reuse the "add" scripts # ============================================================================ # Project-Specific Examples diff --git a/README.md b/README.md index 019b096..c9f5b2a 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,45 @@ pando add --path ../feature-x --branch feature-x --details When `--details` is used with `--json`, the response includes a stable `details` object with `rsync` totals and `symlink` counts/sample paths. Without `--details`, default human and JSON output are unchanged. +### `pando adopt` + +Take over a git worktree that pando did **not** create (for example one made by raw `git worktree add` or another tool) and run pando's standard setup on it — rsync of untracked artifacts, symlinks, skip-worktree, lifecycle metadata, ports, and post-commands. After a successful adopt, the worktree is indistinguishable from one created by `pando add`. + +Adopt is designed to be safe on a worktree that already contains real work: **it never removes the worktree and never overwrites your files.** + +```bash +# Adopt the worktree you're currently in +pando adopt + +# Adopt a specific worktree by path +pando adopt ../feature-x + +# Preview exactly what would happen, changing nothing +pando adopt ../feature-x --dry-run +``` + +**Flags:** + +- `[path]`: Path to the worktree to adopt (positional; defaults to the current directory) +- `--dry-run`: Print the plan (symlinks to create / already-linked / skipped, rsync file count, metadata) without changing anything +- `--replace-existing`: Replace a real file/dir sitting at a symlink target instead of skipping it +- `--ephemeral` / `--long-lived`: Lifecycle kind (mutually exclusive) +- `--ttl ` / `--owner ` / `--ports`: Same lifecycle/port options as `pando add` +- `--skip-rsync` / `--rsync-flags` / `--rsync-exclude`: Rsync controls (same as `pando add`) +- `--skip-symlink` / `--symlink` / `--absolute-symlinks`: Symlink controls (same as `pando add`) +- `--details` / `-j, --json`: Output controls + +**Safety semantics:** + +- **Your work is never touched.** Rsync runs in a hardened mode for adopt: it only carries gitignored artifacts (never tracked or modified files), is forced to untracked-only regardless of `rsync.onlyUntracked`, and uses `--ignore-existing` so a file that already exists in the worktree (e.g. a hand-made `.env`) is never overwritten by the source's copy. Adopting a dirty worktree leaves every uncommitted change in place; JSON output lists them under `preexistingDirty`. +- **A failed adopt never deletes your worktree.** Even after files have been synced, 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 are skipped by default.** If a real file or directory already sits where pando would create a symlink, pando leaves it alone and warns (reported under `setup.symlink.conflicts`). Pass `--replace-existing` to replace it instead. +- **Idempotent.** Re-running `adopt` on an already-managed worktree re-applies setup and reports `alreadyManaged: true` — handy as a "re-sync / repair". It preserves the worktree's existing lifecycle kind, creation time, and source branch unless you pass an explicit lifecycle flag. + +**Lifecycle metadata**: adopt defaults the kind to **long-lived** (a hand-created worktree with real work should not be auto-reaped), ignoring `worktree.defaultKind`; pass `--ephemeral`/`--ttl` to override. The recorded `sourceBranch` is your configured `worktree.targetBranch` (e.g. `main`), since an adopted worktree has no "branched-from" moment. + +**Post-commands**: adopt runs scripts under the `[postCommands] adopt` config key, falling back to `[postCommands] add` when no adopt-specific scripts are configured. The same config-trust prompt as `pando add` applies. + ### `pando list` List all git worktrees @@ -594,23 +633,25 @@ dbBaseName = "dev" # Prefix for a name derived from each worktree's fetch = false # Run git fetch --prune before detection # Post-command scripts -# Runs after a command succeeds. For pando add, scripts run from the created worktree. +# Runs after a command succeeds. Scripts run from the target worktree. [postCommands] add = ["pnpm install"] # Optional setup commands after pando add succeeds +adopt = ["pnpm install"] # Optional; falls back to the add scripts if omitted ``` ### Post-command scripts -`postCommands` lets you configure shell commands to run after Pando completes a command successfully. The first supported hook is `add`: +`postCommands` lets you configure shell commands to run after Pando completes a command successfully. The supported hooks are `add` and `adopt`: ```toml [postCommands] add = ["pnpm install", "pnpm run prepare"] +adopt = ["pnpm install"] # optional; falls back to the `add` scripts if omitted ``` -Scripts configured for `add` run **after** the worktree has been created and rsync/symlink setup has finished. They execute from the created worktree directory and receive useful context through environment variables: +Scripts configured for `add` run **after** the worktree has been created and rsync/symlink setup has finished. `pando adopt` runs its `adopt` scripts, falling back to the `add` scripts when no `adopt` key is configured (so an existing `add` setup "just works" for adopts too). They execute from the worktree directory and receive useful context through environment variables: -- `PANDO_COMMAND` — command id, such as `add` +- `PANDO_COMMAND` — command id, such as `add` or `adopt` - `PANDO_WORKTREE_PATH` — absolute path to the created worktree - `PANDO_BRANCH` — branch name, or empty in detached HEAD mode - `PANDO_COMMIT` — created worktree commit diff --git a/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md new file mode 100644 index 0000000..1086f21 --- /dev/null +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md @@ -0,0 +1,288 @@ +# Logic Spec: `pando adopt` — worktree takeover + +## Status + +Approved — ready for implementation. Tech spec authored (`tech.md`) — gate fired: +touches ≳3 source files. + +## Purpose + +Let pando run against a git worktree it did **not** create (e.g. one made by raw +`git worktree add`, or by another tool such as Orca) and bring it under pando +management: run the standard setup (rsync of untracked artifacts, symlinks, +skip-worktree, lifecycle metadata, port allocation, post-commands). After a +successful adopt, the worktree is indistinguishable from one created by +`pando add`. + +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. + +## Conceptual model + +`pando add` today runs, in order: validate → `git worktree add` → setup +(rsync/symlink) → lifecycle metadata + ports → post-commands → output. + +`pando adopt` reuses the tail of that pipeline against a pre-existing worktree: + +``` +resolve & validate target → read existing state → load config → +compute plan → [--dry-run? emit & exit] → apply setup (adopt-safe mode) → +lifecycle metadata + ports → post-commands → output +``` + +The worktree already exists on disk and is already a linked worktree of the +repo; adopt never creates or removes it. + +## Command surface + +``` +pando adopt [path] +``` + +- `[path]` — optional; defaults to the current working directory. Resolved to an + absolute path and confirmed to be a **linked** worktree of the current repo. + +Flags (mirror `add` where meaningful): + +| Flag | Purpose | +|------|---------| +| `--dry-run` | Print the full plan (human or JSON), change nothing, exit 0 | +| `--replace-existing` | Replace real files at symlink targets instead of skip+warn | +| `--long-lived` (default) / `--ephemeral` | Lifecycle kind | +| `--ttl ` / `--owner ` / `--ports` | Lifecycle metadata / port allocation | +| `--skip-rsync` / `--skip-symlink` | Skip a setup phase | +| `--rsync-flags` / `--rsync-exclude` / `--symlink` / `--absolute-symlinks` | Setup overrides (same semantics as `add`) | +| `--json` / `--details` | Output format | + +## Data flow (prose) + +1. **Resolve & validate target.** Resolve `[path]`/cwd → absolute path. Confirm + it is a linked worktree of this repo by matching against + `gitHelper.listWorktrees()`. Reject with a clear validation error when: + - not inside/there-is-no git repo, + - the path is the **main** worktree (cannot adopt main), + - the path is not a linked worktree of this repo. + Read the worktree's branch from its `WorktreeInfo`; tolerate detached HEAD + (no branch → still adoptable, `sourceBranch` falls back per the rule below). + Run `assertGitVersion()` then `ensureWorktreeConfigEnabled(gitRoot)` (both + idempotent, same as `add`). + +2. **Read existing state.** `readMetadata(worktreePath)`. If `kind` is already + defined, the worktree is already pando-managed → log "already managed, + re-applying" and continue (idempotent re-apply). Capture the dirty file set + via `getDirtyPaths(worktreePath)` — **not** `hasUncommittedChanges`, which + fails open (returns `false` on error). Dirty state is recorded, never a + blocker. + +3. **Load config.** Same `loadAndMergeConfig` path as `add` (defaults → file → + env → flag overrides for rsync/symlink/ports). + +4. **Compute plan** (always computed; consumed by both `--dry-run` and apply): + - **Symlink plan** — matched glob patterns minus git-tracked paths (unless + `allowTracked`); classify each planned target: + - *create* — nothing at the path, + - *no-op* — already the correct symlink, + - **conflict** — a real file/dir with content sits at the path. + - **Rsync plan** — the untracked-ignored file set from + `listIgnoredFiles(sourceTreePath)` (`onlyUntracked` mode). Full-mirror mode + (`onlyUntracked=false`) still requires source/target commits to match; a + foreign worktree usually differs, so it skips with a warning (unchanged + from today's behavior). + - **Metadata plan** — kind (default long-lived), `sourceBranch` (see decision + below), owner, ttl, and ports to allocate. + +5. **`--dry-run` gate.** If set, emit the plan and exit 0 without any mutation. + Human output lists symlinks to create / skip-conflict / replace, rsync file + count, metadata + ports to be written. JSON emits the same as a structured + `preview` object. + +6. **Apply setup (adopt-safe mode).** Call + `WorktreeSetupOrchestrator.setupNewWorktree(worktreePath, { adopt: true, … })`. + `setupNewWorktree` already tolerates a pre-existing worktree (Phase 1 only + checks `pathExists`). The new `adopt` mode changes exactly three behaviors — + see "Adopt-safe orchestrator mode" below. + +7. **Lifecycle metadata + ports.** Shared helper (extracted from `add`'s current + private `setupLifecycleMetadata`) writes `pando.kind` (long-lived default), + `pando.sourceBranch`, `pando.owner`, `pando.ttl`, optional autolock, and + allocates ports via `allocate()` when `--ports`/config enables it. + +8. **Post-commands.** Run `postCommands.adopt` if defined; otherwise fall back to + `postCommands.add`. Trust-gated exactly like `add`. + +9. **Output.** Human + `--json`. Report: worktree path, branch, kind, symlinks + (created / skipped-conflict / replaced), rsync file count, ports, post-command + results, `cleanTree`. + +## Adopt-safe orchestrator mode + +`SetupOptions` gains `adopt?: boolean`. When true, three and only three +behaviors change versus the create-time path: + +1. **Non-destructive rollback (top safety invariant).** On any setup failure, + `rollback()` reverts only the file operations pando performed in *this run* + (symlinks it created, files it synced). It must **never** call + `gitHelper.removeWorktree(...)`. A failed adopt can never delete a worktree + pando did not create. Implementation: the checkpoint that today drives the + destructive `git worktree remove` is either not created in adopt mode, or + `rollback()` skips the worktree-removal step when `adopt` is set. + +2. **Symlink conflicts skip, not clobber.** The symlink phase stops doing + unconditional `fs.remove()` on the target. A real file/dir at a planned + symlink target is skipped and warned. `--replace-existing` opts back into the + remove-then-symlink behavior for those conflicts only. + +3. **Dirt-tolerant validation.** The post-setup "clean tree" check accounts for + pre-existing user dirt: only pando's own additions must be clean. Adopting a + work-in-progress worktree must not fail validation because the user already + had uncommitted changes. + +Everything else about `setupNewWorktree` (rsync `onlyUntracked`, skip-worktree +marking of symlinked tracked paths, symlink verification) is reused unchanged. + +## Decisions Locked + +### Command & invocation +- **New dedicated `pando adopt` command** (not an `add --adopt` flag, not + `pando setup`). Keeps `add` focused on creation; distinct help/flags; matches + industry naming (Terraform `import`, Helm `--take-ownership`). +- **Target = current directory by default, optional `[path]` argument.** Matches + "running pando *in* a worktree." Validates the target is a linked worktree. + +### Safety on existing work +- **Adopt dirty worktrees; never touch tracked/modified files.** Proceed even + with uncommitted changes (that is the point). Only sync gitignored/untracked + artifacts and create symlinks. Warn about dirt, never block on it. +- **Symlink conflicts: skip + warn by default; `--replace-existing` to replace.** + Never silently delete real local files. + +### Lifecycle & idempotency +- **Default lifecycle kind: long-lived.** A hand-created worktree with real work + is presumably valuable and should not be auto-reaped. Override with + `--ephemeral`/`--ttl`. (Differs from `add`, which follows + `config.worktree.defaultKind`.) +- **Idempotent re-apply.** If the worktree already has pando metadata, adopt + re-runs setup convergently (re-sync untracked, re-verify symlinks, refresh + metadata). Doubles as a "repair / re-setup." No `--force` required to re-run. + +### Preview & scope +- **`--dry-run` preview.** Prints the full plan (symlinks with conflicts flagged, + rsync file count, metadata/ports) without mutating. Works with `--json`. +- **Full parity with `add`.** Runs rsync + symlinks + metadata + ports + + post-commands by default; the same `--skip-*` flags apply. + +### Open-but-defaulted (react during review) +- **`sourceBranch` metadata = `config.worktree.targetBranch`.** `add` records the + branch it branched *from*; adopt has no such moment, so it records the + integration branch (e.g. `main`/`develop`) since that is what reap/stale + detection compares against. Alternatives: store the worktree's own current + branch, or omit. **Chosen: `targetBranch`.** +- **`--replace-existing` flag name** (vs `--force`, `--overwrite`, + `--replace-symlinks`). `add`'s `--force` already means "reset branch," so a + distinct name avoids overloading. **Chosen: `--replace-existing`.** +- **Post-commands key: `postCommands.adopt` with fallback to `postCommands.add`.** + Gives parity by default (existing `add` hooks run) while allowing + adopt-specific overrides. Alternatives: `adopt`-only (no fallback), or reuse + `add` directly. + +## Behavior & scenarios + +- **Clean foreign worktree, no conflicts** — full setup applied; metadata + stamped long-lived; ports allocated; `cleanTree: true`; output lists created + symlinks + synced file count. +- **Dirty foreign worktree (uncommitted work)** — setup applied; tracked/modified + files untouched; warning lists dirty paths; `cleanTree` reflects pando's own + additions only. +- **Real file where a symlink is planned** — that item skipped + warned; rest of + setup proceeds; `--replace-existing` replaces it instead. +- **Already pando-managed** — logs "already managed, re-applying"; converges; + metadata refreshed. +- **`--dry-run`** — plan printed/emitted; zero mutation; exit 0. +- **Setup fails mid-apply** — non-destructive rollback reverts only pando's own + file ops this run; the worktree and all user work survive; command exits + non-zero with the partial result. +- **Target is the main worktree / not a worktree / not a repo** — validation + error, no mutation. +- **Detached HEAD** — adoptable; `sourceBranch` falls back to + `config.worktree.targetBranch`. + +## Scope & non-goals + +Out of scope: +- Adopting a directory that is not a linked worktree. +- Adopting the main worktree. +- Creating worktrees (that remains `pando add`). +- A general "repair" tool beyond idempotent re-apply. +- Git-config migration beyond what `ensureWorktreeConfigEnabled` already does. + +## New / changed pieces + +- **New** `src/commands/adopt.ts` — the command. +- **New** `GitHelper.getWorktreeByPath(absPath): Promise` — + resolve+match a linked worktree by absolute path (no such helper exists today). +- **Changed** `src/utils/worktreeSetup.ts` — add `SetupOptions.adopt` mode (the 3 + behavior changes) and symlink-conflict classification in the plan. +- **Refactor** `add`'s private `setupLifecycleMetadata` into a shared helper + consumed by both `add` and `adopt`. +- **New** `postCommands.adopt` config key (schema + `.add` fallback wiring). +- **Docs** — README (command + flags), `.pando.toml.example` (`postCommands.adopt`), + `src/commands/DESIGN.md`, config schema doc. + +## Industry Insights + +- **Naming**: "adopt" is the fitting verb for bringing an externally-created + resource under management — Terraform uses `import`, Helm 3.17+ adds + `--take-ownership`, Kubernetes uses annotation-based adoption, Cargo `init` + bootstraps an existing dir. "Import"/"init" carry different connotations; the + ownership-transfer framing fits a worktree takeover best. + ([Terraform import](https://scalr.com/learning-center/the-ultimate-guide-to-terraform-import), + [Helm --take-ownership](https://alexandre-vazquez.com/helm-take-ownership/), + [Cargo init](https://doc.rust-lang.org/cargo/commands/cargo-init.html)) +- **Dry-run is near-mandatory** for takeover-class operations that mutate + pre-existing state; it must be *detailed* (which files, which symlink targets, + conflict flags), not just "operation would occur." Terraform `plan`, Ansible + `--check`, Kubernetes `--dry-run` normalized this. + ([Dry-run engineering](https://dev.to/danieljglover/dry-run-engineering-the-simple-practice-that-prevents-production-disasters-ek0), + [CLI preview patterns](https://nickjanetakis.com/blog/cli-tools-that-support-previews-dry-runs-or-non-destructive-actions)) +- **Symlink creation over real files is dangerous** — `ln -sf` is non-atomic + (unlink+symlink race), and remove-then-create can clobber real content. Check + target type before acting; never delete a real file to make a symlink without + explicit opt-in. ([node-tar symlink advisory](https://github.com/isaacs/node-tar/security/advisories/GHSA-9r2w-394v-53qc)) +- **Protect dirty state before mutating** — check `git status` first; for an + adopt whose *purpose* is work-in-progress, that means protecting (not + blocking) dirty files: sync only untracked-ignored artifacts, never tracked or + modified files. +- **Idempotency via check-before-act & non-destructive failure** — guard each + phase on current state; prefer checkpoint/resume over destructive rollback; + never leave a half-applied worktree worse than found. + ([Shell idempotency](https://www.commandinline.com/shell-script-idempotency-safe-rerun-patterns/), + [idempotent agent ops](https://www.agentpatterns.ai/agent-design/idempotent-agent-operations/)) +- **Detect "already managed"** — Terraform errors on double-import, Helm/K8s check + ownership metadata. Here: `readMetadata().kind !== undefined` signals an + already-adopted worktree → idempotent re-apply rather than error. + +## Deferred Ideas + +None — discussion stayed within scope. + +## Glossary + +- **Worktree** — a linked working directory of a git repo (`git worktree`), + sharing the repo's object store but with its own checked-out branch. +- **Main worktree** — the primary working directory (first entry of + `git worktree list`); cannot be adopted. +- **Foreign worktree** — a worktree created outside pando (raw `git worktree add` + or another tool) with no `pando.*` metadata. +- **Adopt-safe mode** — the `SetupOptions.adopt` orchestrator variant: non- + destructive rollback, skip-not-clobber symlinks, dirt-tolerant validation. +- **`onlyUntracked` rsync** — the default mode that syncs only gitignored/untracked + artifacts (e.g. `node_modules`, `.venv`), never tracked files. +- **skip-worktree** — `git update-index --skip-worktree`, used to hide + pando-created symlinks of tracked paths from `git status`. +- **Lifecycle kind** — `ephemeral` (reapable by TTL) vs `long-lived`; stored as + `pando.kind`. + +## Status & amendments + +**Amendments:** none yet. diff --git a/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md b/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md new file mode 100644 index 0000000..fbebf2b --- /dev/null +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md @@ -0,0 +1,311 @@ +# Tech Spec: `pando adopt` + +Companion to `logic.md` (approved). This is the implementation contract. Grounded +in the current code at the SHAs/line numbers noted; verify before editing. + +**Complexity-tier gate:** authored — criterion fired: touches ≳3 source files +(`adopt.ts` new, `git.ts`, `worktreeSetup.ts`, `add.ts`, a new post-command +runner util, plus tests/docs). + +## Subsystem anchor + +- `src/commands/` — CLI commands (oclif). New `adopt.ts`; `add.ts` refactored to + share the post-command runner. +- `src/utils/worktreeSetup.ts` — the setup orchestrator. Gains an **adopt mode**. +- `src/utils/git.ts` — `GitHelper`. Gains `getWorktreeByPath`. +- `src/utils/postCommandRunner.ts` (**new**) — shared trust-gated post-command + execution, extracted from `add.ts`. + +## File-level changes + +### 1. `src/utils/git.ts` — `getWorktreeByPath` + +New method on `GitHelper`: + +```ts +async getWorktreeByPath( + targetPath: string +): Promise<{ info: WorktreeInfo; isMain: boolean } | null> +``` + +- Resolve `targetPath` and each `listWorktrees()` entry's `path` through + `fs.realpath` before comparison. **Rationale:** on macOS `/tmp` → + `/private/tmp`, and `process.cwd()` vs `git worktree list` can differ; a raw + string compare would spuriously miss. Fall back to `path.resolve` when + `realpath` throws (path may not exist on disk in edge cases, though for adopt + it always does). +- `isMain = index === 0` — git porcelain guarantees the main worktree is listed + first (already relied on in `worktreeMetadata.enumerateAll`, worktreeMetadata.ts:182). +- Returns `null` when no worktree matches. + +### 2. `src/utils/worktreeSetup.ts` — adopt mode (the crux) + +**DO-NOT-CHANGE fence:** the existing create-time behavior (`adopt` unset) must +be byte-for-byte unchanged. Every new branch is gated on an adopt/dry-run option +that defaults false. `add.ts` passes none of them, so its path is untouched. + +`SetupOptions` gains: + +```ts +adopt?: boolean // enable adopt-safe mode +replaceExistingSymlinks?: boolean // adopt: replace real files at symlink targets (add-parity) +dryRun?: boolean // compute + return the plan, mutate nothing +preexistingDirtyPaths?: string[] // adopt: baseline dirt to exclude from the clean-tree check +``` + +`SetupResult` gains an optional plan (populated on dry-run, and on a real adopt +run for reporting): + +```ts +plan?: { + symlinks: { toCreate: string[]; alreadyLinked: string[]; conflicts: string[] } + rsyncFileCount: number // untracked-ignored files that would/did sync (onlyUntracked) + rsyncMode: 'untracked' | 'full' | 'skipped' +} +``` + +Three behavior changes, each gated on `options.adopt`: + +**(a) Non-destructive rollback.** Skip `this.transaction.createCheckpoint('worktree', …)` +(worktreeSetup.ts:187) when `options.adopt`. `rollback()` looks up the `'worktree'` +checkpoint to decide whether to `removeWorktree` (worktreeSetup.ts:536-548); with +no checkpoint it rolls back only the recorded file/rsync/symlink operations and +never touches the worktree. This is the top safety invariant. No change to +`rollback()` itself — it already no-ops the worktree removal when the checkpoint +is absent. Add a code comment at the checkpoint site explaining the adopt skip. + +**(b) Preserve-existing symlinks.** `createPlannedSymlinks` (worktreeSetup.ts:585) +gets a `preserveExisting: boolean` parameter, computed by the caller as +`adopt && !replaceExistingSymlinks`: +- `preserveExisting === false` (create-time, or adopt `--replace-existing`): + unchanged — `fs.remove` each existing target, then + `createSymlinks({ replaceExisting: true, skipConflicts: true, items })`. +- `preserveExisting === true`: **do not** `fs.remove`. First drop items already + correctly symlinked (via `symlinkHelper.verifySymlink(target, source)`) so an + idempotent re-run doesn't report them as conflicts. Then + `createSymlinks({ replaceExisting: false, skipConflicts: true, items: remaining })`. + `detectConflicts` (fileOps.ts:1080) flags any real file/dir/wrong-symlink at a + target; `skipConflicts` skips them into `result.conflicts`, which the existing + warning path (worktreeSetup.ts:238-240) surfaces. **No user file is deleted.** + +**(c) Dirt-tolerant clean-tree check.** In Phase 6 (worktreeSetup.ts:435-454), +subtract `options.preexistingDirtyPaths` (in addition to `symlinkItems`) from +`getDirtyPaths` before deciding `cleanTree`. So `cleanTree` reflects only paths +pando touched, not the user's pre-existing work-in-progress. + +**Dry-run.** When `options.dryRun`, after computing `symlinkItems` and the symlink +classification and the rsync file list (`listIgnoredFiles` for untracked mode, or +the commit-match decision for full mode) — return early with +`{ success: true, plan, duration, warnings, rolledBack: false }` before Phase 3. +No symlink/rsync/skip-worktree mutation occurs. Dry-run is meaningful only under +`adopt` in practice, but the short-circuit is independent. + +New private helper `classifyAdoptSymlinks(sourceTreePath, worktreePath, symlinkItems, symlinkConfig)` +returns `{ toCreate, alreadyLinked, conflicts }` by stat/verify per item; used by +both the dry-run plan and the preserve-existing no-op filter. + +**Rollback safety (critical).** Rsync records its destination — the worktree +root — as its transaction rollback target, so the default RSYNC rollback +`fs.remove(destination)` would delete the entire adopted worktree. Skipping the +`worktree` checkpoint is NOT sufficient. Adopt mode therefore also passes +`skipRsyncRollback: true` to `FileOperationTransaction.rollback()`, so on failure +it reverts only the symlinks pando created and leaves the worktree and all synced +artifacts in place. Residual gitignored artifacts are harmless and a re-run of +`adopt` converges. (Found in adversarial review; covered by a real-transaction +test.) + +**Rsync hardening (critical).** Adopt forces `rsyncConfig.onlyUntracked = true` +(a config with `onlyUntracked = false` would full-mirror and clobber tracked +files when commits match) and adds `--ignore-existing` (a path gitignored in the +source but already present in the adopted worktree — e.g. a hand-made `.env` — +must not be overwritten by the source copy). (Found in adversarial review.) + +**Idempotent re-apply preserves lifecycle facts.** When the target already has +pando metadata and no explicit lifecycle flag is passed, adopt preserves the +existing `kind`, `createdAt`, and `sourceBranch` (which drive reap + age) rather +than rewriting them. (Found in adversarial review.) + +### 3. `src/utils/postCommandRunner.ts` (new) — shared trust-gated runner + +Extract `add.ts`'s `runPostCommands` (add.ts:916-971) and `evaluatePostCommandTrust` +(add.ts:979-1090) into one exported function so both commands share the trust gate +(the logic is ~130 lines and must not be duplicated): + +```ts +export async function runTrustedPostCommands(params: { + command: Command + config: PandoConfig + commandName: string // 'add' | 'adopt' — used in messaging + PANDO_COMMAND + scriptKey: string // config key to read; 'add' | 'adopt' + fallbackScriptKey?: string // adopt passes 'add' so existing add hooks run + context: Omit + isJson: boolean + spinner: Ora | null + warnings: string[] +}): Promise +``` + +- Scripts = `normalizePostCommandScripts(config, scriptKey)`; if empty and + `fallbackScriptKey` set, retry with the fallback key. +- Same trust gate (`decidePostCommandTrust`, inquirer confirm, `recordTrust`, + `PANDO_TRUST_CONFIG`) as today, keyed on `config.postCommandsSourcePath`. +- Emits warnings via `ErrorHelper.warn(command, …)` (non-JSON) or pushes to + `warnings` (JSON) — same `emitWarning` semantics, inlined. + +`add.ts` change: delete the two private methods; call `runTrustedPostCommands` +with `scriptKey: 'add'`, no fallback. **Behavior must stay identical** — add's +existing tests are the guard. No schema change: `postCommands` is already a +free-form `Record` (schema.ts:184), so the `adopt` +key needs no Zod addition. + +### 4. `src/commands/add.ts` — kind-override hook for reuse + +`setupLifecycleMetadata` (add.ts:104) currently derives `kind` via +`resolveWorktreeKind(flags, worktreeConfig.defaultKind, …)`. Add an optional +`kindOverride?: WorktreeKind` to `LifecycleOptions`; when present, use it instead +of calling `resolveWorktreeKind`. `add` passes nothing (unchanged). `adopt` passes +its long-lived-default resolution. + +New exported helper (in `add.ts` next to `resolveWorktreeKind`, or in `adopt.ts`): + +```ts +export function resolveAdoptKind(flags: Record): WorktreeKind { + if (flags.ephemeral) return 'ephemeral' + if (flags['long-lived']) return 'long-lived' + return 'long-lived' // adopt default: never auto-reap a hand-made worktree +} +``` + +`config.worktree.defaultKind` is intentionally ignored for adopt (logic.md +Decisions-Locked). + +### 5. `src/commands/adopt.ts` (new) — the command + +Flags (from `common-flags` + local): `path` (`pathFlag`), `dry-run`, +`replace-existing`, `ephemeral`/`long-lived` (mutually exclusive), `ttl`, `owner`, +`ports`, `skip-rsync`, `rsync-flags`, `rsync-exclude`, `skip-symlink`, `symlink`, +`absolute-symlinks`, `details`, `json`. Optional positional `path` arg (defaults +to cwd; `--path` also accepted, arg wins if both given — match add's arg/flag +merge pattern). + +`run()` flow: + +1. `gitHelper.isRepository()` guard (ErrorHelper.validation on failure). +2. Resolve target: positional arg || `--path` || `process.cwd()`; resolve to + absolute. `gitRoot = getRepositoryRoot()`. +3. `const match = await gitHelper.getWorktreeByPath(target)`: + - `null` → validation error: "…is not a linked worktree of this repo. Use + `pando add` to create one." + - `match.isMain` → validation error: "Cannot adopt the main worktree." +4. Load + merge config (reuse the same flag-override logic as + `add.loadAndMergeConfig`; factor the shared override block into a small helper + or replicate — see Open item below). +5. `readMetadata(target)`; if `metadata.kind !== undefined`, note "already managed + by pando — re-applying" (idempotent; not an error). +6. Baseline dirt: `preexistingDirtyPaths = await gitHelper.getDirtyPaths(target)` + (best-effort; used to refine clean-tree check and to report preserved work). +7. Build setup options: `{ adopt: true, dryRun: flags['dry-run'], + replaceExistingSymlinks: flags['replace-existing'], skipRsync, skipSymlink, + preexistingDirtyPaths, onProgress }`. +8. `const setup = await orchestrator.setupNewWorktree(target, setupOptions)`. + - **No SIGINT rollback-that-removes-worktree** — adopt mode already makes + rollback non-destructive; a SIGINT handler, if registered, calls the same + non-destructive `rollback()`. (Reuse add's handler; it is safe under adopt.) +9. If `flags['dry-run']`: format the plan (human/JSON) and return — skip + lifecycle/post-commands entirely. +10. Lifecycle: `setupLifecycleMetadata({ …, kindOverride: resolveAdoptKind(flags), + sourceBranch: config.worktree.targetBranch, worktreeBranch: match.info.branch })`. + `mainRepoPath` resolved as in add.ts:432-434. +11. Post-commands: `runTrustedPostCommands({ command: this, config, commandName: + 'adopt', scriptKey: 'adopt', fallbackScriptKey: 'add', context: { cwd: target, + worktreePath: target, branch: match.info.branch, commit: match.info.commit, + kind, ttl, ports, dbName }, … })`. +12. `formatOutput` — adopt-specific (below). + +`sourceBranch` for metadata = `config.worktree.targetBranch` (default `main`), +per logic.md. Detached-HEAD target → `match.info.branch` is `null`; lifecycle +still records `targetBranch` as sourceBranch and uses `basename(path)` for the db +name (setupLifecycleMetadata already handles `worktreeBranch ?? basename`). + +Output (`formatOutput`), two modes: +- **Dry-run:** header "Would adopt "; list symlinks to create / + already-linked / conflicts (skipped, with reason); rsync file count + mode; + metadata that would be written (kind, sourceBranch, owner, ttl); ports that + would allocate (note: allocation is not simulated in v1 — state "ports: would + allocate N in range" without probing). JSON: `{ success: true, dryRun: true, + plan: {…}, wouldWrite: {…} }`. +- **Real:** header "✓ Adopted "; branch/commit/kind; symlinks + created/skipped(conflicts with reasons)/already-linked; rsync files; ports/db; + post-command results; `cleanTree`; preserved-dirty summary ("N pre-existing + changes left untouched"); warnings. JSON mirrors add's shape plus + `adopted: true`, `preexistingDirty: string[]`, and the symlink conflict list. + +Error handling: reuse the `SetupError` / generic branches from add's +`handleError`, MINUS any "rolled back / worktree removed" framing — adopt failures +never remove the worktree. Message on `SetupError`: "Adopt failed; the worktree +and your changes were left untouched." Re-throw oclif exit errors +(`isOclifExitError`). + +### 6. Docs + +- `README.md` — `pando adopt` section: purpose, flags, `--dry-run`, + `--replace-existing`, the "never touches your work" guarantee, `postCommands.adopt`. +- `.pando.toml.example` — document `[postCommands] adopt = [...]`. +- `src/commands/DESIGN.md` — add adopt to the command inventory + the adopt-mode + note on the orchestrator. + +## Open item (resolve during impl) + +The rsync/symlink flag-override block in `add.loadAndMergeConfig` (add.ts:650-688) +is identical to what adopt needs. Prefer extracting it to a small shared helper +`applySetupFlagOverrides(config, flags, emitWarning)` used by both; if that proves +to entangle add's warnings plumbing, replicate the ~30 lines in adopt and note it. +Decide in favor of extraction unless it grows add's risk. + +## Test plan (TDD — write tests first per unit) + +Unit (vitest, test logic directly per CLAUDE.md): +- `git.getWorktreeByPath`: matches by realpath; returns `isMain` for index 0; + `null` for a non-worktree path; handles symlinked temp dirs. +- `worktreeSetup` adopt mode: + - adopt + real file at symlink target → skipped as conflict, file still on disk, + no throw. + - adopt + already-correct symlink → no-op, not reported as conflict (idempotent). + - adopt + `replaceExistingSymlinks` → replaces (add-parity). + - adopt failure path → worktree NOT removed (assert dir still exists), file ops + rolled back. + - adopt + `preexistingDirtyPaths` → clean-tree check excludes them. + - `dryRun` → returns `plan`, mutates nothing (no symlinks created, no rsync). + - create-time (adopt unset) → unchanged (existing tests still green). +- `resolveAdoptKind`: ephemeral/long-lived flags, default long-lived. +- `runTrustedPostCommands`: fallback key; trust-skip in JSON/non-TTY; env trust. +- `postCommandRunner` extraction: add's post-command tests still pass. + +E2E (Docker, real git — `--hookTimeout=60000`): +- Raw `git worktree add` → `pando adopt ` → metadata written, symlinks + created, gitignored artifacts synced, exit 0, git status clean aside from + pando symlinks. +- Adopt a dirty worktree (uncommitted tracked change + untracked file) → + changes preserved, exit 0. +- Adopt with a real `node_modules` present at a symlink target → skipped+warned, + real dir preserved; with `--replace-existing` → replaced by symlink. +- `--dry-run` → no changes on disk, plan printed; `--json` well-formed. +- Adopt the main worktree → validation error, exit non-zero. +- Adopt a non-worktree dir → validation error. +- Idempotent: adopt twice → second run converges, no errors. + +## Acceptance commands + +``` +pnpm build +pnpm lint +pnpm test +pnpm vitest run test/e2e/adopt.e2e.test.ts --hookTimeout=60000 +``` + +## DO-NOT-CHANGE + +- Create-time setup behavior (orchestrator with `adopt` unset) — byte-identical. +- `add`'s observable output/JSON shape and its post-command trust semantics. +- The rsync `onlyUntracked` default and the "never copy tracked files" invariant. +- `rollback()`'s worktree-removal path for the create-time flow. diff --git a/src/commands/DESIGN.md b/src/commands/DESIGN.md index 09eede5..c5cb31d 100644 --- a/src/commands/DESIGN.md +++ b/src/commands/DESIGN.md @@ -9,6 +9,7 @@ This directory contains all CLI command implementations for Pando. Each command | File | Description | |------|-------------| | `add.ts` | Create new git worktrees with optional rsync/symlink setup and post-commands | +| `adopt.ts` | Take over an externally-created worktree: run pando setup (adopt-safe mode) without creating or clobbering | | `list.ts` | List all git worktrees in the repository | | `remove.ts` | Remove worktrees with optional branch deletion (guards the main worktree) | | `clean.ts` | Detect and remove stale worktrees (merged, gone upstream, prunable) | @@ -74,6 +75,24 @@ export default class CommandName extends Command { - `--force, -f`: Force reset existing branch - `--no-rebase`: Skip automatic rebase +### adopt.ts + +**Purpose**: Take over a worktree pando did **not** create (raw `git worktree add`, another tool) and run pando's standard setup on it — the setup-only half of `add`, with safety rails for a worktree that may already hold real work. + +**Key Features**: +- Resolves the target from a positional `[path]` or the cwd; rejects the main worktree and non-worktrees (`GitHelper.getWorktreeByPath`) +- Reuses `WorktreeSetupOrchestrator.setupNewWorktree` in **adopt mode** (`SetupOptions.adopt`): non-destructive rollback (never `git worktree remove`), skip-not-clobber symlink conflicts (`--replace-existing` to override), dirt-tolerant clean-tree check +- `--dry-run` returns a `SetupPlan` (symlink create/already-linked/conflict buckets + rsync file count/mode) and mutates nothing +- Reuses `setupLifecycleMetadata` with a long-lived-default `kindOverride`; records `sourceBranch = worktree.targetBranch` +- Shares the trust-gated post-command runner (`runTrustedPostCommands`) with `add`, using the `adopt` config key (falling back to `add`) +- Idempotent: re-adopting an already-managed worktree re-applies and reports `alreadyManaged` + +**Flags**: +- `[path]`: Worktree to adopt (positional; defaults to cwd) +- `--dry-run`: Preview the plan without changes +- `--replace-existing`: Replace real files at symlink targets instead of skipping +- Lifecycle (`--ephemeral`/`--long-lived`/`--ttl`/`--owner`/`--ports`) and setup (`--skip-rsync`/`--skip-symlink`/`--rsync-*`/`--symlink`/`--absolute-symlinks`) flags mirror `add` + ### list.ts **Purpose**: Display all worktrees in the repository diff --git a/src/commands/add.ts b/src/commands/add.ts index e759f02..6c29cb5 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -13,22 +13,12 @@ import { import { allocate, deriveDbName } from '../utils/portAllocator.js' import { createWorktreeSetupOrchestrator, SetupPhase } from '../utils/worktreeSetup.js' import { jsonFlag, pathFlag } from '../utils/common-flags.js' +import { applySetupFlagOverrides } from '../utils/setupFlags.js' import { ErrorHelper, isOclifExitError } from '../utils/errors.js' import { validateBranchName } from '../utils/validation.js' -import { - computeConfigHash, - decidePostCommandTrust, - isConfigTrusted, - isEnvTrustEnabled, - recordTrust, -} from '../utils/configTrust.js' import { buildAddCommandDetails, type AddCommandDetails } from '../utils/commandDetails.js' -import { - normalizePostCommandScripts, - PostCommandError, - runPostCommandScripts, - type PostCommandResult, -} from '../utils/postCommands.js' +import { PostCommandError, type PostCommandResult } from '../utils/postCommands.js' +import { runTrustedPostCommands } from '../utils/postCommandRunner.js' type WorktreeKind = NonNullable @@ -56,6 +46,12 @@ interface LifecycleOptions { worktreeBranch: string | null env?: NodeJS.ProcessEnv createdAt?: string + /** + * Force the lifecycle kind instead of deriving it from flags/config/path. + * `pando adopt` uses this to default to long-lived (a hand-created worktree + * with real work should not be auto-reaped). + */ + kindOverride?: WorktreeKind } export interface AddLifecycleResult { @@ -108,7 +104,9 @@ export async function setupLifecycleMetadata( const { flags, worktreeConfig, portsConfig, gitHelper, gitRoot, mainRepoPath, resolvedPath } = options const env = options.env ?? process.env - const kind = resolveWorktreeKind(flags, worktreeConfig.defaultKind, resolvedPath, env) + const kind = + options.kindOverride ?? + resolveWorktreeKind(flags, worktreeConfig.defaultKind, resolvedPath, env) const owner = (flags.owner as string | undefined) ?? gitHelper.inferOwner() const hasActiveSession = env.CLAUDE_SESSION_ID !== undefined || env.PANDO_SESSION !== undefined const ttl = flags.ttl as string | undefined @@ -448,18 +446,25 @@ export default class AddWorktree extends Command { if (lifecycle.notice) ErrorHelper.warn(this, lifecycle.notice, false) lifecycle.warnings.forEach((warning) => ErrorHelper.warn(this, warning, false)) } - const postCommandResults = await this.runPostCommands( - flags as Record, + const postCommandResults = await runTrustedPostCommands({ + command: this, config, - worktreeInfo, - resolvedPath, + commandName: 'add', + scriptKey: 'add', + context: { + cwd: resolvedPath, + worktreePath: worktreeInfo.path, + branch: worktreeInfo.branch, + commit: worktreeInfo.commit, + kind: lifecycle.kind, + ttl: lifecycle.effectiveTtl, + ...(lifecycle.ports ? { ports: lifecycle.ports } : {}), + ...(lifecycle.dbName ? { dbName: lifecycle.dbName } : {}), + }, + isJson: Boolean(flags.json), spinner, - lifecycle.kind, - lifecycle.effectiveTtl, - lifecycle.ports, - lifecycle.dbName, - outputContext.warnings - ) + warnings: outputContext.warnings, + }) this.formatOutput( flags as Record, worktreeInfo, @@ -647,44 +652,10 @@ export default class AddWorktree extends Command { gitRoot, }) - // Apply flag overrides - if (flags['skip-rsync']) { - config.rsync.enabled = false - // Warn if rsync-specific flags were provided alongside --skip-rsync - if (flags['rsync-flags'] || flags['rsync-exclude']) { - this.emitWarning( - '--rsync-flags and --rsync-exclude are ignored when --skip-rsync is set', - Boolean(flags.json), - warnings - ) - } - } - if (flags['rsync-flags']) { - const rsyncFlags = flags['rsync-flags'] as string[] - config.rsync.flags = rsyncFlags.flatMap((f: string) => f.split(',')) - } - if (flags['rsync-exclude']) { - const rsyncExclude = flags['rsync-exclude'] as string[] - config.rsync.exclude = [ - ...config.rsync.exclude, - ...rsyncExclude.flatMap((e: string) => e.split(',')), - ] - } - if (flags['skip-symlink']) { - config.symlink.patterns = [] - } - if (flags.symlink) { - const symlinkPatterns = flags.symlink as string[] - config.symlink.patterns = symlinkPatterns.flatMap((s: string) => s.split(',')) - } - if (flags['absolute-symlinks']) { - config.symlink.relative = false - } - if (flags.ports) { - // Allocation lands in T8; preserving the run-level override now keeps the - // flag contract stable for that phase without allocating prematurely. - config.ports.enabled = true - } + // Apply the shared rsync/symlink/ports flag overrides (identical for adopt) + applySetupFlagOverrides(config, flags, (message) => + this.emitWarning(message, Boolean(flags.json), warnings) + ) return config } @@ -913,182 +884,6 @@ export default class AddWorktree extends Command { } } - private async runPostCommands( - flags: Record, - config: Awaited>, - worktreeInfo: { - path: string - branch: string | null - commit: string - }, - resolvedPath: string, - spinner: Awaited> | null, - kind: WorktreeKind, - ttl?: string, - ports?: Record, - dbName?: string, - warnings: string[] = [] - ): Promise { - const scripts = normalizePostCommandScripts(config, 'add') - - if (scripts.length === 0) { - return [] - } - - const isJson = Boolean(flags.json) - - // ============================================================ - // Trust gate (direnv-style): post-commands run with shell: true, - // so a config file from a freshly-cloned repo must be explicitly - // trusted before its scripts execute. See src/utils/configTrust.ts. - // ============================================================ - const allowed = await this.evaluatePostCommandTrust( - config.postCommandsSourcePath, - scripts, - isJson, - spinner, - warnings - ) - if (!allowed) { - return [] - } - - if (spinner) { - spinner.text = `Running ${scripts.length} post-command script${scripts.length === 1 ? '' : 's'}...` - } - - return runPostCommandScripts(scripts, { - commandName: 'add', - cwd: resolvedPath, - worktreePath: worktreeInfo.path, - branch: worktreeInfo.branch, - commit: worktreeInfo.commit, - kind, - ttl, - ...(ports ? { ports } : {}), - ...(dbName ? { dbName } : {}), - }) - } - - /** - * Decide whether post-commands from a config file are allowed to run, and - * persist trust when the user approves interactively. - * - * @returns True if the post-commands should run; false to skip them - */ - private async evaluatePostCommandTrust( - sourcePath: string | undefined, - scripts: Array<{ name?: string; command: string }>, - isJson: boolean, - spinner: Awaited> | null, - warnings: string[] = [] - ): Promise { - const envTrust = isEnvTrustEnabled(process.env.PANDO_TRUST_CONFIG) - - // Only hash/check trust when there is an actual file on disk to vet. - let currentHash: string | undefined - let trustedWithMatchingHash = false - if (sourcePath && !envTrust) { - try { - currentHash = await computeConfigHash(sourcePath) - trustedWithMatchingHash = await isConfigTrusted(sourcePath, currentHash) - } catch { - // If we cannot read/hash the file, treat it as untrusted. - trustedWithMatchingHash = false - } - } - - const isTty = Boolean(process.stdin.isTTY) - - const decision = decidePostCommandTrust({ - hasScripts: scripts.length > 0, - sourcePath, - envTrust, - trustedWithMatchingHash, - isTty, - isJson, - }) - - if (decision === 'run') { - return true - } - - if (decision === 'skip') { - this.emitWarning( - `Skipping ${scripts.length} post-command script(s) from untrusted config file` + - (sourcePath ? ` '${sourcePath}'` : '') + - '.\n' + - 'To allow them: run `pando add` interactively once to trust this file, ' + - 'or set PANDO_TRUST_CONFIG=1.', - isJson, - warnings - ) - return false - } - - // decision === 'prompt' (interactive TTY, not JSON) - // Pause the spinner so the inquirer prompt renders cleanly. By this point - // the spinner has typically already succeeded (setup completed), so it is - // usually NOT spinning — but it may still be active if a caller invokes the - // trust gate mid-setup. The wasSpinning guard handles both cases: we only - // stop a spinner that is actually running, and only restart it afterward if - // we stopped it (see the matching `if (spinner && wasSpinning)` below). - const wasSpinning = Boolean(spinner?.isSpinning) - if (spinner && wasSpinning) { - spinner.stop() - } - - if (!isJson) { - this.log('') - this.log(`A config file requests running post-command scripts on 'pando add':`) - if (sourcePath) { - this.log(` File: ${sourcePath}`) - } - for (const script of scripts) { - const label = script.name ? `${script.name}: ${script.command}` : script.command - this.log(` • ${label}`) - } - this.log('') - } - - const { confirm } = await import('@inquirer/prompts') - const approved = await confirm({ - message: 'Trust this config file and run its post-commands?', - default: false, - }) - - if (!approved) { - this.emitWarning( - `Skipped ${scripts.length} post-command script(s); config file not trusted.`, - isJson, - warnings - ) - return false - } - - // Persist trust at the current content hash, then run. - if (sourcePath) { - try { - const hash = currentHash ?? (await computeConfigHash(sourcePath)) - await recordTrust(sourcePath, hash) - } catch { - // Non-fatal: failing to persist trust just means we'll prompt again - // next time. Still allow this run since the user approved it. - this.emitWarning( - 'Could not persist trust decision; will prompt again next time.', - isJson, - warnings - ) - } - } - - if (spinner && wasSpinning) { - spinner.start() - } - - return true - } - /** * Phase 5: Output formatting */ diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts new file mode 100644 index 0000000..5028d24 --- /dev/null +++ b/src/commands/adopt.ts @@ -0,0 +1,682 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createGitHelper } from '../utils/git.js' +import { loadConfig, type LoadedPandoConfig } from '../config/loader.js' +import { readMetadata, type WorktreeMetadata } from '../utils/worktreeMetadata.js' +import { + createWorktreeSetupOrchestrator, + SetupPhase, + type SetupResult, +} from '../utils/worktreeSetup.js' +import { setupLifecycleMetadata, type AddLifecycleResult } from './add.js' +import { applySetupFlagOverrides } from '../utils/setupFlags.js' +import { jsonFlag } from '../utils/common-flags.js' +import { ErrorHelper, isOclifExitError } from '../utils/errors.js' +import { runTrustedPostCommands } from '../utils/postCommandRunner.js' +import { PostCommandError, type PostCommandResult } from '../utils/postCommands.js' +import type { WorktreeInfo } from '../utils/git.js' + +type WorktreeKind = NonNullable + +/** + * Resolve the lifecycle kind for an adopted worktree. Unlike `pando add`, adopt + * defaults to long-lived (a hand-created worktree with real work should not be + * auto-reaped) and ignores `config.worktree.defaultKind` entirely; only an + * explicit flag overrides the default. + */ +export function resolveAdoptKind(flags: Record): WorktreeKind { + if (flags.ephemeral) return 'ephemeral' + if (flags['long-lived']) return 'long-lived' + return 'long-lived' +} + +/** + * Adopt a git worktree pando did not create. + * + * Runs pando's standard setup (rsync of untracked artifacts, symlinks, + * skip-worktree, lifecycle metadata, ports, post-commands) against a worktree + * created by raw `git worktree add` or another tool. Never overwrites the user's + * work and never removes the worktree. + */ +export default class AdoptWorktree extends Command { + static description = + 'Adopt an existing git worktree (created outside pando) and run pando setup on it' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> ../feature-x', + '<%= config.bin %> <%= command.id %> ../feature-x --dry-run', + '<%= config.bin %> <%= command.id %> ../feature-x --replace-existing', + '<%= config.bin %> <%= command.id %> ../feature-x --ephemeral --ttl 4h', + ] + + static args = { + path: Args.string({ + description: 'Path to the worktree to adopt (defaults to the current directory)', + required: false, + }), + } + + static flags = { + 'dry-run': Flags.boolean({ + description: 'Preview the plan without changing anything', + default: false, + }), + 'replace-existing': Flags.boolean({ + description: 'Replace real files at symlink targets instead of skipping them', + default: false, + }), + + // Lifecycle + ephemeral: Flags.boolean({ + description: 'Mark the worktree as ephemeral', + exclusive: ['long-lived'], + default: false, + }), + 'long-lived': Flags.boolean({ + description: 'Mark the worktree as long-lived (the adopt default)', + exclusive: ['ephemeral'], + default: false, + }), + ttl: Flags.string({ description: 'Set a per-worktree lifecycle duration' }), + owner: Flags.string({ description: 'Set the worktree owner or agent session id' }), + ports: Flags.boolean({ description: 'Enable port allocation for this run', default: false }), + + // Rsync + 'skip-rsync': Flags.boolean({ description: 'Skip rsync (ignore config)', default: false }), + 'rsync-flags': Flags.string({ + description: 'Override rsync flags (comma-separated)', + multiple: true, + }), + 'rsync-exclude': Flags.string({ + description: 'Additional rsync exclude patterns', + multiple: true, + }), + + // Symlink + 'skip-symlink': Flags.boolean({ + description: 'Skip symlink creation (ignore config)', + default: false, + }), + symlink: Flags.string({ + description: 'Additional symlink patterns (overrides config)', + multiple: true, + }), + 'absolute-symlinks': Flags.boolean({ + description: 'Use absolute paths for symlinks instead of relative', + default: false, + }), + + // Output + details: Flags.boolean({ description: 'Show detailed setup information', default: false }), + json: jsonFlag, + } + + async run(): Promise { + const { flags, args } = await this.parse(AdoptWorktree) + const isJson = Boolean(flags.json) + const warnings: string[] = [] + const startTime = Date.now() + const { spinner, chalk } = await this.initializeUI(isJson) + + try { + const gitHelper = createGitHelper() + if (!(await gitHelper.isRepository())) { + this.failValidation( + 'Not a git repository. Run this command from within a git repository.', + isJson, + warnings + ) + } + + const gitRoot = await gitHelper.getRepositoryRoot() + + // Resolve the target: positional arg, else the current working directory. + const targetInput = args.path ?? process.cwd() + const match = await gitHelper.getWorktreeByPath(targetInput) + if (!match) { + this.failValidation( + `'${targetInput}' is not a linked worktree of this repository.\n\n` + + `Use 'pando add' to create a new worktree, or run adopt from inside an existing linked worktree.`, + isJson, + warnings + ) + } + if (match.isMain) { + this.failValidation( + `Cannot adopt the main worktree (${match.info.path}).\n\n` + + `Adopt applies to linked worktrees created outside pando.`, + isJson, + warnings + ) + } + // Use the canonical path git records, not the (possibly relative) input. + const targetPath = match.info.path + + const config = await this.loadAndMergeConfig( + flags as Record, + gitHelper, + spinner, + warnings + ) + gitHelper.setRetryConfig(config.concurrency.retry) + + // Idempotent re-apply: already-managed worktrees are re-set-up, not rejected. + const existing = await readMetadata(targetPath) + const alreadyManaged = existing.kind !== undefined + if (alreadyManaged && !isJson) { + ErrorHelper.warn( + this, + `Worktree is already pando-managed (${existing.kind}); re-applying setup.`, + false + ) + } + + // Baseline dirt (best-effort): the user's pre-existing work, protected from + // the clean-tree check and reported as preserved. + let preexistingDirtyPaths: string[] = [] + try { + preexistingDirtyPaths = await gitHelper.getDirtyPaths(targetPath) + } catch { + // Non-fatal: without a baseline the clean-tree check is just less precise. + } + + // Idempotent re-apply must not silently rewrite lifecycle facts. When the + // worktree is already managed and no lifecycle flag is passed, preserve its + // existing kind / sourceBranch / createdAt (which drive reap + age); only an + // explicit --ephemeral/--long-lived overrides the kind. + const explicitKind = Boolean(flags.ephemeral || flags['long-lived']) + const adoptKind: WorktreeKind = explicitKind + ? resolveAdoptKind(flags as Record) + : (existing.kind ?? 'long-lived') + const adoptSourceBranch = existing.sourceBranch ?? config.worktree.targetBranch ?? 'main' + + const setupResult = await this.runSetup( + flags as Record, + config, + gitHelper, + targetPath, + spinner, + preexistingDirtyPaths + ) + + // Dry run stops here: no metadata, no ports, no post-commands. + if (flags['dry-run']) { + this.formatDryRun( + flags as Record, + targetPath, + match.info, + setupResult, + adoptKind, + adoptSourceBranch, + chalk, + warnings + ) + return + } + + const mainRepoPath = config.ports.enabled + ? await gitHelper.getMainWorktreePath().catch(() => gitRoot) + : gitRoot + const lifecycle = await setupLifecycleMetadata({ + flags: flags as Record, + worktreeConfig: config.worktree, + portsConfig: config.ports, + gitHelper, + gitRoot, + mainRepoPath, + resolvedPath: targetPath, + // First adopt records the integration branch (adopt has no "branched-from" + // moment); a re-adopt preserves whatever was already stored. + sourceBranch: adoptSourceBranch, + worktreeBranch: match.info.branch, + kindOverride: adoptKind, + // Preserve the original creation time on re-adopt (undefined -> now on + // first adopt). + createdAt: existing.createdAt, + }) + if (!isJson) { + if (lifecycle.notice) ErrorHelper.warn(this, lifecycle.notice, false) + lifecycle.warnings.forEach((warning) => ErrorHelper.warn(this, warning, false)) + } + + const postCommandResults = await runTrustedPostCommands({ + command: this, + config, + commandName: 'adopt', + scriptKey: 'adopt', + fallbackScriptKey: 'add', + context: { + cwd: targetPath, + worktreePath: targetPath, + branch: match.info.branch, + commit: match.info.commit, + kind: lifecycle.kind, + ttl: lifecycle.effectiveTtl, + ...(lifecycle.ports ? { ports: lifecycle.ports } : {}), + ...(lifecycle.dbName ? { dbName: lifecycle.dbName } : {}), + }, + isJson, + spinner, + warnings, + }) + + this.formatOutput({ + flags: flags as Record, + targetPath, + info: match.info, + setupResult, + lifecycle, + postCommandResults, + alreadyManaged, + preexistingDirtyPaths, + duration: Date.now() - startTime, + chalk, + warnings, + }) + } catch (error) { + this.handleError(error, flags as Record, spinner, warnings) + } + } + + private async initializeUI(isJson: boolean): Promise<{ + spinner: Awaited> | null + chalk: Awaited | null + }> { + const ora = !isJson ? (await import('ora')).default : null + const spinner = ora ? ora() : null + const chalk = !isJson ? (await import('chalk')).default : null + return { spinner, chalk } + } + + private emitWarning(message: string, isJson: boolean, warnings: string[]): void { + if (isJson) { + warnings.push(message) + } else { + ErrorHelper.warn(this, message, false) + } + } + + private failValidation(message: string, isJson: boolean, warnings: string[]): never { + if (!isJson) return ErrorHelper.validation(this, message, false) + this.log(JSON.stringify({ success: false, error: message, warnings }, null, 2)) + this.exit(1) + } + + private async loadAndMergeConfig( + flags: Record, + gitHelper: ReturnType, + spinner: Awaited> | null, + warnings: string[] + ): Promise { + if (spinner) spinner.text = 'Loading configuration...' + // adopt runs from inside the target worktree, so process.cwd()/--show-toplevel + // both resolve to that worktree. Project config (symlink/rsync patterns) lives + // in the main worktree — which is also the rsync/symlink source. Loading from + // the target worktree instead would miss the real config AND try to parse the + // user's own local files (e.g. a non-JSON package.json at a symlink target), + // which adopt must tolerate rather than choke on. + const mainWorktree = await gitHelper.getMainWorktreePath() + const config = await loadConfig({ cwd: mainWorktree, gitRoot: mainWorktree }) + applySetupFlagOverrides(config, flags, (message) => + this.emitWarning(message, Boolean(flags.json), warnings) + ) + return config + } + + private async runSetup( + flags: Record, + config: LoadedPandoConfig, + gitHelper: ReturnType, + targetPath: string, + spinner: Awaited> | null, + preexistingDirtyPaths: string[] + ): Promise { + const orchestrator = createWorktreeSetupOrchestrator(gitHelper, config) + const setupOptions = { + adopt: true, + dryRun: flags['dry-run'] as boolean | undefined, + replaceExistingSymlinks: flags['replace-existing'] as boolean | undefined, + skipRsync: flags['skip-rsync'] as boolean | undefined, + skipSymlink: flags['skip-symlink'] as boolean | undefined, + preexistingDirtyPaths, + onProgress: this.buildProgressCallback(spinner), + } + + // A SIGINT mid-setup triggers the orchestrator's rollback. In adopt mode + // rollback is non-destructive (it never removes the worktree), so this is + // safe even though the worktree predates pando. + const sigintListener = (): void => { + void (async (): Promise => { + if (spinner) spinner.stop() + try { + await orchestrator.rollback() + } catch { + // rollback swallows its own errors; this guard just ensures exit 130. + } + if (!flags.json) this.log('\nInterrupted — rolled back pando changes (worktree preserved)') + process.exit(130) + })() + } + process.once('SIGINT', sigintListener) + + try { + return await orchestrator.setupNewWorktree(targetPath, setupOptions) + } catch (error) { + if (spinner) spinner.fail('Setup failed') + throw error + } finally { + process.removeListener('SIGINT', sigintListener) + } + } + + private buildProgressCallback( + spinner: Awaited> | null + ): (phase: SetupPhase, message: string) => void { + return (phase: SetupPhase, message: string): void => { + if (!spinner) return + if (phase === SetupPhase.COMPLETE) { + spinner.succeed('Setup complete') + } else if (phase === SetupPhase.ROLLBACK) { + spinner.fail(message || 'Setup failed, rolling back...') + } else { + spinner.text = message || `Processing: ${phase}...` + } + } + } + + private formatDryRun( + flags: Record, + targetPath: string, + info: WorktreeInfo, + setupResult: SetupResult, + kind: WorktreeKind, + sourceBranch: string, + chalk: Awaited | null, + warnings: string[] + ): void { + const plan = setupResult.plan ?? { + symlinks: { toCreate: [], alreadyLinked: [], conflicts: [] }, + rsyncFileCount: 0, + rsyncMode: 'skipped' as const, + } + + if (flags.json) { + this.log( + JSON.stringify( + { + success: true, + dryRun: true, + worktree: { path: targetPath, branch: info.branch, commit: info.commit }, + plan, + wouldWrite: { kind, sourceBranch, owner: flags.owner ?? null, ttl: flags.ttl ?? null }, + warnings: [...setupResult.warnings, ...warnings], + }, + null, + 2 + ) + ) + return + } + + if (!chalk) { + ErrorHelper.unexpected(this, new Error('Chalk not initialized for human-readable output')) + } + const out: string[] = [] + out.push(chalk.cyan(`Dry run — would adopt ${targetPath}`)) + if (info.branch) out.push(chalk.gray(` Branch: ${info.branch}`)) + out.push('') + out.push(chalk.bold('Symlinks:')) + out.push(chalk.green(` create: ${plan.symlinks.toCreate.length}`)) + out.push(chalk.gray(` already linked: ${plan.symlinks.alreadyLinked.length}`)) + if (plan.symlinks.conflicts.length > 0) { + const verb = flags['replace-existing'] ? 'replace' : 'skip (real file present)' + out.push(chalk.yellow(` ${verb}: ${plan.symlinks.conflicts.length}`)) + plan.symlinks.conflicts.forEach((item) => out.push(chalk.yellow(` • ${item}`))) + } + out.push('') + out.push(chalk.bold('Rsync:')) + out.push(chalk.gray(` mode: ${plan.rsyncMode}, files: ${plan.rsyncFileCount}`)) + out.push('') + out.push(chalk.bold('Metadata that would be written:')) + out.push(chalk.gray(` kind: ${kind}, sourceBranch: ${sourceBranch}`)) + if (flags.owner) out.push(chalk.gray(` owner: ${flags.owner as string}`)) + if (flags.ttl) out.push(chalk.gray(` ttl: ${flags.ttl as string}`)) + for (const w of [...setupResult.warnings, ...warnings]) { + out.push(chalk.yellow(`⚠ ${w}`)) + } + out.push('') + out.push(chalk.cyan('No changes made. Re-run without --dry-run to apply.')) + this.log(out.join('\n')) + } + + private formatOutput(args: { + flags: Record + targetPath: string + info: WorktreeInfo + setupResult: SetupResult + lifecycle: AddLifecycleResult + postCommandResults: PostCommandResult[] + alreadyManaged: boolean + preexistingDirtyPaths: string[] + duration: number + chalk: Awaited | null + warnings: string[] + }): void { + const { + flags, + targetPath, + info, + setupResult, + lifecycle, + postCommandResults, + alreadyManaged, + preexistingDirtyPaths, + duration, + chalk, + warnings, + } = args + const allWarnings = [ + ...setupResult.warnings, + ...warnings, + ...(lifecycle.notice ? [lifecycle.notice] : []), + ...lifecycle.warnings, + ] + + if (flags.json) { + this.log( + JSON.stringify( + { + success: true, + adopted: true, + alreadyManaged, + worktree: { + path: targetPath, + branch: info.branch, + commit: info.commit, + kind: lifecycle.kind, + ...(lifecycle.owner ? { owner: lifecycle.owner } : {}), + ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}), + ...(lifecycle.effectiveTtl ? { effectiveTtl: lifecycle.effectiveTtl } : {}), + ...(lifecycle.ports ? { ports: lifecycle.ports } : {}), + ...(lifecycle.dbName ? { dbName: lifecycle.dbName } : {}), + locked: lifecycle.locked, + }, + setup: { + rsync: setupResult.rsyncResult + ? { + filesTransferred: setupResult.rsyncResult.filesTransferred, + totalSize: setupResult.rsyncResult.totalSize, + } + : null, + symlink: setupResult.symlinkResult + ? { + created: setupResult.symlinkResult.created, + skipped: setupResult.symlinkResult.skipped, + conflictCount: setupResult.symlinkResult.conflicts.length, + conflicts: setupResult.symlinkResult.conflicts, + } + : null, + alreadyLinked: setupResult.plan?.symlinks.alreadyLinked ?? [], + cleanTree: setupResult.cleanTree ?? null, + }, + preexistingDirty: preexistingDirtyPaths, + postCommands: postCommandResults, + duration, + warnings: allWarnings, + }, + null, + 2 + ) + ) + return + } + + if (!chalk) { + ErrorHelper.unexpected(this, new Error('Chalk not initialized for human-readable output')) + } + const out: string[] = [] + out.push(chalk.green(`✓ Adopted ${targetPath}`)) + if (info.branch) out.push(chalk.gray(` Branch: ${info.branch}`)) + out.push(chalk.gray(` Commit: ${info.commit.substring(0, 7)}`)) + const status = [ + ...(lifecycle.locked ? ['locked'] : []), + ...(lifecycle.owner ? [`owner ${lifecycle.owner}`] : []), + ...(lifecycle.ttl ? [`ttl ${lifecycle.ttl}`] : []), + ] + out.push( + chalk.gray(` Kind: ${lifecycle.kind}${status.length > 0 ? ` (${status.join(', ')})` : ''}`) + ) + const resources = [ + ...Object.entries(lifecycle.ports ?? {}).map(([name, port]) => `${name}=${port}`), + ...(lifecycle.dbName ? [`db=${lifecycle.dbName}`] : []), + ] + if (resources.length > 0) out.push(chalk.gray(` Resources: ${resources.join(', ')}`)) + out.push('') + + if (setupResult.rsyncResult) { + const { filesTransferred, totalSize } = setupResult.rsyncResult + const mb = (totalSize / (1024 * 1024)).toFixed(2) + out.push(chalk.green(`✓ Files synced: ${filesTransferred.toLocaleString()} files (${mb} MB)`)) + } + if (setupResult.symlinkResult) { + const { created, conflicts } = setupResult.symlinkResult + if (created > 0) out.push(chalk.green(`✓ Symlinks created: ${created}`)) + const alreadyLinked = setupResult.plan?.symlinks.alreadyLinked.length ?? 0 + if (alreadyLinked > 0) out.push(chalk.gray(` Symlinks already in place: ${alreadyLinked}`)) + if (conflicts.length > 0) { + out.push( + chalk.yellow( + `⚠ Symlinks skipped (real file present): ${conflicts.length}` + + ` — use --replace-existing to replace` + ) + ) + conflicts.forEach((c) => out.push(chalk.yellow(` • ${c.target} (${c.reason})`))) + } + } + if (preexistingDirtyPaths.length > 0) { + out.push( + chalk.gray(` Preserved ${preexistingDirtyPaths.length} pre-existing change(s) untouched`) + ) + } + if (postCommandResults.length > 0) { + out.push('') + out.push(chalk.cyan('Post-command scripts:')) + postCommandResults.forEach((result) => { + const label = result.name ? `${result.name} (${result.command})` : result.command + out.push(chalk.green(` ✓ ${label}`)) + }) + } + if (setupResult.cleanTree === false) { + out.push(chalk.yellow('⚠ git status is not clean in the worktree')) + } + if (allWarnings.length > 0) { + out.push('') + out.push(chalk.yellow('⚠ Warnings:')) + allWarnings.forEach((w) => out.push(chalk.yellow(` - ${w}`))) + } + out.push('') + out.push(chalk.cyan(`Ready to use: cd ${targetPath}`)) + out.push(chalk.gray(`Duration: ${(duration / 1000).toFixed(2)}s`)) + this.log(out.join('\n')) + } + + private handleError( + error: unknown, + flags: Record, + spinner: Awaited> | null, + warnings: string[] + ): void { + if (isOclifExitError(error)) throw error + if (spinner) spinner.fail('Failed') + + if (error instanceof PostCommandError) { + if (flags.json) { + this.log( + JSON.stringify( + { + success: false, + error: error.message, + postCommands: error.results, + failedPostCommand: error.result, + warnings, + }, + null, + 2 + ) + ) + this.exit(1) + } + ErrorHelper.operation(this, error, `Post-command failed: ${error.result.command}`, false) + return + } + + // SetupError: adopt never removes the worktree, so make that explicit. + if (error instanceof Error && error.name === 'SetupError') { + const setupError = error as Error & { result?: { warnings?: string[] } } + const combined = [...(setupError.result?.warnings ?? []), ...warnings] + if (flags.json) { + this.log( + JSON.stringify( + { + success: false, + error: setupError.message, + worktreePreserved: true, + warnings: combined, + }, + null, + 2 + ) + ) + this.exit(1) + } + ErrorHelper.operation( + this, + setupError, + 'Adopt failed; the worktree and your changes were left untouched', + false + ) + return + } + + if (flags.json) { + this.log( + JSON.stringify( + { + success: false, + error: error instanceof Error ? error.message : String(error), + warnings, + }, + null, + 2 + ) + ) + this.exit(1) + } + ErrorHelper.operation( + this, + error instanceof Error ? error : new Error(String(error)), + 'Adopt failed', + false + ) + } +} diff --git a/src/utils/DESIGN.md b/src/utils/DESIGN.md index 7faa18f..a276e2e 100644 --- a/src/utils/DESIGN.md +++ b/src/utils/DESIGN.md @@ -8,8 +8,10 @@ This module provides core utility functions for git operations, file operations - **git.ts** - Git operations wrapper using simple-git - **fileOps.ts** - Rsync and symlink operations with transaction support; strips transport/exec-class rsync flags as a security denylist -- **worktreeSetup.ts** - Post-worktree-creation orchestrator (rsync + symlink, transactional) +- **worktreeSetup.ts** - Post-worktree-creation orchestrator (rsync + symlink, transactional). Also powers `pando adopt` via **adopt mode** (`SetupOptions.adopt`): non-destructive rollback, skip-not-clobber symlink conflicts, dirt-tolerant clean-tree check, and a `dryRun` plan (`SetupPlan`) - **postCommands.ts** - Run configured post-command shell scripts and shape their results (`PostCommandResult`, `PostCommandError`) +- **postCommandRunner.ts** - Trust-gated post-command execution shared by `add` and `adopt` (`runTrustedPostCommands`): normalize → config-trust gate → run, with a fallback config key +- **setupFlags.ts** - Shared rsync/symlink/ports flag-override logic applied by both `add` and `adopt` (`applySetupFlagOverrides`) - **configTrust.ts** - direnv-style trust store gating config-file post-commands (content-hash pinning, pure decision function) - **branch-backups.ts** - Timestamp formatting and backup branch name parsing/formatting for backup/restore - **commandDetails.ts** - Build the `--details` payload for `add` (rsync totals, sampled symlink paths) diff --git a/src/utils/fileOps.ts b/src/utils/fileOps.ts index 232d3c7..c436857 100644 --- a/src/utils/fileOps.ts +++ b/src/utils/fileOps.ts @@ -166,8 +166,16 @@ export class FileOperationTransaction { /** * Rollback all operations in reverse order + * + * @param options.skipRsyncRollback - Do NOT remove the rsync destination during + * rollback. Rsync records its destination (the worktree root) as the rollback + * target, so the default RSYNC rollback `fs.remove(destination)` deletes the + * entire worktree. That is only safe when the worktree was created this run + * (e.g. `pando add`, which also removes the worktree via checkpoint). For an + * ADOPTED worktree pando did not create, this would destroy the user's work, + * so adopt mode sets this flag. */ - async rollback(): Promise { + async rollback(options: { skipRsyncRollback?: boolean } = {}): Promise { // Preserve checkpoints BEFORE clearing - critical for post-rollback use const preservedCheckpoints = new Map(this.checkpoints) const rolledBackOperations: Operation[] = [] @@ -203,6 +211,15 @@ export class FileOperationTransaction { break case OperationType.RSYNC: + // Adopt mode: never remove the rsync destination — it is the + // pre-existing worktree root, and removing it would delete the + // user's work along with the synced artifacts. + if (options.skipRsyncRollback) { + this.onWarning?.( + `Skipped rollback of ${op.type} at ${op.path}: adopt mode preserves the existing worktree (synced artifacts left in place)` + ) + break + } // For rsync, we need to remove the destination // This is tricky - we can only remove if we have metadata about what was created if (op.metadata?.destination) { diff --git a/src/utils/git.ts b/src/utils/git.ts index 3c49345..7fa65c8 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,8 +1,25 @@ -import { stat } from 'node:fs/promises' +import { stat, realpath } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' import { simpleGit, type SimpleGit } from 'simple-git' import { withGitRetry, type GitRetryOptions } from './gitRetry.js' import { readMetadata, type WorktreeMetadata } from './worktreeMetadata.js' +/** + * Canonicalize a path for worktree comparison: make it absolute (relative to + * cwd) and resolve symlinks via realpath. On macOS `/tmp` -> `/private/tmp` and + * `process.cwd()` can disagree with the absolute path git records, so a raw + * string compare would spuriously miss. Falls back to a plain resolve when the + * path does not exist on disk. + */ +async function canonicalizePath(target: string): Promise { + const absolute = isAbsolute(target) ? target : resolve(process.cwd(), target) + try { + return await realpath(absolute) + } catch { + return resolve(absolute) + } +} + /** * Git utility wrapper for worktree and branch operations * @@ -236,6 +253,34 @@ export class GitHelper { return worktrees } + /** + * Find the linked worktree whose path matches `targetPath`. + * + * Paths are compared after canonicalization (realpath) so a symlinked target + * or a cwd-relative path still matches the absolute path git records. `isMain` + * is true for the main worktree, which git's porcelain output always lists + * first. + * + * @param targetPath - Absolute or cwd-relative path to look up + * @returns The matching worktree plus whether it is the main worktree, or null + * when no linked worktree matches + */ + async getWorktreeByPath( + targetPath: string + ): Promise<{ info: WorktreeInfo; isMain: boolean } | null> { + const worktrees = await this.listWorktrees() + const resolvedTarget = await canonicalizePath(targetPath) + + for (const [index, info] of worktrees.entries()) { + const resolvedInfo = await canonicalizePath(info.path) + if (resolvedInfo === resolvedTarget) { + return { info, isMain: index === 0 } + } + } + + return null + } + /** * Check if a worktree has uncommitted changes */ diff --git a/src/utils/postCommandRunner.ts b/src/utils/postCommandRunner.ts new file mode 100644 index 0000000..624ef7a --- /dev/null +++ b/src/utils/postCommandRunner.ts @@ -0,0 +1,214 @@ +import type { Command } from '@oclif/core' +import type { LoadedPandoConfig } from '../config/loader.js' +import { ErrorHelper } from './errors.js' +import { + computeConfigHash, + decidePostCommandTrust, + isConfigTrusted, + isEnvTrustEnabled, + recordTrust, +} from './configTrust.js' +import { + normalizePostCommandScripts, + runPostCommandScripts, + type PostCommandContext, + type PostCommandResult, + type PostCommandScriptConfig, +} from './postCommands.js' + +type Spinner = Awaited> + +export interface RunTrustedPostCommandsParams { + command: Command + config: LoadedPandoConfig + /** The running command, e.g. 'add' | 'adopt'. Used in messaging + PANDO_COMMAND. */ + commandName: string + /** Config key to read scripts from (usually === commandName). */ + scriptKey: string + /** Fallback config key when `scriptKey` has no scripts (adopt falls back to 'add'). */ + fallbackScriptKey?: string + context: Omit + isJson: boolean + spinner: Spinner | null + warnings: string[] +} + +function emitWarning(command: Command, message: string, isJson: boolean, warnings: string[]): void { + if (isJson) { + warnings.push(message) + } else { + ErrorHelper.warn(command, message, false) + } +} + +/** + * Run a command's configured post-command scripts, gated by the direnv-style + * config trust check. Shared by `pando add` and `pando adopt` so the trust + * semantics stay identical. + * + * @returns The results of each executed script (empty if none ran / not trusted) + */ +export async function runTrustedPostCommands( + params: RunTrustedPostCommandsParams +): Promise { + const { command, config, commandName, scriptKey, fallbackScriptKey, context, isJson, spinner } = + params + const { warnings } = params + + let scripts = normalizePostCommandScripts(config, scriptKey) + if (scripts.length === 0 && fallbackScriptKey) { + scripts = normalizePostCommandScripts(config, fallbackScriptKey) + } + + if (scripts.length === 0) { + return [] + } + + // ============================================================ + // Trust gate (direnv-style): post-commands run with shell: true, + // so a config file from a freshly-cloned repo must be explicitly + // trusted before its scripts execute. See src/utils/configTrust.ts. + // ============================================================ + const allowed = await evaluatePostCommandTrust( + command, + config.postCommandsSourcePath, + scripts, + commandName, + isJson, + spinner, + warnings + ) + if (!allowed) { + return [] + } + + if (spinner) { + spinner.text = `Running ${scripts.length} post-command script${scripts.length === 1 ? '' : 's'}...` + } + + return runPostCommandScripts(scripts, { commandName, ...context }) +} + +/** + * Decide whether post-commands from a config file are allowed to run, and + * persist trust when the user approves interactively. + * + * @returns True if the post-commands should run; false to skip them + */ +async function evaluatePostCommandTrust( + command: Command, + sourcePath: string | undefined, + scripts: PostCommandScriptConfig[], + commandName: string, + isJson: boolean, + spinner: Spinner | null, + warnings: string[] +): Promise { + const envTrust = isEnvTrustEnabled(process.env.PANDO_TRUST_CONFIG) + + // Only hash/check trust when there is an actual file on disk to vet. + let currentHash: string | undefined + let trustedWithMatchingHash = false + if (sourcePath && !envTrust) { + try { + currentHash = await computeConfigHash(sourcePath) + trustedWithMatchingHash = await isConfigTrusted(sourcePath, currentHash) + } catch { + // If we cannot read/hash the file, treat it as untrusted. + trustedWithMatchingHash = false + } + } + + const isTty = Boolean(process.stdin.isTTY) + + const decision = decidePostCommandTrust({ + hasScripts: scripts.length > 0, + sourcePath, + envTrust, + trustedWithMatchingHash, + isTty, + isJson, + }) + + if (decision === 'run') { + return true + } + + if (decision === 'skip') { + emitWarning( + command, + `Skipping ${scripts.length} post-command script(s) from untrusted config file` + + (sourcePath ? ` '${sourcePath}'` : '') + + '.\n' + + `To allow them: run \`pando ${commandName}\` interactively once to trust this file, ` + + 'or set PANDO_TRUST_CONFIG=1.', + isJson, + warnings + ) + return false + } + + // decision === 'prompt' (interactive TTY, not JSON) + // Pause the spinner so the inquirer prompt renders cleanly. By this point + // the spinner has typically already succeeded (setup completed), so it is + // usually NOT spinning — but it may still be active if a caller invokes the + // trust gate mid-setup. The wasSpinning guard handles both cases: we only + // stop a spinner that is actually running, and only restart it afterward if + // we stopped it (see the matching `if (spinner && wasSpinning)` below). + const wasSpinning = Boolean(spinner?.isSpinning) + if (spinner && wasSpinning) { + spinner.stop() + } + + if (!isJson) { + command.log('') + command.log(`A config file requests running post-command scripts on 'pando ${commandName}':`) + if (sourcePath) { + command.log(` File: ${sourcePath}`) + } + for (const script of scripts) { + const label = script.name ? `${script.name}: ${script.command}` : script.command + command.log(` • ${label}`) + } + command.log('') + } + + const { confirm } = await import('@inquirer/prompts') + const approved = await confirm({ + message: 'Trust this config file and run its post-commands?', + default: false, + }) + + if (!approved) { + emitWarning( + command, + `Skipped ${scripts.length} post-command script(s); config file not trusted.`, + isJson, + warnings + ) + return false + } + + // Persist trust at the current content hash, then run. + if (sourcePath) { + try { + const hash = currentHash ?? (await computeConfigHash(sourcePath)) + await recordTrust(sourcePath, hash) + } catch { + // Non-fatal: failing to persist trust just means we'll prompt again + // next time. Still allow this run since the user approved it. + emitWarning( + command, + 'Could not persist trust decision; will prompt again next time.', + isJson, + warnings + ) + } + } + + if (spinner && wasSpinning) { + spinner.start() + } + + return true +} diff --git a/src/utils/setupFlags.ts b/src/utils/setupFlags.ts new file mode 100644 index 0000000..2455db4 --- /dev/null +++ b/src/utils/setupFlags.ts @@ -0,0 +1,47 @@ +import type { LoadedPandoConfig } from '../config/loader.js' + +export type SetupFlagWarn = (message: string) => void + +/** + * Apply the shared rsync / symlink / ports flag overrides onto a loaded config. + * Used by both `pando add` and `pando adopt` so the two commands interpret the + * same flags identically. Mutates `config` in place. + * + * @param config - Loaded, merged config to mutate + * @param flags - Parsed command flags (erased to Record for cross-command reuse) + * @param warn - Sink for non-fatal flag-coordination warnings + */ +export function applySetupFlagOverrides( + config: LoadedPandoConfig, + flags: Record, + warn: SetupFlagWarn +): void { + if (flags['skip-rsync']) { + config.rsync.enabled = false + // Warn if rsync-specific flags were provided alongside --skip-rsync + if (flags['rsync-flags'] || flags['rsync-exclude']) { + warn('--rsync-flags and --rsync-exclude are ignored when --skip-rsync is set') + } + } + if (flags['rsync-flags']) { + const rsyncFlags = flags['rsync-flags'] as string[] + config.rsync.flags = rsyncFlags.flatMap((f) => f.split(',')) + } + if (flags['rsync-exclude']) { + const rsyncExclude = flags['rsync-exclude'] as string[] + config.rsync.exclude = [...config.rsync.exclude, ...rsyncExclude.flatMap((e) => e.split(','))] + } + if (flags['skip-symlink']) { + config.symlink.patterns = [] + } + if (flags.symlink) { + const symlinkPatterns = flags.symlink as string[] + config.symlink.patterns = symlinkPatterns.flatMap((s) => s.split(',')) + } + if (flags['absolute-symlinks']) { + config.symlink.relative = false + } + if (flags.ports) { + config.ports.enabled = true + } +} diff --git a/src/utils/worktreeSetup.ts b/src/utils/worktreeSetup.ts index dc92484..8a90096 100644 --- a/src/utils/worktreeSetup.ts +++ b/src/utils/worktreeSetup.ts @@ -51,6 +51,60 @@ export interface SetupOptions { * Progress callback for long operations */ onProgress?: (phase: SetupPhase, message: string) => void + + /** + * Adopt mode: set up a worktree pando did NOT create (takeover). Changes three + * behaviors versus the create-time path: + * 1. No 'worktree' checkpoint is created, so a setup failure rolls back only + * the file operations pando performed - it never removes the worktree. + * 2. Symlink targets that hold a real file/dir are skipped (not clobbered); + * see `replaceExistingSymlinks` to opt back into replacement. + * 3. The clean-tree check excludes `preexistingDirtyPaths` so pre-existing + * work-in-progress does not read as setup pollution. + */ + adopt?: boolean + + /** + * Adopt mode only: replace a real file/dir sitting at a symlink target instead + * of skipping it (restores the create-time remove-then-link behavior). + */ + replaceExistingSymlinks?: boolean + + /** + * Compute and return the setup plan without mutating anything. Meaningful for + * adopt previews (`pando adopt --dry-run`). + */ + dryRun?: boolean + + /** + * Adopt mode only: paths that were already dirty before setup ran. Excluded + * from the post-setup clean-tree check so the user's own changes are not + * reported as pollution. + */ + preexistingDirtyPaths?: string[] +} + +/** + * Classification of planned symlink targets against the current worktree state. + */ +export interface SymlinkClassification { + /** Target path is empty - a symlink will be created */ + toCreate: string[] + /** Target is already the correct symlink - nothing to do (idempotent) */ + alreadyLinked: string[] + /** Target holds a real file/dir/other symlink - skipped unless replacing */ + conflicts: string[] +} + +/** + * A preview of what setup would do, returned on `dryRun` and attached to adopt + * runs for reporting. + */ +export interface SetupPlan { + symlinks: SymlinkClassification + /** Number of untracked/ignored files rsync would (or did) copy */ + rsyncFileCount: number + rsyncMode: 'untracked' | 'full' | 'skipped' } /** @@ -85,6 +139,12 @@ export interface SetupResult { duration: number warnings: string[] rolledBack: boolean + /** + * Setup plan. Populated on `dryRun` (the whole result) and on adopt runs (for + * reporting what was created / already-linked / skipped). Undefined for + * create-time runs. + */ + plan?: SetupPlan } // ============================================================================ @@ -114,6 +174,14 @@ export class WorktreeSetupOrchestrator { */ private hasRolledBack = false + /** + * True while setting up an adopted worktree. Makes rollback non-destructive: + * it never removes the worktree (no 'worktree' checkpoint is created) and + * never removes the rsync destination (which is the pre-existing worktree + * root). Set per-run at the start of setupNewWorktree. + */ + private adoptMode = false + constructor( private gitHelper: GitHelper, private config: PandoConfig @@ -133,6 +201,9 @@ export class WorktreeSetupOrchestrator { */ async setupNewWorktree(worktreePath: string, options: SetupOptions = {}): Promise { const startTime = Date.now() + // Record adopt mode for rollback() (which is also called from the SIGINT + // handler, without access to these options). + this.adoptMode = Boolean(options.adopt) const warnings: string[] = [] let rsyncResult: RsyncResult | undefined let symlinkResult: SymlinkResult | undefined @@ -161,6 +232,20 @@ export class WorktreeSetupOrchestrator { ], } + // Adopt mode: harden rsync so it can never overwrite the user's files. + // (1) Force onlyUntracked — a config with onlyUntracked=false would + // full-mirror and clobber tracked/modified target files when the + // commits happen to match. + // (2) Add --ignore-existing — a path that is gitignored in the source but + // already exists in the adopted worktree (e.g. a hand-made .env) must + // not be replaced by the source's copy. + if (options.adopt) { + rsyncConfig.onlyUntracked = true + if (!rsyncConfig.flags.includes('--ignore-existing')) { + rsyncConfig.flags = [...rsyncConfig.flags, '--ignore-existing'] + } + } + // Get source tree path (main worktree) const sourceTreePath = await this.gitHelper.getMainWorktreePath() @@ -184,7 +269,14 @@ export class WorktreeSetupOrchestrator { // below) - otherwise a failure there leaves rollback() with no // 'worktree' checkpoint, and the already-created git worktree is never // cleaned up. - this.transaction.createCheckpoint('worktree', { path: worktreePath }) + // + // Adopt mode deliberately skips this: pando did not create the worktree, + // so a setup failure must roll back only pando's own file operations and + // NEVER `git worktree remove` a tree that predates pando's involvement. + // rollback() removes the worktree only when this checkpoint is present. + if (!options.adopt) { + this.transaction.createCheckpoint('worktree', { path: worktreePath }) + } // Plan symlinks once: matched items minus (in strict mode) git-tracked // paths. The symlink phases and rsync exclusions all consume this plan so @@ -217,6 +309,37 @@ export class WorktreeSetupOrchestrator { } } + // Adopt/dry-run: classify each planned symlink target against the current + // worktree (create / already-linked / conflict). Consumed by the dry-run + // plan, the preserve-existing symlink phase, and adopt-run reporting. + const preserveExisting = Boolean(options.adopt) && !options.replaceExistingSymlinks + const classification = + options.adopt || options.dryRun + ? await this.classifyAdoptSymlinks(sourceTreePath, worktreePath, symlinkItems) + : undefined + + // ============================================================ + // Dry run: report the plan, mutate nothing + // ============================================================ + if (options.dryRun) { + const rsync = await this.planRsync(sourceTreePath, worktreePath, rsyncConfig, options) + this.reportProgress(options.onProgress, SetupPhase.COMPLETE, 'Dry run complete') + return { + success: true, + plan: { + symlinks: classification ?? { toCreate: [], alreadyLinked: [], conflicts: [] }, + rsyncFileCount: rsync.count, + rsyncMode: rsync.mode, + }, + duration: Date.now() - startTime, + warnings, + rolledBack: false, + } + } + + // Track rsync outcome for the adopt-run plan. + let rsyncMode: SetupPlan['rsyncMode'] = 'skipped' + // ============================================================ // Phase 3: Symlinks (Before Rsync) // ============================================================ @@ -231,7 +354,8 @@ export class WorktreeSetupOrchestrator { sourceTreePath, worktreePath, symlinkConfig, - symlinkItems + symlinkItems, + { preserveExisting, classification } ) // Add warnings for skipped conflicts @@ -286,6 +410,7 @@ export class WorktreeSetupOrchestrator { } if (runRsync) { + rsyncMode = onlyUntracked ? 'untracked' : 'full' // Check rsync is installed const { RsyncNotInstalledError } = await import('./fileOps.js') if (!(await this.rsyncHelper.isInstalled())) { @@ -352,7 +477,8 @@ export class WorktreeSetupOrchestrator { sourceTreePath, worktreePath, symlinkConfig, - symlinkItems + symlinkItems, + { preserveExisting, classification } ) // Add warnings for any conflicts (shouldn't happen since rsync excluded them) @@ -435,8 +561,11 @@ export class WorktreeSetupOrchestrator { let cleanTree: boolean | undefined try { const symlinkItemSet = new Set(symlinkItems) + // Adopt mode: the user's pre-existing work-in-progress is not setup + // pollution, so exclude it from the clean-tree verdict. + const preexistingSet = new Set(options.preexistingDirtyPaths ?? []) const dirtyPaths = (await this.gitHelper.getDirtyPaths(worktreePath)).filter( - (dirtyPath) => !symlinkItemSet.has(dirtyPath) + (dirtyPath) => !symlinkItemSet.has(dirtyPath) && !preexistingSet.has(dirtyPath) ) cleanTree = dirtyPaths.length === 0 if (!cleanTree) { @@ -469,6 +598,17 @@ export class WorktreeSetupOrchestrator { duration, warnings, rolledBack: false, + // Attach the plan on adopt runs so the command can report already-linked + // vs. created vs. skipped without re-deriving it. + ...(classification + ? { + plan: { + symlinks: classification, + rsyncFileCount: rsyncResult?.filesTransferred ?? 0, + rsyncMode, + } satisfies SetupPlan, + } + : {}), } } catch (error) { // ============================================================ @@ -529,8 +669,12 @@ export class WorktreeSetupOrchestrator { this.reportProgress(onProgress, SetupPhase.ROLLBACK, 'Rolling back file operations') // 1. Rollback file operations (symlinks, copied files) - // rollback() returns preserved checkpoints since it clears internal state - const rollbackResult = await this.transaction.rollback() + // rollback() returns preserved checkpoints since it clears internal state. + // In adopt mode, skip removing the rsync destination — it is the + // pre-existing worktree root, not something pando created. + const rollbackResult = await this.transaction.rollback({ + skipRsyncRollback: this.adoptMode, + }) // 2. Remove the worktree via git using preserved checkpoint const worktreeCheckpoint = rollbackResult.checkpoints.get('worktree') @@ -586,27 +730,123 @@ export class WorktreeSetupOrchestrator { sourceTreePath: string, worktreePath: string, symlinkConfig: SymlinkConfig, - symlinkItems: string[] + symlinkItems: string[], + options: { preserveExisting?: boolean; classification?: SymlinkClassification } = {} ): Promise { const fs = (await import('fs-extra')).default const path = await import('path') - // Remove git-checked-out files that will be symlinked - // Git automatically checks out tracked files when creating worktrees - for (const item of symlinkItems) { - const targetPath = path.default.join(worktreePath, item) - if (await fs.pathExists(targetPath)) { - await fs.remove(targetPath) + if (!options.preserveExisting) { + // Create-time (and adopt --replace-existing): remove any checked-out copy + // git created, then symlink in its place. Git automatically checks out + // tracked files when creating worktrees. Use lstat so a dangling symlink + // (which pathExists misses) is also cleared, letting --replace-existing + // replace it instead of failing with EEXIST. + for (const item of symlinkItems) { + const targetPath = path.default.join(worktreePath, item) + // Only the "no entry" case is caught here; a real removal failure + // (EACCES/EPERM/IO) must propagate so setup fails and rolls back rather + // than silently leaving the target and reporting a bogus conflict. + let targetExists = true + try { + await fs.lstat(targetPath) + } catch { + targetExists = false + } + if (targetExists) { + await fs.remove(targetPath) + } } + + return this.symlinkHelper.createSymlinks(sourceTreePath, worktreePath, symlinkConfig, { + replaceExisting: true, + skipConflicts: true, + items: symlinkItems, + }) } + // Adopt (preserve): never delete a real file. Drop targets that are already + // the correct symlink (idempotent no-op) and leave the rest to + // createSymlinks, which skips real-file conflicts (surfaced as warnings + // upstream) rather than clobbering them. + const classification = + options.classification ?? + (await this.classifyAdoptSymlinks(sourceTreePath, worktreePath, symlinkItems)) + const alreadyLinked = new Set(classification.alreadyLinked) + const actionable = symlinkItems.filter((item) => !alreadyLinked.has(item)) + return this.symlinkHelper.createSymlinks(sourceTreePath, worktreePath, symlinkConfig, { - replaceExisting: true, + replaceExisting: false, skipConflicts: true, - items: symlinkItems, + items: actionable, }) } + /** + * Classify each planned symlink target against the current worktree: empty + * (toCreate), already the correct symlink (alreadyLinked), or a real + * file/dir/other symlink (conflict). Read-only; used by adopt/dry-run. + */ + private async classifyAdoptSymlinks( + sourceTreePath: string, + worktreePath: string, + symlinkItems: string[] + ): Promise { + const fs = (await import('fs-extra')).default + const path = await import('path') + const result: SymlinkClassification = { toCreate: [], alreadyLinked: [], conflicts: [] } + + for (const item of symlinkItems) { + const target = path.default.join(worktreePath, item) + const source = path.default.join(sourceTreePath, item) + // lstat (not pathExists): pathExists follows symlinks and returns false for + // a dangling link, which would misclassify it as `toCreate` even though a + // link entry is present. lstat sees the entry itself. + let exists = true + try { + await fs.lstat(target) + } catch { + exists = false + } + if (!exists) { + result.toCreate.push(item) + } else if (await this.symlinkHelper.verifySymlink(target, source)) { + result.alreadyLinked.push(item) + } else { + result.conflicts.push(item) + } + } + + return result + } + + /** + * Compute the rsync file count and mode without running rsync (for dry-run). + * Mirrors the mode decision in Phase 4. + */ + private async planRsync( + sourceTreePath: string, + worktreePath: string, + rsyncConfig: RsyncConfig, + options: SetupOptions + ): Promise<{ count: number; mode: SetupPlan['rsyncMode'] }> { + if (options.skipRsync || !rsyncConfig.enabled) { + return { count: 0, mode: 'skipped' } + } + + const onlyUntracked = rsyncConfig.onlyUntracked ?? true + if (onlyUntracked) { + const files = await this.gitHelper.listIgnoredFiles(sourceTreePath) + return { count: files.length, mode: files.length > 0 ? 'untracked' : 'skipped' } + } + + const sourceCommit = await this.gitHelper.getWorktreeCommit(sourceTreePath) + const targetCommit = await this.gitHelper.getWorktreeCommit(worktreePath) + return sourceCommit === targetCommit + ? { count: 0, mode: 'full' } + : { count: 0, mode: 'skipped' } + } + /** * Report progress to callback */ diff --git a/test/commands/add.test.ts b/test/commands/add.test.ts index e4a5f3a..358174d 100644 --- a/test/commands/add.test.ts +++ b/test/commands/add.test.ts @@ -317,6 +317,38 @@ describe('add: lifecycle metadata setup', () => { expect(lockWorktree).toHaveBeenCalledWith('/repo/wt', 'pando: active session agent-7') }) + it('honors kindOverride, ignoring flags/config/inference (used by adopt)', async () => { + const deps = dependencies() + const result = await setupLifecycleMetadata( + { + flags: {}, + worktreeConfig, + portsConfig, + gitHelper: { + inferOwner: vi.fn().mockReturnValue(''), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree: vi.fn(), + }, + gitRoot: '/repo', + mainRepoPath: '/repo', + resolvedPath: '/repo/wt', + sourceBranch: 'main', + worktreeBranch: 'feature', + // An agent session would normally infer 'ephemeral'... + env: { CLAUDE_SESSION_ID: 'abc' }, + // ...but the override wins. + kindOverride: 'long-lived', + }, + deps + ) + + expect(result.kind).toBe('long-lived') + expect(deps.writeMetadata).toHaveBeenCalledWith( + '/repo/wt', + expect.objectContaining({ kind: 'long-lived' }) + ) + }) + it('allocates ports, derives a database name, and writes both when enabled', async () => { const deps = dependencies() deps.allocate.mockResolvedValue({ web: 3100, api: 3101 }) @@ -727,10 +759,13 @@ describe('add: JSON document consistency', () => { success: false, duration: 1, } - const internals = command as unknown as { - runPostCommands: () => Promise - } - vi.spyOn(internals, 'runPostCommands').mockRejectedValue( + // Drive the failure through the real shared runner: trust the config file, + // then have the script execution throw a PostCommandError (as a non-zero + // exit would). This exercises add's handleError PostCommandError branch. + vi.mocked(normalizePostCommandScripts).mockReturnValue([{ command: 'exit 2' }]) + vi.mocked(isEnvTrustEnabled).mockReturnValue(true) + vi.mocked(decidePostCommandTrust).mockReturnValue('run') + vi.mocked(runPostCommandScripts).mockRejectedValue( new PostCommandError('Post-command script failed: exit 2', failedResult, [failedResult]) ) @@ -985,121 +1020,8 @@ describe('add: flag-consistency warnings', () => { // Trust gate decision wiring (runPostCommands) // --------------------------------------------------------------------------- -describe('add: post-command trust gate wiring', () => { - const scripts = [{ command: 'echo hi' }] - const worktreeInfo = { path: '/wt', branch: 'feature', commit: 'abc1234' } - - function callRunPostCommands( - command: AddWorktree, - config: PandoConfig, - flags: Record, - resources: { ports?: Record; dbName?: string } = {} - ): Promise { - return ( - command as unknown as { - runPostCommands: ( - f: Record, - c: PandoConfig, - w: typeof worktreeInfo, - p: string, - s: null, - k: 'ephemeral' | 'long-lived', - t?: string, - ports?: Record, - dbName?: string - ) => Promise - } - ).runPostCommands( - flags, - config, - worktreeInfo, - '/wt', - null, - 'ephemeral', - '4h', - resources.ports, - resources.dbName - ) - } - - it('runs post-commands when the trust decision is "run"', async () => { - const { command } = createCommand() - vi.mocked(normalizePostCommandScripts).mockReturnValue(scripts) - vi.mocked(isEnvTrustEnabled).mockReturnValue(true) - vi.mocked(decidePostCommandTrust).mockReturnValue('run') - vi.mocked(runPostCommandScripts).mockResolvedValue([ - { - name: null, - command: 'echo hi', - cwd: '/wt', - exitCode: 0, - signal: null, - stdout: 'hi\n', - stderr: '', - success: true, - duration: 1, - }, - ]) - - const config = baseConfig({ - postCommandsSourcePath: '/repo/.pando.toml', - } as Partial) - const result = await callRunPostCommands( - command, - config, - { json: false }, - { ports: { web: 3100 }, dbName: 'dev_feature' } - ) - - expect(decidePostCommandTrust).toHaveBeenCalledTimes(1) - expect(runPostCommandScripts).toHaveBeenCalledTimes(1) - expect(runPostCommandScripts).toHaveBeenCalledWith(scripts, { - commandName: 'add', - cwd: '/wt', - worktreePath: '/wt', - branch: 'feature', - commit: 'abc1234', - kind: 'ephemeral', - ttl: '4h', - ports: { web: 3100 }, - dbName: 'dev_feature', - }) - expect(result).toHaveLength(1) - }) - - it('skips post-commands (without running them) when the trust decision is "skip"', async () => { - const { command, warnSpy } = createCommand() - vi.mocked(normalizePostCommandScripts).mockReturnValue(scripts) - vi.mocked(isEnvTrustEnabled).mockReturnValue(false) - vi.mocked(computeConfigHash).mockResolvedValue('deadbeef') - vi.mocked(isConfigTrusted).mockResolvedValue(false) - vi.mocked(decidePostCommandTrust).mockReturnValue('skip') - - const config = baseConfig({ - postCommandsSourcePath: '/repo/.pando.toml', - } as Partial) - // Non-JSON mode → skip decision warns via command.warn and returns []. - const result = await callRunPostCommands(command, config, { json: false }) - - expect(decidePostCommandTrust).toHaveBeenCalledTimes(1) - expect(runPostCommandScripts).not.toHaveBeenCalled() - expect(result).toEqual([]) - // A warning explains how to trust the file. - expect(warnSpy).toHaveBeenCalled() - expect(warnSpy.mock.calls[0]?.[0]).toContain('untrusted config file') - }) - - it('short-circuits without consulting the trust gate when there are no scripts', async () => { - const { command } = createCommand() - vi.mocked(normalizePostCommandScripts).mockReturnValue([]) - - const result = await callRunPostCommands(command, baseConfig(), { json: false }) - - expect(decidePostCommandTrust).not.toHaveBeenCalled() - expect(runPostCommandScripts).not.toHaveBeenCalled() - expect(result).toEqual([]) - }) -}) +// Post-command trust-gate wiring is exercised in test/utils/postCommandRunner.test.ts +// (the logic moved to src/utils/postCommandRunner.ts, shared by add + adopt). // --------------------------------------------------------------------------- // SIGINT interrupt handler (preserved from security work — DO NOT DELETE) diff --git a/test/commands/adopt.test.ts b/test/commands/adopt.test.ts new file mode 100644 index 0000000..4901475 --- /dev/null +++ b/test/commands/adopt.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AdoptWorktree, { resolveAdoptKind } from '../../src/commands/adopt' +import type { PandoConfig } from '../../src/config/schema' +import type { SetupResult } from '../../src/utils/worktreeSetup' + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const mockGitHelper = { + setRetryConfig: vi.fn(), + isRepository: vi.fn(), + getRepositoryRoot: vi.fn(), + getWorktreeByPath: vi.fn(), + getDirtyPaths: vi.fn(), + getMainWorktreePath: vi.fn(), + getMainBranch: vi.fn(), + inferOwner: vi.fn(), + lockWorktree: vi.fn(), +} + +vi.mock('../../src/utils/git.js', () => ({ + GitHelper: vi.fn(() => mockGitHelper), + createGitHelper: vi.fn(() => mockGitHelper), +})) + +vi.mock('../../src/config/loader.js', () => ({ + loadConfig: vi.fn(), +})) + +vi.mock('../../src/utils/worktreeMetadata.js', () => ({ + readMetadata: vi.fn(), + assertGitVersion: vi.fn(), + ensureWorktreeConfigEnabled: vi.fn(), + writeMetadata: vi.fn(), +})) + +vi.mock('../../src/utils/portAllocator.js', async () => { + const actual = await vi.importActual( + '../../src/utils/portAllocator.js' + ) + return { ...actual, allocate: vi.fn() } +}) + +vi.mock('../../src/utils/worktreeSetup.js', async () => { + const actual = await vi.importActual( + '../../src/utils/worktreeSetup.js' + ) + return { ...actual, createWorktreeSetupOrchestrator: vi.fn() } +}) + +vi.mock('../../src/utils/postCommandRunner.js', () => ({ + runTrustedPostCommands: vi.fn(), +})) + +import { loadConfig } from '../../src/config/loader.js' +import { + readMetadata, + assertGitVersion, + ensureWorktreeConfigEnabled, + writeMetadata, +} from '../../src/utils/worktreeMetadata.js' +import { createWorktreeSetupOrchestrator } from '../../src/utils/worktreeSetup.js' +import { runTrustedPostCommands } from '../../src/utils/postCommandRunner.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function baseConfig(overrides: Partial = {}): PandoConfig { + return { + rsync: { enabled: true, flags: ['--archive'], exclude: [] }, + symlink: { patterns: [], relative: true, beforeRsync: true }, + worktree: { + rebaseOnAdd: true, + deleteBranchOnRemove: 'local', + useProjectSubfolder: false, + targetBranch: 'main', + defaultKind: 'auto', + ephemeralTtl: '4h', + autoLockActive: true, + }, + clean: { fetch: false }, + concurrency: { retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 } }, + ports: { + enabled: false, + range: '3100-3199', + names: ['web'], + dbStrategy: 'named', + dbBaseName: 'dev', + }, + reap: {}, + postCommands: {}, + ...overrides, + } as PandoConfig +} + +function setupResult(overrides: Partial = {}): SetupResult { + return { + success: true, + rsyncResult: { + success: true, + filesTransferred: 2, + bytesSent: 0, + totalSize: 1024, + duration: 1, + } as never, + symlinkResult: { success: true, created: 1, skipped: 0, conflicts: [] }, + skipWorktreeResult: { filesMarked: 0, success: true }, + cleanTree: true, + duration: 1, + warnings: [], + rolledBack: false, + plan: { + symlinks: { toCreate: ['x'], alreadyLinked: [], conflicts: [] }, + rsyncFileCount: 2, + rsyncMode: 'untracked', + }, + ...overrides, + } +} + +function createCommand(): { + command: AdoptWorktree + logSpy: ReturnType + warnSpy: ReturnType + errorSpy: ReturnType +} { + const command = new AdoptWorktree([], { runHook: vi.fn() } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + const warnSpy = vi.spyOn(command, 'warn').mockImplementation(((msg: string) => msg) as never) + const errorSpy = vi.spyOn(command, 'error').mockImplementation(((msg: string | Error) => { + throw new Error(typeof msg === 'string' ? msg : msg.message) + }) as never) + vi.spyOn(command as unknown as { exit: (n?: number) => void }, 'exit').mockImplementation((( + code?: number + ) => { + throw new Error(`exit:${code ?? 0}`) + }) as never) + return { command, logSpy, warnSpy, errorSpy } +} + +function stubParse( + command: AdoptWorktree, + flags: Record, + args: Record = {} +): void { + vi.spyOn(command as unknown as { parse: () => Promise }, 'parse').mockResolvedValue({ + flags, + args, + } as never) +} + +const mockOrchestrator = { + setupNewWorktree: vi.fn(), + rollback: vi.fn(), + getTransaction: vi.fn(), +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(assertGitVersion).mockResolvedValue(undefined) + vi.mocked(ensureWorktreeConfigEnabled).mockResolvedValue({ enabled: true, migrated: [] }) + vi.mocked(writeMetadata).mockResolvedValue(undefined) + vi.mocked(readMetadata).mockResolvedValue({}) + vi.mocked(runTrustedPostCommands).mockResolvedValue([]) + mockGitHelper.isRepository.mockResolvedValue(true) + mockGitHelper.getRepositoryRoot.mockResolvedValue('/repo') + mockGitHelper.getDirtyPaths.mockResolvedValue([]) + mockGitHelper.getMainWorktreePath.mockResolvedValue('/repo') + mockGitHelper.getMainBranch.mockResolvedValue('main') + mockGitHelper.inferOwner.mockReturnValue('') + mockGitHelper.lockWorktree.mockResolvedValue(undefined) + mockOrchestrator.setupNewWorktree.mockResolvedValue(setupResult()) + mockOrchestrator.rollback.mockResolvedValue({ rolledBack: true, warnings: [] }) + vi.mocked(createWorktreeSetupOrchestrator).mockReturnValue(mockOrchestrator as never) + vi.mocked(loadConfig).mockResolvedValue(baseConfig() as never) +}) + +const linkedWorktree = { + info: { path: '/repo/feature', branch: 'feature', commit: 'abc1234', isPrunable: false }, + isMain: false, +} + +// --------------------------------------------------------------------------- +// resolveAdoptKind +// --------------------------------------------------------------------------- + +describe('resolveAdoptKind', () => { + it('defaults to long-lived', () => { + expect(resolveAdoptKind({})).toBe('long-lived') + }) + it('honors explicit --ephemeral', () => { + expect(resolveAdoptKind({ ephemeral: true })).toBe('ephemeral') + }) + it('honors explicit --long-lived', () => { + expect(resolveAdoptKind({ 'long-lived': true })).toBe('long-lived') + }) + it('registers mutually exclusive lifecycle flags', () => { + expect(AdoptWorktree.flags.ephemeral.exclusive).toContain('long-lived') + expect(AdoptWorktree.flags['long-lived'].exclusive).toContain('ephemeral') + }) +}) + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +describe('adopt: validation', () => { + it('errors when not in a git repository', async () => { + const { command, errorSpy } = createCommand() + stubParse(command, { json: false }) + mockGitHelper.isRepository.mockResolvedValue(false) + + await expect(command.run()).rejects.toThrow(/Not a git repository/) + expect(errorSpy).toHaveBeenCalled() + }) + + it('errors when the target is not a linked worktree', async () => { + const { command, errorSpy } = createCommand() + stubParse(command, { json: false }, { path: '/some/dir' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(null) + + await expect(command.run()).rejects.toThrow(/is not a linked worktree/) + expect(errorSpy).toHaveBeenCalled() + }) + + it('errors when adopting the main worktree', async () => { + const { command, errorSpy } = createCommand() + stubParse(command, { json: false }, { path: '/repo' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue({ + info: { path: '/repo', branch: 'main', commit: 'abc', isPrunable: false }, + isMain: true, + }) + + await expect(command.run()).rejects.toThrow(/Cannot adopt the main worktree/) + expect(errorSpy).toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Apply +// --------------------------------------------------------------------------- + +describe('adopt: apply', () => { + it('runs setup in adopt mode and reports success (JSON)', async () => { + const { command, logSpy } = createCommand() + stubParse(command, { json: true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + + await command.run() + + // Orchestrator invoked in adopt mode against the canonical worktree path. + const [calledPath, opts] = mockOrchestrator.setupNewWorktree.mock.calls[0] as [ + string, + Record, + ] + expect(calledPath).toBe('/repo/feature') + expect(opts).toMatchObject({ adopt: true, preexistingDirtyPaths: [] }) + + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.success).toBe(true) + expect(output.adopted).toBe(true) + expect(output.worktree).toMatchObject({ path: '/repo/feature', kind: 'long-lived' }) + expect(runTrustedPostCommands).toHaveBeenCalledTimes(1) + }) + + it('writes long-lived metadata with sourceBranch = config targetBranch', async () => { + const { command } = createCommand() + stubParse(command, { json: true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + vi.mocked(loadConfig).mockResolvedValue( + baseConfig({ worktree: { ...baseConfig().worktree, targetBranch: 'develop' } }) as never + ) + + await command.run() + + expect(writeMetadata).toHaveBeenCalledWith( + '/repo/feature', + expect.objectContaining({ kind: 'long-lived', sourceBranch: 'develop' }) + ) + }) + + it('re-adopt preserves existing kind, createdAt, and sourceBranch (no silent rewrite)', async () => { + const { command } = createCommand() + stubParse(command, { json: true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + vi.mocked(readMetadata).mockResolvedValue({ + kind: 'ephemeral', + createdAt: '2020-01-01T00:00:00.000Z', + sourceBranch: 'develop', + }) + + await command.run() + + // An ephemeral worktree must NOT be silently rewritten to long-lived, and its + // age (createdAt) and sourceBranch must be preserved. + expect(writeMetadata).toHaveBeenCalledWith( + '/repo/feature', + expect.objectContaining({ + kind: 'ephemeral', + createdAt: '2020-01-01T00:00:00.000Z', + sourceBranch: 'develop', + }) + ) + }) + + it('an explicit lifecycle flag still overrides a preserved kind on re-adopt', async () => { + const { command } = createCommand() + stubParse(command, { json: true, 'long-lived': true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + vi.mocked(readMetadata).mockResolvedValue({ + kind: 'ephemeral', + createdAt: '2020-01-01T00:00:00.000Z', + sourceBranch: 'develop', + }) + + await command.run() + + expect(writeMetadata).toHaveBeenCalledWith( + '/repo/feature', + expect.objectContaining({ kind: 'long-lived' }) + ) + }) + + it('passes pre-existing dirty paths to setup and reports them', async () => { + const { command, logSpy } = createCommand() + stubParse(command, { json: true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + mockGitHelper.getDirtyPaths.mockResolvedValue(['src/wip.ts']) + + await command.run() + + const [, opts] = mockOrchestrator.setupNewWorktree.mock.calls[0] as [ + string, + Record, + ] + expect(opts.preexistingDirtyPaths).toEqual(['src/wip.ts']) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.preexistingDirty).toEqual(['src/wip.ts']) + }) + + it('re-applies for an already-managed worktree (idempotent) and flags it', async () => { + const { command, logSpy } = createCommand() + stubParse(command, { json: true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + vi.mocked(readMetadata).mockResolvedValue({ kind: 'long-lived' }) + + await command.run() + + expect(mockOrchestrator.setupNewWorktree).toHaveBeenCalledTimes(1) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.alreadyManaged).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Dry run +// --------------------------------------------------------------------------- + +describe('adopt: dry run', () => { + it('emits the plan and skips lifecycle + post-commands', async () => { + const { command, logSpy } = createCommand() + stubParse(command, { json: true, 'dry-run': true }, { path: '/repo/feature' }) + mockGitHelper.getWorktreeByPath.mockResolvedValue(linkedWorktree) + mockOrchestrator.setupNewWorktree.mockResolvedValue( + setupResult({ + plan: { + symlinks: { toCreate: ['a'], alreadyLinked: ['b'], conflicts: ['c'] }, + rsyncFileCount: 3, + rsyncMode: 'untracked', + }, + }) + ) + + await command.run() + + const [, opts] = mockOrchestrator.setupNewWorktree.mock.calls[0] as [ + string, + Record, + ] + expect(opts.dryRun).toBe(true) + + // No side effects: metadata + post-commands are not invoked in a dry run. + expect(writeMetadata).not.toHaveBeenCalled() + expect(runTrustedPostCommands).not.toHaveBeenCalled() + + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.dryRun).toBe(true) + expect(output.plan.symlinks.toCreate).toEqual(['a']) + expect(output.plan.symlinks.conflicts).toEqual(['c']) + expect(output.wouldWrite).toMatchObject({ kind: 'long-lived', sourceBranch: 'main' }) + }) +}) diff --git a/test/e2e/commands/adopt.e2e.test.ts b/test/e2e/commands/adopt.e2e.test.ts new file mode 100644 index 0000000..727b645 --- /dev/null +++ b/test/e2e/commands/adopt.e2e.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { createE2EContainer, type E2EContainer } from '../helpers/container.js' +import { setupGitRepo } from '../helpers/git-repo.js' +import { pandoAdopt } from '../helpers/cli-runner.js' +import { expectJsonSuccess, expectJsonError } from '../helpers/assertions.js' + +/** + * E2E coverage for `pando adopt` — taking over a worktree created OUTSIDE pando + * (raw `git worktree add`). Verifies the safety invariants against real git: + * user work is never clobbered and the worktree is never removed. + */ +describe('pando adopt (E2E)', () => { + let container: E2EContainer + let repoPath: string + + beforeAll(async () => { + container = await createE2EContainer() + repoPath = await setupGitRepo(container, { + name: 'adopt-test-repo', + files: [ + { path: 'package.json', content: '{"name": "test"}' }, + { path: 'src/index.ts', content: 'export const main = () => {}' }, + { path: '.gitignore', content: 'node_modules/\n.venv/' }, + { path: 'node_modules/.bin/tool', content: 'binary' }, + { path: '.venv/cache.bin', content: 'artifact' }, + ], + }) + }) + + afterAll(async () => { + if (container) await container.stop() + }) + + /** Create a linked worktree with raw git (not pando). */ + async function rawWorktree(name: string, branch: string): Promise { + const wtPath = `${repoPath}/../worktrees/${name}` + await container.exec(['sh', '-c', `cd ${repoPath} && git worktree add -b ${branch} ${wtPath}`]) + return wtPath + } + + describe('basic adoption', () => { + it('adopts a raw worktree: writes metadata, syncs artifacts, stays clean', async () => { + const wt = await rawWorktree('basic', 'adopt-basic') + + const result = await pandoAdopt(container, wt, []) + expectJsonSuccess(result) + expect(result.json?.adopted).toBe(true) + expect((result.json?.worktree as { kind: string }).kind).toBe('long-lived') + + // pando metadata was stamped onto the worktree config. + const meta = await container.exec([ + 'sh', + '-c', + `cd ${wt} && git config --worktree --get-regexp '^pando\\.'`, + ]) + expect(meta.stdout).toContain('pando.kind long-lived') + + // Gitignored artifact was synced from the main worktree. + const artifact = await container.exec(['ls', `${wt}/node_modules/.bin/tool`]) + expect(artifact.exitCode).toBe(0) + + // git status is clean aside from pando's own additions. + const status = await container.exec(['sh', '-c', `cd ${wt} && git status --porcelain`]) + expect(status.stdout.trim()).toBe('') + }) + + it('is idempotent: a second adopt re-applies and flags alreadyManaged', async () => { + const wt = await rawWorktree('idem', 'adopt-idem') + await pandoAdopt(container, wt, []) + + const again = await pandoAdopt(container, wt, []) + expectJsonSuccess(again) + expect(again.json?.alreadyManaged).toBe(true) + }) + }) + + describe('protects user work', () => { + it('preserves uncommitted changes (tracked + untracked) when adopting a dirty worktree', async () => { + const wt = await rawWorktree('dirty', 'adopt-dirty') + await container.exec(['sh', '-c', `echo 'wip work' > ${wt}/UNTRACKED.txt`]) + await container.exec(['sh', '-c', `echo '{"name":"changed"}' > ${wt}/package.json`]) + + const result = await pandoAdopt(container, wt, []) + expectJsonSuccess(result) + expect(result.json?.preexistingDirty).toEqual( + expect.arrayContaining(['UNTRACKED.txt', 'package.json']) + ) + + // Both the untracked file and the modified tracked file survive untouched. + const untracked = await container.exec(['cat', `${wt}/UNTRACKED.txt`]) + expect(untracked.stdout.trim()).toBe('wip work') + const pkg = await container.exec(['cat', `${wt}/package.json`]) + expect(pkg.stdout).toContain('changed') + }) + + it('skips a symlink whose target holds a real file (default), preserving it', async () => { + // Configure a symlink pattern for package.json, then put a real file there. + await container.exec([ + 'sh', + '-c', + `cd ${repoPath} && printf '[symlink]\\npatterns = ["package.json"]\\n' > .pando.toml`, + ]) + const wt = await rawWorktree('conflict', 'adopt-conflict') + await container.exec(['sh', '-c', `echo 'REAL LOCAL' > ${wt}/package.json`]) + + const result = await pandoAdopt(container, wt, []) + expectJsonSuccess(result) + expect( + (result.json?.setup as { symlink: { conflictCount: number } }).symlink.conflictCount + ).toBe(1) + + // The real file is left in place (not replaced by a symlink). + const check = await container.exec([ + 'sh', + '-c', + `test -L ${wt}/package.json && echo LINK || echo FILE`, + ]) + expect(check.stdout.trim()).toBe('FILE') + }) + + it('replaces the real file with a symlink under --replace-existing', async () => { + await container.exec([ + 'sh', + '-c', + `cd ${repoPath} && printf '[symlink]\\npatterns = ["package.json"]\\n' > .pando.toml`, + ]) + const wt = await rawWorktree('replace', 'adopt-replace') + await container.exec(['sh', '-c', `echo 'REAL LOCAL' > ${wt}/package.json`]) + + const result = await pandoAdopt(container, wt, ['--replace-existing']) + expectJsonSuccess(result) + + const check = await container.exec([ + 'sh', + '-c', + `test -L ${wt}/package.json && echo LINK || echo FILE`, + ]) + expect(check.stdout.trim()).toBe('LINK') + + // Clean up the config so it does not leak into later tests. + await container.exec(['sh', '-c', `cd ${repoPath} && rm -f .pando.toml`]) + }) + }) + + describe('dry run', () => { + it('changes nothing and reports a plan', async () => { + const wt = await rawWorktree('dry', 'adopt-dry') + + const result = await pandoAdopt(container, wt, ['--dry-run']) + expectJsonSuccess(result) + expect(result.json?.dryRun).toBe(true) + expect(result.json?.plan).toBeDefined() + + // No metadata was written. + const meta = await container.exec([ + 'sh', + '-c', + `cd ${wt} && git config --worktree --get-regexp '^pando\\.' || true`, + ]) + expect(meta.stdout).not.toContain('pando.kind') + }) + }) + + describe('validation', () => { + it('refuses to adopt the main worktree', async () => { + const result = await pandoAdopt(container, repoPath, [repoPath]) + expectJsonError(result, 'main worktree') + }) + + it('refuses a path that is not a linked worktree', async () => { + await container.exec(['mkdir', '-p', '/tmp/not-a-wt']) + const result = await pandoAdopt(container, repoPath, ['/tmp/not-a-wt']) + expectJsonError(result, 'not a linked worktree') + }) + }) +}) diff --git a/test/e2e/helpers/cli-runner.ts b/test/e2e/helpers/cli-runner.ts index 8baf36e..8203190 100644 --- a/test/e2e/helpers/cli-runner.ts +++ b/test/e2e/helpers/cli-runner.ts @@ -30,6 +30,22 @@ export function pandoList(container: E2EContainer, cwd: string): Promise { + return runPando(container, { command: 'adopt', args, cwd, json: true }) +} + +export function pandoAdoptHuman( + container: E2EContainer, + cwd: string, + args: string[] = [] +): Promise { + return runPando(container, { command: 'adopt', args, cwd, json: false }) +} + export function pandoRemove( container: E2EContainer, cwd: string, diff --git a/test/utils/fileOps.test.ts b/test/utils/fileOps.test.ts index 480a849..409a8a4 100644 --- a/test/utils/fileOps.test.ts +++ b/test/utils/fileOps.test.ts @@ -186,6 +186,23 @@ describe('FileOperationTransaction', () => { // Rollback should not throw without destination metadata await expect(transaction.rollback()).resolves.not.toThrow() }) + + it('preserves the rsync destination when skipRsyncRollback is set (adopt safety)', async () => { + // Adopt mode: the rsync destination is the pre-existing worktree root, so + // removing it on rollback would delete the user's entire worktree. This is + // the critical safety guarantee for `pando adopt`. + const worktreeRoot = path.join(testDir, 'adopted-worktree') + await fs.ensureDir(worktreeRoot) + await fs.writeFile(path.join(worktreeRoot, 'user-work.txt'), 'precious') + + transaction.record(OperationType.RSYNC, '/source/path', { destination: worktreeRoot }) + + await transaction.rollback({ skipRsyncRollback: true }) + + // The worktree and the user's file must still be there. + expect(await fs.pathExists(worktreeRoot)).toBe(true) + expect(await fs.pathExists(path.join(worktreeRoot, 'user-work.txt'))).toBe(true) + }) }) describe('rollback - CREATE_DIR', () => { diff --git a/test/utils/git.test.ts b/test/utils/git.test.ts index af67f09..62a24fc 100644 --- a/test/utils/git.test.ts +++ b/test/utils/git.test.ts @@ -120,6 +120,57 @@ branch refs/heads/feature }) }) + describe('getWorktreeByPath', () => { + const porcelain = `worktree /path/to/main +HEAD abc123def456 +branch refs/heads/main + +worktree /path/to/feature +HEAD def789abc012 +branch refs/heads/feature +` + + it('returns the matching linked worktree with isMain false', async () => { + mockGit.raw = vi.fn().mockResolvedValue(porcelain) + + const result = await gitHelper.getWorktreeByPath('/path/to/feature') + + expect(result).not.toBeNull() + expect(result!.info.path).toBe('/path/to/feature') + expect(result!.info.branch).toBe('feature') + expect(result!.isMain).toBe(false) + }) + + it('flags the main worktree with isMain true', async () => { + mockGit.raw = vi.fn().mockResolvedValue(porcelain) + + const result = await gitHelper.getWorktreeByPath('/path/to/main') + + expect(result).not.toBeNull() + expect(result!.isMain).toBe(true) + }) + + it('returns null for a path that is not a linked worktree', async () => { + mockGit.raw = vi.fn().mockResolvedValue(porcelain) + + const result = await gitHelper.getWorktreeByPath('/some/other/dir') + + expect(result).toBeNull() + }) + + it('resolves a cwd-relative path (and canonicalizes via realpath)', async () => { + const cwd = process.cwd() + mockGit.raw = vi + .fn() + .mockResolvedValue(`worktree ${cwd}\nHEAD abc123def456\nbranch refs/heads/main\n`) + + const result = await gitHelper.getWorktreeByPath('.') + + expect(result).not.toBeNull() + expect(result!.isMain).toBe(true) + }) + }) + describe('worktree operations', () => { it('should add a new worktree with branch', async () => { mockGit.raw = vi diff --git a/test/utils/postCommandRunner.test.ts b/test/utils/postCommandRunner.test.ts new file mode 100644 index 0000000..baeb8dd --- /dev/null +++ b/test/utils/postCommandRunner.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Command } from '@oclif/core' +import type { LoadedPandoConfig } from '../../src/config/loader' + +vi.mock('../../src/utils/configTrust.js', () => ({ + computeConfigHash: vi.fn(), + decidePostCommandTrust: vi.fn(), + isConfigTrusted: vi.fn(), + isEnvTrustEnabled: vi.fn(), + recordTrust: vi.fn(), +})) + +vi.mock('../../src/utils/postCommands.js', async () => { + const actual = await vi.importActual( + '../../src/utils/postCommands.js' + ) + return { + ...actual, + normalizePostCommandScripts: vi.fn(), + runPostCommandScripts: vi.fn(), + } +}) + +import { runTrustedPostCommands } from '../../src/utils/postCommandRunner' +import { + computeConfigHash, + decidePostCommandTrust, + isConfigTrusted, + isEnvTrustEnabled, +} from '../../src/utils/configTrust.js' +import { normalizePostCommandScripts, runPostCommandScripts } from '../../src/utils/postCommands.js' + +const scripts = [{ command: 'echo hi' }] + +function fakeCommand(): { + command: Command + logSpy: ReturnType + warnSpy: ReturnType +} { + const logSpy = vi.fn() + const warnSpy = vi.fn() + const command = { log: logSpy, warn: warnSpy } as unknown as Command + return { command, logSpy, warnSpy } +} + +function baseContext() { + return { + cwd: '/wt', + worktreePath: '/wt', + branch: 'feature', + commit: 'abc1234', + kind: 'long-lived' as const, + ttl: undefined, + } +} + +describe('runTrustedPostCommands', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('runs scripts when the trust decision is "run"', async () => { + vi.mocked(normalizePostCommandScripts).mockReturnValue(scripts) + vi.mocked(isEnvTrustEnabled).mockReturnValue(true) + vi.mocked(decidePostCommandTrust).mockReturnValue('run') + vi.mocked(runPostCommandScripts).mockResolvedValue([ + { + name: null, + command: 'echo hi', + cwd: '/wt', + exitCode: 0, + signal: null, + stdout: 'hi\n', + stderr: '', + success: true, + duration: 1, + }, + ]) + const { command } = fakeCommand() + + const result = await runTrustedPostCommands({ + command, + config: { postCommandsSourcePath: '/repo/.pando.toml' } as LoadedPandoConfig, + commandName: 'adopt', + scriptKey: 'adopt', + context: { ...baseContext(), ports: { web: 3100 }, dbName: 'dev_feature' }, + isJson: false, + spinner: null, + warnings: [], + }) + + expect(runPostCommandScripts).toHaveBeenCalledWith(scripts, { + commandName: 'adopt', + cwd: '/wt', + worktreePath: '/wt', + branch: 'feature', + commit: 'abc1234', + kind: 'long-lived', + ttl: undefined, + ports: { web: 3100 }, + dbName: 'dev_feature', + }) + expect(result).toHaveLength(1) + }) + + it('skips (without running) when the trust decision is "skip" and warns', async () => { + vi.mocked(normalizePostCommandScripts).mockReturnValue(scripts) + vi.mocked(isEnvTrustEnabled).mockReturnValue(false) + vi.mocked(computeConfigHash).mockResolvedValue('deadbeef') + vi.mocked(isConfigTrusted).mockResolvedValue(false) + vi.mocked(decidePostCommandTrust).mockReturnValue('skip') + const { command, warnSpy } = fakeCommand() + + const result = await runTrustedPostCommands({ + command, + config: { postCommandsSourcePath: '/repo/.pando.toml' } as LoadedPandoConfig, + commandName: 'adopt', + scriptKey: 'adopt', + context: baseContext(), + isJson: false, + spinner: null, + warnings: [], + }) + + expect(runPostCommandScripts).not.toHaveBeenCalled() + expect(result).toEqual([]) + expect(warnSpy).toHaveBeenCalled() + expect(warnSpy.mock.calls[0]?.[0]).toContain('untrusted config file') + // The remediation hint references the running command, not a hard-coded 'add'. + expect(warnSpy.mock.calls[0]?.[0]).toContain('pando adopt') + }) + + it('pushes the skip warning into warnings[] in JSON mode (no command.warn)', async () => { + vi.mocked(normalizePostCommandScripts).mockReturnValue(scripts) + vi.mocked(isEnvTrustEnabled).mockReturnValue(false) + vi.mocked(computeConfigHash).mockResolvedValue('deadbeef') + vi.mocked(isConfigTrusted).mockResolvedValue(false) + vi.mocked(decidePostCommandTrust).mockReturnValue('skip') + const { command, warnSpy } = fakeCommand() + const warnings: string[] = [] + + await runTrustedPostCommands({ + command, + config: { postCommandsSourcePath: '/repo/.pando.toml' } as LoadedPandoConfig, + commandName: 'adopt', + scriptKey: 'adopt', + context: baseContext(), + isJson: true, + spinner: null, + warnings, + }) + + expect(warnSpy).not.toHaveBeenCalled() + expect(warnings.some((w) => w.includes('untrusted config file'))).toBe(true) + }) + + it('short-circuits without consulting the trust gate when there are no scripts', async () => { + vi.mocked(normalizePostCommandScripts).mockReturnValue([]) + const { command } = fakeCommand() + + const result = await runTrustedPostCommands({ + command, + config: {} as LoadedPandoConfig, + commandName: 'adopt', + scriptKey: 'adopt', + context: baseContext(), + isJson: false, + spinner: null, + warnings: [], + }) + + expect(decidePostCommandTrust).not.toHaveBeenCalled() + expect(runPostCommandScripts).not.toHaveBeenCalled() + expect(result).toEqual([]) + }) + + it('falls back to fallbackScriptKey when the primary key has no scripts', async () => { + // adopt has no scripts configured; 'add' does — adopt should run them. + vi.mocked(normalizePostCommandScripts).mockImplementation((_config, key) => + key === 'add' ? scripts : [] + ) + vi.mocked(isEnvTrustEnabled).mockReturnValue(true) + vi.mocked(decidePostCommandTrust).mockReturnValue('run') + vi.mocked(runPostCommandScripts).mockResolvedValue([]) + const { command } = fakeCommand() + + await runTrustedPostCommands({ + command, + config: { postCommandsSourcePath: '/repo/.pando.toml' } as LoadedPandoConfig, + commandName: 'adopt', + scriptKey: 'adopt', + fallbackScriptKey: 'add', + context: baseContext(), + isJson: false, + spinner: null, + warnings: [], + }) + + expect(normalizePostCommandScripts).toHaveBeenCalledWith(expect.anything(), 'adopt') + expect(normalizePostCommandScripts).toHaveBeenCalledWith(expect.anything(), 'add') + // Runs the fallback scripts, but still under the adopt command identity. + expect(runPostCommandScripts).toHaveBeenCalledWith( + scripts, + expect.objectContaining({ commandName: 'adopt' }) + ) + }) +}) diff --git a/test/utils/worktreeSetup.test.ts b/test/utils/worktreeSetup.test.ts index f31b60d..f0c28c6 100644 --- a/test/utils/worktreeSetup.test.ts +++ b/test/utils/worktreeSetup.test.ts @@ -28,10 +28,12 @@ vi.mock('fs-extra', async () => { ...actual, pathExists: vi.fn(), stat: vi.fn(), + lstat: vi.fn(), remove: vi.fn(), }, pathExists: vi.fn(), stat: vi.fn(), + lstat: vi.fn(), remove: vi.fn(), } }) @@ -59,6 +61,7 @@ describe('WorktreeSetupOrchestrator', () => { let mockTransaction: FileOperationTransaction let mockPathExists: ReturnType let mockStat: ReturnType + let mockLstat: ReturnType let mockRemove: ReturnType beforeEach(async () => { @@ -66,9 +69,11 @@ describe('WorktreeSetupOrchestrator', () => { const fsExtra = (await import('fs-extra')).default as any mockPathExists = fsExtra.pathExists mockStat = fsExtra.stat + mockLstat = fsExtra.lstat mockRemove = fsExtra.remove vi.mocked(mockPathExists).mockReset() vi.mocked(mockStat).mockReset() + vi.mocked(mockLstat).mockReset() vi.mocked(mockRemove).mockReset() // Create mock GitHelper @@ -146,6 +151,13 @@ describe('WorktreeSetupOrchestrator', () => { isDirectory: () => false, isFile: () => true, } as any) + // lstat resolving = "an entry exists at this path" (adopt classification + + // the create-time pre-removal loop use lstat, not pathExists). + vi.mocked(mockLstat).mockResolvedValue({ + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + } as any) vi.mocked(mockRemove).mockResolvedValue(undefined) // Mock factory functions @@ -1128,4 +1140,193 @@ describe('WorktreeSetupOrchestrator', () => { expect(transaction).toBe(mockTransaction) }) }) + + // ========================================================================== + // Adopt mode (takeover of a pre-existing worktree) + // ========================================================================== + + describe('adopt mode', () => { + it('does not create the worktree checkpoint (non-destructive rollback)', async () => { + await orchestrator.setupNewWorktree('/repo/feature', { adopt: true }) + + expect(mockTransaction.createCheckpoint).not.toHaveBeenCalledWith( + 'worktree', + expect.anything() + ) + }) + + it('on failure rolls back file ops but never removes the worktree', async () => { + mockRsyncHelper.rsync.mockRejectedValueOnce(new Error('boom')) + + await expect(orchestrator.setupNewWorktree('/repo/feature', { adopt: true })).rejects.toThrow( + SetupError + ) + + expect(mockTransaction.rollback).toHaveBeenCalled() + // Rollback must skip removing the rsync destination (the worktree root). + expect(mockTransaction.rollback).toHaveBeenCalledWith({ skipRsyncRollback: true }) + expect(mockTransaction.createCheckpoint).not.toHaveBeenCalledWith( + 'worktree', + expect.anything() + ) + expect(mockGitHelper.removeWorktree).not.toHaveBeenCalled() + }) + + it('preserves a real file at a symlink target: skips it, never removes it', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue(['package.json']) + vi.mocked(mockPathExists).mockResolvedValue(true) + // Not the correct symlink -> a real conflicting file + vi.mocked(mockSymlinkHelper.verifySymlink).mockResolvedValue(false) + vi.mocked(mockSymlinkHelper.createSymlinks).mockResolvedValue({ + success: true, + created: 0, + skipped: 1, + conflicts: [{ source: 's', target: 'package.json', reason: 'file exists at target' }], + } as SymlinkResult) + + await orchestrator.setupNewWorktree('/repo/feature', { adopt: true }) + + expect(mockRemove).not.toHaveBeenCalled() + expect(mockSymlinkHelper.createSymlinks).toHaveBeenCalledWith( + '/repo/main', + '/repo/feature', + mockConfig.symlink, + expect.objectContaining({ + replaceExisting: false, + skipConflicts: true, + items: ['package.json'], + }) + ) + }) + + it('with replaceExistingSymlinks replaces the file (add-parity: remove then link)', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue(['package.json']) + vi.mocked(mockPathExists).mockResolvedValue(true) + + await orchestrator.setupNewWorktree('/repo/feature', { + adopt: true, + replaceExistingSymlinks: true, + }) + + expect(mockRemove).toHaveBeenCalledWith('/repo/feature/package.json') + expect(mockSymlinkHelper.createSymlinks).toHaveBeenCalledWith( + '/repo/main', + '/repo/feature', + mockConfig.symlink, + expect.objectContaining({ + replaceExisting: true, + skipConflicts: true, + items: ['package.json'], + }) + ) + }) + + it('drops already-correct symlinks (idempotent re-apply is a no-op for them)', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue([ + 'package.json', + 'pnpm-lock.yaml', + ]) + vi.mocked(mockPathExists).mockResolvedValue(true) + vi.mocked(mockSymlinkHelper.verifySymlink).mockResolvedValue(true) // both already linked + vi.mocked(mockSymlinkHelper.createSymlinks).mockResolvedValue({ + success: true, + created: 0, + skipped: 0, + conflicts: [], + } as SymlinkResult) + + await orchestrator.setupNewWorktree('/repo/feature', { adopt: true }) + + expect(mockRemove).not.toHaveBeenCalled() + expect(mockSymlinkHelper.createSymlinks).toHaveBeenCalledWith( + '/repo/main', + '/repo/feature', + mockConfig.symlink, + expect.objectContaining({ items: [] }) + ) + }) + + it('excludes preexistingDirtyPaths from the clean-tree check', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue([]) + vi.mocked(mockGitHelper.getDirtyPaths).mockResolvedValue(['src/user-work.ts', 'notes.md']) + + const result = await orchestrator.setupNewWorktree('/repo/feature', { + adopt: true, + preexistingDirtyPaths: ['src/user-work.ts', 'notes.md'], + }) + + expect(result.cleanTree).toBe(true) + }) + + it('dryRun returns a plan and mutates nothing', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue([ + 'package.json', + 'pnpm-lock.yaml', + ]) + // package.json target absent (lstat throws) -> toCreate; + // pnpm-lock target present + correct symlink -> alreadyLinked + vi.mocked(mockLstat).mockImplementation(async (p: string) => { + if (p === '/repo/feature/package.json') throw new Error('ENOENT') + return { + isSymbolicLink: () => true, + isDirectory: () => false, + isFile: () => false, + } as never + }) + vi.mocked(mockSymlinkHelper.verifySymlink).mockResolvedValue(true) + + const result = await orchestrator.setupNewWorktree('/repo/feature', { + adopt: true, + dryRun: true, + }) + + expect(result.plan).toBeDefined() + expect(result.plan!.symlinks.toCreate).toContain('package.json') + expect(result.plan!.symlinks.alreadyLinked).toContain('pnpm-lock.yaml') + expect(result.plan!.rsyncMode).toBe('untracked') + expect(result.plan!.rsyncFileCount).toBe(2) + + expect(mockSymlinkHelper.createSymlinks).not.toHaveBeenCalled() + expect(mockRsyncHelper.rsync).not.toHaveBeenCalled() + expect(mockRemove).not.toHaveBeenCalled() + expect(mockTransaction.createCheckpoint).not.toHaveBeenCalled() + }) + + it('forces onlyUntracked rsync and adds --ignore-existing to protect existing files', async () => { + // Even a full-mirror config must be downgraded to safe mode for adopt. + mockConfig.rsync.onlyUntracked = false + mockConfig.symlink.patterns = [] + + await orchestrator.setupNewWorktree('/repo/feature', { adopt: true }) + + // onlyUntracked forced -> the ignored-file list drives the sync (no full mirror). + expect(mockGitHelper.listIgnoredFiles).toHaveBeenCalledWith('/repo/main') + expect(mockRsyncHelper.rsync).toHaveBeenCalledWith( + '/repo/main', + '/repo/feature', + expect.objectContaining({ flags: expect.arrayContaining(['--ignore-existing']) }), + expect.any(Object) + ) + }) + + it('classifies a dangling symlink at a target as a conflict, not toCreate', async () => { + vi.mocked(mockSymlinkHelper.matchPatterns).mockResolvedValue(['.env']) + // lstat resolves (a broken symlink entry is present) but it is not the + // correct link, so it must be a conflict rather than "create". + vi.mocked(mockLstat).mockResolvedValue({ + isSymbolicLink: () => true, + isDirectory: () => false, + isFile: () => false, + } as never) + vi.mocked(mockSymlinkHelper.verifySymlink).mockResolvedValue(false) + + const result = await orchestrator.setupNewWorktree('/repo/feature', { + adopt: true, + dryRun: true, + }) + + expect(result.plan!.symlinks.conflicts).toContain('.env') + expect(result.plan!.symlinks.toCreate).not.toContain('.env') + }) + }) })