From be362e09b4052eabc4407cbbfbade241f65018cc Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 08:24:17 -0500 Subject: [PATCH 1/8] docs(adopt): logic spec for pando adopt (worktree takeover) --- .../specs/2026-07-23-worktree-adopt/logic.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/quirk/specs/2026-07-23-worktree-adopt/logic.md 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..e1672bd --- /dev/null +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md @@ -0,0 +1,287 @@ +# Logic Spec: `pando adopt` — worktree takeover + +## Status + +Draft — awaiting user review. + +## 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. From c199b05b9e046b181e4bf8470ff7efb45096a7e7 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 08:42:55 -0500 Subject: [PATCH 2/8] docs(adopt): mark logic spec approved --- docs/quirk/specs/2026-07-23-worktree-adopt/logic.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md index e1672bd..9e8b3a3 100644 --- a/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md @@ -2,7 +2,7 @@ ## Status -Draft — awaiting user review. +Approved — ready for implementation. ## Purpose From 93b791615f2c032062f332215b3351a539cd7ad7 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 08:55:56 -0500 Subject: [PATCH 3/8] docs(adopt): tech spec + status --- .../specs/2026-07-23-worktree-adopt/logic.md | 3 +- .../specs/2026-07-23-worktree-adopt/tech.md | 296 ++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 docs/quirk/specs/2026-07-23-worktree-adopt/tech.md diff --git a/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md index 9e8b3a3..1086f21 100644 --- a/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/logic.md @@ -2,7 +2,8 @@ ## Status -Approved — ready for implementation. +Approved — ready for implementation. Tech spec authored (`tech.md`) — gate fired: +touches ≳3 source files. ## Purpose 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..6e3f06f --- /dev/null +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md @@ -0,0 +1,296 @@ +# 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. + +**Accepted limitation (document in code):** adopt-mode rollback reverts recorded +symlink and rsync operations but does not remove the worktree; residual synced +gitignored artifacts, if any survive a partial rollback, are harmless (gitignored, +not user work) and a re-run of `adopt` converges. This is consistent with the +"never delete user work" invariant. + +### 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. From 7a35f9ab0934249a31611acf2504f2ac4397d9b1 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 09:27:48 -0500 Subject: [PATCH 4/8] feat(adopt): add pando adopt to take over externally-created worktrees - New `pando adopt [path]` command: runs pando setup (rsync, symlinks, metadata, ports, post-commands) on a worktree pando did not create. - Safety invariants: never removes the worktree on failure, never clobbers user work (skip+warn on symlink conflicts, --replace-existing to override), dirt-tolerant clean-tree check, --dry-run preview. - GitHelper.getWorktreeByPath (realpath match, isMain detection). - worktreeSetup adopt mode (SetupOptions.adopt/dryRun/replaceExistingSymlinks/ preexistingDirtyPaths) + SetupPlan. - Shared trust-gated post-command runner (extracted from add) + adopt->add post-command key fallback. - Shared setup-flag overrides (extracted from add). - Long-lived default kind for adopts (kindOverride on setupLifecycleMetadata). - Unit + E2E coverage. --- src/commands/add.ts | 271 ++--------- src/commands/adopt.ts | 663 +++++++++++++++++++++++++++ src/utils/git.ts | 47 +- src/utils/postCommandRunner.ts | 214 +++++++++ src/utils/setupFlags.ts | 47 ++ src/utils/worktreeSetup.ts | 217 ++++++++- test/commands/add.test.ts | 160 ++----- test/commands/adopt.test.ts | 352 ++++++++++++++ test/e2e/commands/adopt.e2e.test.ts | 176 +++++++ test/e2e/helpers/cli-runner.ts | 16 + test/utils/git.test.ts | 51 +++ test/utils/postCommandRunner.test.ts | 207 +++++++++ test/utils/worktreeSetup.test.ts | 144 ++++++ 13 files changed, 2194 insertions(+), 371 deletions(-) create mode 100644 src/commands/adopt.ts create mode 100644 src/utils/postCommandRunner.ts create mode 100644 src/utils/setupFlags.ts create mode 100644 test/commands/adopt.test.ts create mode 100644 test/e2e/commands/adopt.e2e.test.ts create mode 100644 test/utils/postCommandRunner.test.ts 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..1e72b9a --- /dev/null +++ b/src/commands/adopt.ts @@ -0,0 +1,663 @@ +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. + } + + 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, + resolveAdoptKind(flags as Record), + config.worktree.targetBranch ?? 'main', + 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, + // Adopt has no "branched-from" moment; record the integration branch, + // which is what reap/stale detection compares against. + sourceBranch: config.worktree.targetBranch, + worktreeBranch: match.info.branch, + kindOverride: resolveAdoptKind(flags as Record), + }) + 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...' + const gitRoot = await gitHelper.getRepositoryRoot() + const config = await loadConfig({ cwd: process.cwd(), gitRoot }) + 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/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..9a67666 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 } // ============================================================================ @@ -184,7 +244,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 +284,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 +329,8 @@ export class WorktreeSetupOrchestrator { sourceTreePath, worktreePath, symlinkConfig, - symlinkItems + symlinkItems, + { preserveExisting, classification } ) // Add warnings for skipped conflicts @@ -286,6 +385,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 +452,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 +536,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 +573,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) { // ============================================================ @@ -586,27 +701,103 @@ 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. + for (const item of symlinkItems) { + const targetPath = path.default.join(worktreePath, item) + if (await fs.pathExists(targetPath)) { + 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) + if (!(await fs.pathExists(target))) { + 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..40532c8 --- /dev/null +++ b/test/commands/adopt.test.ts @@ -0,0 +1,352 @@ +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('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/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..88cb71f 100644 --- a/test/utils/worktreeSetup.test.ts +++ b/test/utils/worktreeSetup.test.ts @@ -1128,4 +1128,148 @@ 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() + 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 -> toCreate; pnpm-lock target present + correct -> alreadyLinked + vi.mocked(mockPathExists).mockImplementation( + async (p: string) => p !== '/repo/feature/package.json' + ) + 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() + }) + }) }) From 23dcdd34ff44fe9aac217a0f3b02e19103c469b0 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 09:29:57 -0500 Subject: [PATCH 5/8] docs(adopt): document pando adopt (README, .pando.toml.example, DESIGN) --- .pando.toml.example | 6 ++++-- README.md | 49 ++++++++++++++++++++++++++++++++++++++---- src/commands/DESIGN.md | 19 ++++++++++++++++ src/utils/DESIGN.md | 4 +++- 4 files changed, 71 insertions(+), 7 deletions(-) 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..7b8f7f9 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 only carries gitignored artifacts (never tracked or modified files), and adopting a dirty worktree leaves every uncommitted change in place. JSON output lists them under `preexistingDirty`. +- **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. +- **Failure never deletes the worktree.** If setup fails, pando rolls back only the changes it made this run; the worktree and your work survive. +- **Idempotent.** Re-running `adopt` on an already-managed worktree re-applies setup and reports `alreadyManaged: true` — handy as a "re-sync / repair". + +**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/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/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) From 114a8b33fbdb2f5709b1b5d652378b8e74f35f92 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 09:46:28 -0500 Subject: [PATCH 6/8] fix(adopt): harden against worktree/data loss (adversarial review) Findings from a Codex adversarial review: - CRITICAL: adopt-mode rollback could delete the entire worktree. rsync records the worktree root as its rollback target, so transaction.rollback() would fs.remove() it even though the 'worktree' checkpoint was skipped. Add skipRsyncRollback to FileOperationTransaction.rollback() and set it in adopt mode (real-transaction test proves the worktree survives). - HIGH: adopt could overwrite user files via rsync. Force onlyUntracked=true and add --ignore-existing in adopt mode so a full-mirror config can't clobber tracked files and an existing target file (e.g. a hand-made .env) is never overwritten. - MEDIUM: re-adopt silently rewrote lifecycle metadata. Preserve existing kind, createdAt, and sourceBranch unless an explicit lifecycle flag is passed. - MEDIUM: dangling symlink at a target was misclassified as toCreate. Use lstat (not pathExists) in classification and the pre-removal loop. Docs + tech spec updated. 848 tests pass. --- README.md | 6 +- .../specs/2026-07-23-worktree-adopt/tech.md | 25 +++++-- src/commands/adopt.ts | 25 +++++-- src/utils/fileOps.ts | 19 +++++- src/utils/worktreeSetup.ts | 53 +++++++++++++-- test/commands/adopt.test.ts | 42 ++++++++++++ test/utils/fileOps.test.ts | 17 +++++ test/utils/worktreeSetup.test.ts | 65 +++++++++++++++++-- 8 files changed, 228 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7b8f7f9..c9f5b2a 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,10 @@ pando adopt ../feature-x --dry-run **Safety semantics:** -- **Your work is never touched.** Rsync only carries gitignored artifacts (never tracked or modified files), and adopting a dirty worktree leaves every uncommitted change in place. JSON output lists them under `preexistingDirty`. +- **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. -- **Failure never deletes the worktree.** If setup fails, pando rolls back only the changes it made this run; the worktree and your work survive. -- **Idempotent.** Re-running `adopt` on an already-managed worktree re-applies setup and reports `alreadyManaged: true` — handy as a "re-sync / repair". +- **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. diff --git a/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md b/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md index 6e3f06f..fbebf2b 100644 --- a/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md +++ b/docs/quirk/specs/2026-07-23-worktree-adopt/tech.md @@ -104,11 +104,26 @@ New private helper `classifyAdoptSymlinks(sourceTreePath, worktreePath, symlinkI returns `{ toCreate, alreadyLinked, conflicts }` by stat/verify per item; used by both the dry-run plan and the preserve-existing no-op filter. -**Accepted limitation (document in code):** adopt-mode rollback reverts recorded -symlink and rsync operations but does not remove the worktree; residual synced -gitignored artifacts, if any survive a partial rollback, are harmless (gitignored, -not user work) and a re-run of `adopt` converges. This is consistent with the -"never delete user work" invariant. +**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 diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 1e72b9a..1b73b66 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -180,6 +180,16 @@ export default class AdoptWorktree extends Command { // 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, @@ -196,8 +206,8 @@ export default class AdoptWorktree extends Command { targetPath, match.info, setupResult, - resolveAdoptKind(flags as Record), - config.worktree.targetBranch ?? 'main', + adoptKind, + adoptSourceBranch, chalk, warnings ) @@ -215,11 +225,14 @@ export default class AdoptWorktree extends Command { gitRoot, mainRepoPath, resolvedPath: targetPath, - // Adopt has no "branched-from" moment; record the integration branch, - // which is what reap/stale detection compares against. - sourceBranch: config.worktree.targetBranch, + // 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: resolveAdoptKind(flags as Record), + 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) 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/worktreeSetup.ts b/src/utils/worktreeSetup.ts index 9a67666..aa5ef5d 100644 --- a/src/utils/worktreeSetup.ts +++ b/src/utils/worktreeSetup.ts @@ -174,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 @@ -193,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 @@ -221,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() @@ -644,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') @@ -710,11 +739,16 @@ export class WorktreeSetupOrchestrator { 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. + // 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) - if (await fs.pathExists(targetPath)) { + try { + await fs.lstat(targetPath) await fs.remove(targetPath) + } catch { + // No entry at target; nothing to remove. } } @@ -759,7 +793,16 @@ export class WorktreeSetupOrchestrator { for (const item of symlinkItems) { const target = path.default.join(worktreePath, item) const source = path.default.join(sourceTreePath, item) - if (!(await fs.pathExists(target))) { + // 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) diff --git a/test/commands/adopt.test.ts b/test/commands/adopt.test.ts index 40532c8..4901475 100644 --- a/test/commands/adopt.test.ts +++ b/test/commands/adopt.test.ts @@ -281,6 +281,48 @@ describe('adopt: apply', () => { ) }) + 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' }) 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/worktreeSetup.test.ts b/test/utils/worktreeSetup.test.ts index 88cb71f..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 @@ -1151,6 +1163,8 @@ describe('WorktreeSetupOrchestrator', () => { ) 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() @@ -1249,10 +1263,16 @@ describe('WorktreeSetupOrchestrator', () => { 'package.json', 'pnpm-lock.yaml', ]) - // package.json target absent -> toCreate; pnpm-lock target present + correct -> alreadyLinked - vi.mocked(mockPathExists).mockImplementation( - async (p: string) => p !== '/repo/feature/package.json' - ) + // 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', { @@ -1271,5 +1291,42 @@ describe('WorktreeSetupOrchestrator', () => { 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') + }) }) }) From 345237ff49115c72455e032796ba3a8dd11990f9 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Thu, 23 Jul 2026 09:49:45 -0500 Subject: [PATCH 7/8] fix(adopt): let symlink pre-removal errors propagate, not just lstat not-found Second-pass review: the dangling-symlink lstat fix wrapped lstat+remove in one try, swallowing real removal failures (EACCES/EPERM/IO) that should fail setup and roll back. Catch only the lstat not-found case; let removal errors propagate. --- src/utils/worktreeSetup.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/utils/worktreeSetup.ts b/src/utils/worktreeSetup.ts index aa5ef5d..8a90096 100644 --- a/src/utils/worktreeSetup.ts +++ b/src/utils/worktreeSetup.ts @@ -744,11 +744,17 @@ export class WorktreeSetupOrchestrator { // 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) - await fs.remove(targetPath) } catch { - // No entry at target; nothing to remove. + targetExists = false + } + if (targetExists) { + await fs.remove(targetPath) } } From 776e434c0b778dd73027d776b372b98b83f29808 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sat, 25 Jul 2026 07:31:26 -0500 Subject: [PATCH 8/8] fix(adopt): load project config from the main worktree, not the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adopt runs from inside the target worktree, so process.cwd() and git --show-toplevel both resolved to that worktree. Config discovery then parsed the worktree's own files (e.g. a non-JSON package.json placed at a symlink target by the user), throwing a ConfigParseFailureError, and never read the real project config (.pando.toml in the main repo) that governs symlink/rsync patterns. Load config from getMainWorktreePath() instead — the same tree already used as the rsync/symlink source. Fixes the two 'protects user work' E2E tests (symlink conflict skip + --replace-existing). --- src/commands/adopt.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 1b73b66..5028d24 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -309,8 +309,14 @@ export default class AdoptWorktree extends Command { warnings: string[] ): Promise { if (spinner) spinner.text = 'Loading configuration...' - const gitRoot = await gitHelper.getRepositoryRoot() - const config = await loadConfig({ cwd: process.cwd(), gitRoot }) + // 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) )