diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e820e58 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "pando", + "owner": { + "name": "zpyoung" + }, + "plugins": [ + { + "name": "pando", + "source": "./plugin", + "description": "Claude Code worktree lifecycle management powered by pando" + } + ] +} diff --git a/.pando.toml.example b/.pando.toml.example index c693ada..22ce885 100644 --- a/.pando.toml.example +++ b/.pando.toml.example @@ -140,6 +140,20 @@ deleteBranchOnRemove = "local" # Default: "main" # targetBranch = "main" +# Default lifecycle kind for newly created worktrees +# "auto" lets Pando choose; explicit options are "ephemeral" and "long-lived" +# Default: "auto" +defaultKind = "auto" + +# Time-to-live assigned to ephemeral worktrees +# Use a duration such as "30m", "4h", or "2d" +# Default: "4h" +ephemeralTtl = "4h" + +# Automatically lock worktrees that are currently active against reaping +# Default: true +autoLockActive = true + # ============================================================================ # Clean Configuration # ============================================================================ @@ -153,6 +167,61 @@ deleteBranchOnRemove = "local" # Default: false # fetch = true +# ============================================================================ +# Reap Configuration +# ============================================================================ +# Controls safety checks when reaping ephemeral worktrees + +[reap] +# Require the worktree branch to be merged before it can be reaped +# Default: true +requireMerged = true + +# ============================================================================ +# Concurrency Configuration +# ============================================================================ +# Controls retry behavior when lifecycle operations encounter concurrent updates + +[concurrency.retry] +# Maximum number of attempts before failing +# Default: 5 +maxAttempts = 5 + +# Initial retry delay in milliseconds +# Default: 100 +baseMs = 100 + +# Maximum retry delay in milliseconds +# Default: 2000 +capMs = 2000 + +# ============================================================================ +# Port Allocation Configuration +# ============================================================================ +# Controls deterministic service ports and database names for worktrees + +[ports] +# Enable port and database allocation +# Default: false +enabled = false + +# Inclusive port range available for allocation +# Default: "3100-3199" +range = "3100-3199" + +# Logical service names that receive allocated ports +# Default: ["web"] +names = ["web"] + +# Database naming strategy +# Options: "named" +# Default: "named" +dbStrategy = "named" + +# Base name used with each worktree's own branch to derive its database name +# Default: "dev" +dbBaseName = "dev" + # ============================================================================ # Post-command Scripts # ============================================================================ @@ -216,7 +285,19 @@ deleteBranchOnRemove = "local" # PANDO_WORKTREE_DELETE_BRANCH_ON_REMOVE=local # PANDO_WORKTREE_USE_PROJECT_SUBFOLDER=true # PANDO_WORKTREE_TARGET_BRANCH=main +# PANDO_WORKTREE_DEFAULT_KIND=ephemeral +# PANDO_WORKTREE_EPHEMERAL_TTL=4h +# PANDO_WORKTREE_AUTO_LOCK_ACTIVE=false # PANDO_CLEAN_FETCH=true +# PANDO_REAP_REQUIRE_MERGED=true +# PANDO_CONCURRENCY_RETRY_MAX_ATTEMPTS=9 +# PANDO_CONCURRENCY_RETRY_BASE_MS=100 +# PANDO_CONCURRENCY_RETRY_CAP_MS=2000 +# PANDO_PORTS_ENABLED=true +# PANDO_PORTS_RANGE=3100-3199 +# PANDO_PORTS_NAMES=web,api +# PANDO_PORTS_DB_STRATEGY=named +# PANDO_PORTS_DB_BASE_NAME=dev # ============================================================================ # CLI Flag Overrides diff --git a/README.md b/README.md index f0be79e..019b096 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ Create a new git worktree (supports both creating new branches and checking out - `-c, --commit`: Commit hash to base the new branch on - `-f, --force`: Force create branch even if it exists (uses git worktree add -B) - `--no-rebase`: Skip automatic rebase of existing branch onto source branch +- `--ephemeral`: Mark the worktree as ephemeral (mutually exclusive with `--long-lived`) +- `--long-lived`: Mark the worktree as long-lived (mutually exclusive with `--ephemeral`) +- `--ttl `: Set a per-worktree TTL override (for example, `30m` or `2d`) +- `--owner `: Record an owner or agent session id +- `--ports`: Force-enable configured port and database-name allocation for this run - `--skip-rsync`: Skip the rsync copy step (ignores rsync config) - `--rsync-flags`: Override rsync flags (comma-separated; repeatable) - `--rsync-exclude`: Additional rsync exclude patterns (comma-separated; repeatable) @@ -96,6 +101,8 @@ Create a new git worktree (supports both creating new branches and checking out > When `--skip-rsync` is combined with `--rsync-flags` or `--rsync-exclude`, the rsync flags are ignored and a warning is shown. +**Lifecycle metadata**: `pando add` stores the worktree kind, creation time, source branch, and optional owner/TTL in Git's per-worktree config. Flags override `worktree.defaultKind`; `auto` treats Claude worktree paths, active agent-session environments, and boolean `PANDO_EPHEMERAL=true|1|yes` signals as ephemeral, and other worktrees as long-lived. Worktrees created during an active `CLAUDE_SESSION_ID` or `PANDO_SESSION` are automatically Git-locked when `worktree.autoLockActive` is enabled; passing `--owner` alone does not trigger auto-locking. When `[ports].enabled` or `--ports` is set, requested service ports and a database name derived from each worktree's own branch are also stored. Exhausted ports produce a warning without failing worktree creation. + **Automatic Rebase**: When checking out an existing branch, pando automatically rebases it onto the current branch. This keeps your feature branches up-to-date. If the rebase fails (e.g., conflicts), a warning is shown but the worktree is still created. Use `--no-rebase` to skip this behavior, or set `worktree.rebaseOnAdd = false` in config. **Rsync Behavior**: By default (`rsync.onlyUntracked = true`), rsync carries over only files that git ignores in the source worktree — build artifacts like `.venv/`, `node_modules/`, and caches. Tracked files always come from the new worktree's own checkout, so rsync can never dirty the tree, and artifact syncing works even when the new worktree is on a different branch or commit. Non-ignored untracked files (work in progress) are deliberately not copied. With `rsync.onlyUntracked = false`, pando mirrors the full source tree instead, but only when the source and new worktree are on the same commit — otherwise rsync is skipped with a warning so tracked files from the source branch do not dirty the new checkout. Use `--skip-rsync` to disable rsync explicitly. @@ -143,9 +150,12 @@ List all git worktrees ```bash pando list +pando list --verbose pando list --json ``` +Verbose human output includes compact kind, owner, age, and lock details. JSON output always includes `kind`, `createdAt`, `owner`, `ttl`, `ageMs`, and `locked` for every worktree (nullable metadata fields are `null`). + ### `pando health` Show health status of all worktrees @@ -163,6 +173,8 @@ Show health status of all worktrees - `detached`: Worktree is in detached-HEAD state (no branch) - `error`: Cannot check status (directory missing, git error, or remote check failed) +Each human report entry also shows lifecycle kind and lock state. JSON worktree entries include `kind`, `ageMs`, `ttl`, `locked`, and `owner` alongside the health fields. + **Examples:** ```bash @@ -226,6 +238,7 @@ Remove a git worktree - Before deleting, checks if branch is merged (use `--force` to skip this check) - Remote branch deletion requires confirmation unless `--force` is used - Use `worktree.deleteBranchOnRemove` in config to change default behavior +- JSON results include the worktree's captured lifecycle `metadata`, including assigned ports and database name **Examples:** @@ -291,6 +304,40 @@ pando clean --keep-branch pando clean --json ``` +### `pando reap` + +Reclaim expired ephemeral worktrees without touching long-lived, locked, or dirty worktrees. A worktree is eligible after its metadata TTL (or `worktree.ephemeralTtl`, default `4h`) expires. By default its branch must also be merged into `worktree.targetBranch`; set `reap.requireMerged = false` to retain unmerged branches while removing clean worktrees. + +**Flags:** + +- `--dry-run`: Show eligible and safety-skipped worktrees without removing anything +- `--owner `: Only consider worktrees owned by the specified session +- `-f, --force`: Skip the confirmation prompt; safety checks still apply +- `-j, --json`: Run non-interactively and emit structured results + +```bash +pando reap --dry-run +pando reap --owner session-123 --force +pando reap --json +``` + +### `pando lock` / `pando unlock` + +Lock a worktree so Git and `pando reap` leave it alone, or remove that lock. Targets may be an absolute/relative worktree path or an exact branch name; `--path` is available as an alternative to the positional target. + +**Flags:** + +- `-p, --path `: Worktree path or branch name +- `--reason `: Record a lock reason (`pando lock` only) +- `-j, --json`: Output in JSON format + +```bash +pando lock ../feature-x --reason "active work" +pando lock feature/x --json +pando unlock ../feature-x +pando unlock --path feature/x --json +``` + ### `pando symlink` Move a file from the current worktree to the main worktree and replace it with a symlink. Useful for keeping configuration files, dependencies, or other shared files in sync across all worktrees. @@ -530,6 +577,17 @@ rebaseOnAdd = true # Rebase existing branches when adding worktre deleteBranchOnRemove = "local" # Delete branch on worktree remove: "none", "local", "remote" (default: "local") useProjectSubfolder = false # Nest worktrees as defaultPath/projectName/branchName (default: false) targetBranch = "main" # Target branch for merge checks (used by clean command) +defaultKind = "auto" # auto, ephemeral, or long-lived +ephemeralTtl = "4h" # Default TTL exposed to ephemeral setup hooks +autoLockActive = true # Git-lock worktrees owned by active sessions + +# Optional per-worktree resources +[ports] +enabled = false # Or opt in for one add with --ports +range = "3100-3199" +names = ["web"] +dbStrategy = "named" +dbBaseName = "dev" # Prefix for a name derived from each worktree's own branch # Clean Configuration [clean] @@ -556,6 +614,10 @@ Scripts configured for `add` run **after** the worktree has been created and rsy - `PANDO_WORKTREE_PATH` — absolute path to the created worktree - `PANDO_BRANCH` — branch name, or empty in detached HEAD mode - `PANDO_COMMIT` — created worktree commit +- `PANDO_KIND` — resolved lifecycle kind (`ephemeral` or `long-lived`) +- `PANDO_TTL` — effective TTL when defined (explicit `--ttl`, otherwise the ephemeral default) +- `PANDO_PORT_` — each assigned port; service names are uppercased and hyphens become underscores (for example, `web-api` becomes `PANDO_PORT_WEB_API`) +- `PANDO_DB_NAME` — the derived database name when port allocation is enabled Human-readable output shows each script, its working directory, exit status, stdout, and stderr. JSON output includes a stable `postCommands` array with `name`, `command`, `cwd`, `exitCode`, `signal`, `stdout`, `stderr`, `success`, and `duration` fields. A non-zero exit code stops later scripts and returns the existing JSON error shape with `success: false`, `error`, `postCommands`, and `failedPostCommand`. diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..864c3a3 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "pando", + "description": "Manage Claude Code worktrees with pando lifecycle metadata, locking, and reaping", + "author": { + "name": "zpyoung", + "url": "https://github.com/zpyoung" + }, + "homepage": "https://github.com/zpyoung/pando", + "repository": "https://github.com/zpyoung/pando", + "license": "MIT", + "keywords": ["git", "worktrees", "parallel-agents", "automation"], + "hooks": "./hooks/hooks.json", + "userConfig": { + "delegateCreation": { + "type": "boolean", + "title": "Delegate worktree creation to pando", + "default": true, + "description": "Use pando for Claude Code worktree creation; disable to use plain git worktree add." + }, + "reapOnSessionEnd": { + "type": "boolean", + "title": "Reap worktrees when sessions end", + "default": true, + "description": "Unlock and reap eligible ephemeral worktrees owned by the ending Claude Code session." + } + } +} diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..e938ba7 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,65 @@ +# pando plugin for Claude Code + +This plugin delegates Claude Code worktree creation and lifecycle cleanup to the `pando` CLI. It also provides the `pando-worktrees` skill and the `/pando:pando-status` and `/pando:pando-reap` commands. + +## Requirements + +Install these before installing the plugin: + +- Git 2.38 or newer (pando uses worktree-specific Git configuration). +- A recent Claude Code release with plugin `userConfig`, `WorktreeCreate`, and `SessionEnd` support. +- The `pando` CLI. +- `jq` is recommended for structured hook input/output parsing. Without it, worktree creation degrades to plain Git and session cleanup is limited to best-effort reaping. + +Install the CLI from npm: + +```sh +pnpm add -g @zyoung-ff/pando +``` + +Or install a downloaded release/package tarball: + +```sh +pnpm add -g ./zyoung-ff-pando-.tgz +``` + +Verify both dependencies: + +```sh +pando --version +git --version +``` + +## Install the plugin + +In Claude Code, add this repository as a marketplace and install its plugin: + +```text +/plugin marketplace add zpyoung/pando +/plugin install pando@pando +``` + +Restart Claude Code if the newly installed hooks are not active in the current session. + +The hooks check `pando --version` at runtime. Worktree creation falls back to `git worktree add` when the CLI is missing, too old, disabled, or returns an unusable response, so Claude Code can still create its isolated worktree. + +## Configuration + +Claude Code prompts for these options when enabling the plugin. Both default to `true`: + +- `delegateCreation`: let pando create Claude Code worktrees and attach ephemeral lifecycle metadata. Set it to `false` to use plain `git worktree add`. +- `reapOnSessionEnd`: unlock worktrees owned by an ending session and run owner-scoped pando reaping. Set it to `false` to disable session-end cleanup. + +They can also be supplied during CLI installation, for example: + +```sh +claude plugin install pando@pando --config delegateCreation=true --config reapOnSessionEnd=true +``` + +## Usage + +Claude Code automatically uses pando when it creates an isolated worktree. The bundled skill guides ephemeral versus long-lived worktree choices and locking behavior. + +Use `/pando:pando-status` to summarize `pando list --json` and `pando health --json`. Use `/pando:pando-reap` to preview expired worktrees and confirm cleanup before running it. + +Pando post-commands remain subject to pando's normal configuration trust gate. The plugin does not set `PANDO_TRUST_CONFIG`; trust project configuration separately when appropriate. diff --git a/plugin/commands/pando-reap.md b/plugin/commands/pando-reap.md new file mode 100644 index 0000000..724272d --- /dev/null +++ b/plugin/commands/pando-reap.md @@ -0,0 +1,5 @@ +--- +description: Preview and optionally reap expired pando worktrees +--- + +Run `pando reap --dry-run --json` and summarize the worktrees that would be reaped, plus anything skipped and each safety reason. If there are eligible worktrees, ask for explicit confirmation before taking action. Only after confirmation run `pando reap --force --json`, then summarize the structured result. Never omit `--json` and never run the force command without approval. diff --git a/plugin/commands/pando-status.md b/plugin/commands/pando-status.md new file mode 100644 index 0000000..482183d --- /dev/null +++ b/plugin/commands/pando-status.md @@ -0,0 +1,5 @@ +--- +description: Show pando worktree lifecycle and repository health status +--- + +Run `pando list --json` and `pando health --json` from the current repository. Parse the JSON responses and summarize each worktree's path/branch, kind, age, lock state, and owner. Then report health warnings or errors and call out ephemeral worktrees that appear stale. Do not use non-JSON pando commands and do not change repository state. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..1117a3c --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "WorktreeCreate": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/worktree-create.sh" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/session-end.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugin/scripts/session-end.sh b/plugin/scripts/session-end.sh new file mode 100755 index 0000000..be0cf79 --- /dev/null +++ b/plugin/scripts/session-end.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -u + +# SessionEnd is advisory and time-bounded. Never let cleanup failures interfere +# with Claude Code shutdown, and keep command stdout out of the hook response. +diagnose() { + printf 'pando plugin: %s\n' "$*" >&2 +} + +is_falsey() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + false | 0 | no) return 0 ;; + *) return 1 ;; + esac +} + +simple_json_string() { + local key="$1" + printf '%s' "$hook_input" \ + | tr '\n' ' ' \ + | sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1 +} + +pando_is_compatible() { + local version_output major minor + command -v pando >/dev/null 2>&1 || return 1 + version_output=$(pando --version 2>/dev/null) || return 1 + if [[ ! "$version_output" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + return 0 + fi + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + if ((major > 0 || minor >= 1)); then + return 0 + fi + return 1 +} + +hook_input=$(cat 2>/dev/null || printf '{}') + +if is_falsey "${CLAUDE_PLUGIN_OPTION_REAPONSESSIONEND:-true}"; then + exit 0 +fi + +has_jq=false +if command -v jq >/dev/null 2>&1; then + has_jq=true + session_id=$(printf '%s' "$hook_input" | jq -er '.session_id | select(type == "string" and length > 0)' 2>/dev/null || printf '') +else + session_id=$(simple_json_string session_id) + diagnose "jq is unavailable; skipping owner lock discovery" +fi + +if [ -z "$session_id" ]; then + diagnose "SessionEnd input has no session_id; skipping cleanup" + exit 0 +fi + +if ! pando_is_compatible; then + diagnose "pando 0.1.0 or newer is unavailable; skipping cleanup" + exit 0 +fi + +if [ "$has_jq" = true ]; then + list_output="" + if list_output=$(pando list --json); then + owned_paths=$(printf '%s' "$list_output" | jq -r --arg owner "$session_id" ' + .worktrees[]? + | select(.owner == $owner and .locked == true) + | .path + | select(type == "string" and length > 0) + ' 2>/dev/null || printf '') + + if [ -n "$owned_paths" ]; then + while IFS= read -r worktree_path; do + [ -n "$worktree_path" ] || continue + if ! pando unlock "$worktree_path" --json >/dev/null; then + diagnose "could not unlock '$worktree_path'" + fi + done <<<"$owned_paths" + fi + else + diagnose "could not list worktrees for session cleanup" + fi +fi + +if ! pando reap --owner "$session_id" --force --json >/dev/null; then + diagnose "owner-scoped reaping failed for session '$session_id'" +fi + +exit 0 diff --git a/plugin/scripts/worktree-create.sh b/plugin/scripts/worktree-create.sh new file mode 100755 index 0000000..983a670 --- /dev/null +++ b/plugin/scripts/worktree-create.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -u + +# WorktreeCreate replaces Claude Code's own git operation. Keep stdout reserved +# for the one path Claude Code consumes, and fail open to a usable directory. +diagnose() { + printf 'pando plugin: %s\n' "$*" >&2 +} + +is_falsey() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + false | 0 | no) return 0 ;; + *) return 1 ;; + esac +} + +simple_json_string() { + local key="$1" + printf '%s' "$hook_input" \ + | tr '\n' ' ' \ + | sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1 +} + +safe_slug() { + local value safe + value="$1" + safe=$(printf '%s' "$value" | sed 's/[^A-Za-z0-9._-]/-/g; s/^[.-]*//; s/[.-]*$//') + if [ -z "$safe" ]; then + safe="claude-worktree-$$" + fi + printf '%s' "$safe" +} + +pando_is_compatible() { + local version_output major minor + command -v pando >/dev/null 2>&1 || return 1 + version_output=$(pando --version 2>/dev/null) || return 1 + + # Lifecycle flags first shipped with pando 0.1.0. Development builds do not + # always print semver, so let the non-interactive add call probe those builds. + if [[ ! "$version_output" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + return 0 + fi + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + if ((major > 0 || minor >= 1)); then + return 0 + fi + return 1 +} + +is_worktree_directory() { + local top + [ -d "$target_dir" ] || return 1 + top=$(git -C "$target_dir" rev-parse --show-toplevel 2>/dev/null) || return 1 + [ "$top" = "$target_dir" ] +} + +canonicalize_directory() { + local candidate base canonical + candidate="$1" + [ -n "$candidate" ] || return 1 + + case "$candidate" in + /*) ;; + *) + base=$(pwd -P 2>/dev/null) || return 1 + candidate="$base/$candidate" + ;; + esac + + [ -d "$candidate" ] || return 1 + canonical=$(cd "$candidate" 2>/dev/null && pwd -P) || return 1 + case "$canonical" in + /*) ;; + *) return 1 ;; + esac + [ -d "$canonical" ] || return 1 + printf '%s' "$canonical" +} + +absolute_tmp_directory() { + local candidate attempt + + candidate=$(mktemp -d "/tmp/pando-worktree.XXXXXX" 2>/dev/null || printf '') + if [ -d "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + + # Retain the path contract even if mktemp itself is unavailable or fails. + attempt=0 + while [ "$attempt" -lt 10 ]; do + candidate="/tmp/pando-worktree.$$.$RANDOM.$attempt" + if mkdir -m 700 "$candidate" 2>/dev/null; then + printf '%s' "$candidate" + return 0 + fi + attempt=$((attempt + 1)) + done + return 1 +} + +fallback_to_git() { + local output_path tmp_base canonical_path + diagnose "using git worktree fallback for '$name'" + mkdir -p "$(dirname "$target_dir")" 2>/dev/null || true + + # A failed pando response can still leave a complete worktree behind. + if ! is_worktree_directory; then + rmdir "$target_dir" 2>/dev/null || true + if ! git -C "$repo_root" worktree add -b "$branch" "$target_dir" >&2; then + diagnose "could not create branch '$branch'; trying an existing branch" + if ! git -C "$repo_root" worktree add "$target_dir" "$branch" >&2; then + diagnose "git worktree fallback failed; preserving a directory for Claude Code" + fi + fi + fi + + output_path="$target_dir" + if [ ! -d "$output_path" ]; then + mkdir -p "$output_path" 2>/dev/null || true + fi + if [ ! -d "$output_path" ]; then + tmp_base="/tmp" + case "${TMPDIR:-}" in + /*) tmp_base="$TMPDIR" ;; + esac + output_path=$(mktemp -d "${tmp_base%/}/pando-${safe_name}.XXXXXX" 2>/dev/null || printf '') + fi + + canonical_path=$(canonicalize_directory "$output_path" 2>/dev/null || printf '') + if [ -z "$canonical_path" ]; then + output_path=$(absolute_tmp_directory 2>/dev/null || printf '') + canonical_path=$(canonicalize_directory "$output_path" 2>/dev/null || printf '') + fi + + # Creation can only fail when the environment offers no writable location. + # Never compound that failure by emitting a relative or nonexistent path. + if [ -z "$canonical_path" ]; then + canonical_path=$(canonicalize_directory /tmp 2>/dev/null \ + || canonicalize_directory "$base_cwd" 2>/dev/null \ + || canonicalize_directory / 2>/dev/null) + fi + + printf '%s\n' "$canonical_path" +} + +hook_input=$(cat 2>/dev/null || printf '{}') +name="" +session_id="" +input_cwd="" +json_ready=false + +if command -v jq >/dev/null 2>&1; then + name=$(printf '%s' "$hook_input" | jq -er '.name | select(type == "string" and length > 0)' 2>/dev/null || printf '') + session_id=$(printf '%s' "$hook_input" | jq -er '.session_id | select(type == "string" and length > 0)' 2>/dev/null || printf '') + input_cwd=$(printf '%s' "$hook_input" | jq -er '.cwd | select(type == "string" and length > 0)' 2>/dev/null || printf '') + if [ -n "$name" ] && [ -n "$session_id" ] && [ -n "$input_cwd" ]; then + json_ready=true + else + diagnose "invalid WorktreeCreate input; delegating to git" + fi +else + # The fallback parser only needs the contract's slug and ordinary path fields; + # pando output itself is never parsed without jq. + name=$(simple_json_string name) + session_id=$(simple_json_string session_id) + input_cwd=$(simple_json_string cwd) + diagnose "jq is unavailable; delegating worktree creation to git" +fi + +safe_name=$(safe_slug "${name:-worktree}") +name="${name:-$safe_name}" +branch="$safe_name" + +base_cwd="$input_cwd" +if [ -z "$base_cwd" ] || [ ! -d "$base_cwd" ]; then + base_cwd=$(pwd -P) +else + base_cwd=$(cd "$base_cwd" 2>/dev/null && pwd -P || pwd -P) +fi + +repo_root=$(git -C "$base_cwd" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$base_cwd") +case "$repo_root" in + /*) ;; + *) repo_root="$base_cwd/$repo_root" ;; +esac +target_dir="$repo_root/.claude/worktrees/$safe_name" + +if is_falsey "${CLAUDE_PLUGIN_OPTION_DELEGATECREATION:-true}"; then + diagnose "pando delegation is disabled" + fallback_to_git + exit 0 +fi + +if [ "$json_ready" != true ]; then + fallback_to_git + exit 0 +fi + +if ! pando_is_compatible; then + diagnose "pando 0.1.0 or newer is unavailable" + fallback_to_git + exit 0 +fi + +pando_output="" +if pando_output=$(pando add "$branch" --path "$target_dir" --ephemeral --owner "$session_id" --json); then + created_path=$(printf '%s' "$pando_output" | jq -er ' + if (.success // true) == true then + (.worktree.path // .path // .worktreePath) + else + empty + end + | select(type == "string" and length > 0) + ' 2>/dev/null || printf '') + + if [ -n "$created_path" ]; then + case "$created_path" in + /*) ;; + *) created_path="$repo_root/$created_path" ;; + esac + printf '%s\n' "$created_path" + exit 0 + fi + diagnose "pando returned JSON without a worktree path" +else + diagnose "pando add failed" +fi + +fallback_to_git +exit 0 diff --git a/plugin/skills/pando-worktrees/SKILL.md b/plugin/skills/pando-worktrees/SKILL.md new file mode 100644 index 0000000..aad50e4 --- /dev/null +++ b/plugin/skills/pando-worktrees/SKILL.md @@ -0,0 +1,34 @@ +--- +name: pando-worktrees +description: Manage isolated Git worktrees with pando when work involves parallel subagents, Claude Code worktree isolation, separate branches, or concurrent task execution. +--- + +# Pando worktree lifecycle + +Use pando rather than ad-hoc `git worktree` commands when creating or managing task worktrees. **Always pass `--json`** and make decisions from the structured response. + +## Choose a lifecycle + +- Use an **ephemeral** worktree for a parallel subagent, isolated experiment, short review, or other session-scoped task: + `pando add --path --ephemeral --owner --json` +- Use a **long-lived** worktree for work intended to survive sessions, such as an ongoing feature or release branch: + `pando add --path --long-lived --owner --json` +- Add `--ttl ` to an ephemeral worktree when the configured lifetime is unsuitable. Add `--ports` when the task needs isolated configured ports. + +## Protect active work + +Lock a worktree before active edits or handing it to a subagent: + +```text +pando lock --reason "active task" --json +``` + +Unlock it when active ownership ends: + +```text +pando unlock --json +``` + +Do not reap or remove a locked, dirty, or unmerged worktree. The plugin's session-end hook unlocks worktrees owned by that session and asks pando to reap eligible ephemeral worktrees; rely on that cleanup rather than deleting directories manually. + +Inspect state with `pando list --json` and `pando health --json`. Preview cleanup with `pando reap --dry-run --json`; only after explicit approval use `pando reap --force --json` (optionally scoped with `--owner `). diff --git a/src/commands/add.ts b/src/commands/add.ts index ea27732..e759f02 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,6 +1,16 @@ import { Args, Command, Flags } from '@oclif/core' +import { basename } from 'node:path' import { createGitHelper } from '../utils/git.js' import { loadConfig } from '../config/loader.js' +import { parseBoolean } from '../config/env.js' +import type { PandoConfig } from '../config/schema.js' +import { + assertGitVersion, + ensureWorktreeConfigEnabled, + writeMetadata, + type WorktreeMetadata, +} from '../utils/worktreeMetadata.js' +import { allocate, deriveDbName } from '../utils/portAllocator.js' import { createWorktreeSetupOrchestrator, SetupPhase } from '../utils/worktreeSetup.js' import { jsonFlag, pathFlag } from '../utils/common-flags.js' import { ErrorHelper, isOclifExitError } from '../utils/errors.js' @@ -20,6 +30,219 @@ import { type PostCommandResult, } from '../utils/postCommands.js' +type WorktreeKind = NonNullable + +type LifecycleGitHelper = Pick< + ReturnType, + 'getMainBranch' | 'inferOwner' | 'lockWorktree' +> + +interface LifecycleDependencies { + assertGitVersion: () => Promise + ensureWorktreeConfigEnabled: typeof ensureWorktreeConfigEnabled + writeMetadata: typeof writeMetadata + allocate: typeof allocate +} + +interface LifecycleOptions { + flags: Record + worktreeConfig: PandoConfig['worktree'] + portsConfig: PandoConfig['ports'] + gitHelper: LifecycleGitHelper + gitRoot: string + mainRepoPath: string + resolvedPath: string + sourceBranch?: string + worktreeBranch: string | null + env?: NodeJS.ProcessEnv + createdAt?: string +} + +export interface AddLifecycleResult { + kind: WorktreeKind + owner?: string + ttl?: string + effectiveTtl?: string + ports?: Record + dbName?: string + locked: boolean + notice?: string + warnings: string[] +} + +const lifecycleDependencies: LifecycleDependencies = { + assertGitVersion, + ensureWorktreeConfigEnabled, + writeMetadata, + allocate, +} + +/** Resolve lifecycle kind without coupling precedence rules to command I/O. */ +export function resolveWorktreeKind( + flags: Record, + configuredKind: PandoConfig['worktree']['defaultKind'], + resolvedPath: string, + env: NodeJS.ProcessEnv = process.env +): WorktreeKind { + if (flags.ephemeral) return 'ephemeral' + if (flags['long-lived']) return 'long-lived' + if (configuredKind === 'ephemeral' || configuredKind === 'long-lived') return configuredKind + + const normalizedPath = resolvedPath.replaceAll('\\', '/') + const hasAgentSession = env.CLAUDE_SESSION_ID !== undefined || env.PANDO_SESSION !== undefined + const hasEphemeralSignal = env.PANDO_EPHEMERAL !== undefined && parseBoolean(env.PANDO_EPHEMERAL) + const isAgentWorktree = + normalizedPath.includes('/.claude/worktrees/') || hasAgentSession || hasEphemeralSignal + + return isAgentWorktree ? 'ephemeral' : 'long-lived' +} + +/** + * Lifecycle setup is best-effort because a usable worktree is more valuable + * than failing the whole add after setup has already completed. + */ +export async function setupLifecycleMetadata( + options: LifecycleOptions, + dependencies: LifecycleDependencies = lifecycleDependencies +): Promise { + const { flags, worktreeConfig, portsConfig, gitHelper, gitRoot, mainRepoPath, resolvedPath } = + options + const env = options.env ?? process.env + const kind = 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 + const effectiveTtl = ttl ?? (kind === 'ephemeral' ? worktreeConfig.ephemeralTtl : undefined) + const result: AddLifecycleResult = { + kind, + ...(owner ? { owner } : {}), + ...(ttl ? { ttl } : {}), + ...(effectiveTtl !== undefined ? { effectiveTtl } : {}), + locked: false, + warnings: [], + } + + try { + await dependencies.assertGitVersion() + const enablement = await dependencies.ensureWorktreeConfigEnabled(gitRoot) + if (enablement.notice) result.notice = enablement.notice + + const sourceBranch = options.sourceBranch ?? (await gitHelper.getMainBranch()) + await dependencies.writeMetadata(resolvedPath, { + kind, + createdAt: options.createdAt ?? new Date().toISOString(), + sourceBranch, + ...(owner ? { owner } : {}), + ...(ttl ? { ttl } : {}), + }) + + if (worktreeConfig.autoLockActive && hasActiveSession) { + // Locking is best-effort: a lock failure must not skip the remaining + // lifecycle setup (port allocation / DB name) below. + try { + const reason = owner ? `pando: active session ${owner}` : 'pando: active session' + await gitHelper.lockWorktree(resolvedPath, reason) + result.locked = true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + result.warnings.push(`Could not auto-lock worktree: ${message}`) + } + } + + if (portsConfig.enabled) { + try { + const allocatedPorts = await dependencies.allocate(resolvedPath, { + range: portsConfig.range, + names: portsConfig.names, + mainRepoPath, + }) + result.ports = allocatedPorts + + const skippedNames = portsConfig.names.filter( + (name) => !Object.hasOwn(allocatedPorts, name) + ) + if (skippedNames.length > 0) { + result.warnings.push( + `Could not allocate requested port(s) for ${skippedNames.join(', ')} in range ${portsConfig.range}` + ) + } + + if (portsConfig.dbStrategy === 'named') { + const databaseBranch = options.worktreeBranch ?? basename(resolvedPath) + const dbName = deriveDbName(portsConfig.dbBaseName, databaseBranch) + result.dbName = dbName + await dependencies.writeMetadata(resolvedPath, { dbName }) + } + } catch (error) { + // Resource setup cannot invalidate a worktree that was already created successfully. + const reason = error instanceof Error ? error.message : String(error) + result.warnings.push(`Could not allocate worktree ports or database name: ${reason}`) + } + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + result.warnings.push(`Could not fully initialize worktree lifecycle metadata: ${reason}`) + } + + return result +} + +interface CreatedWorktreeInfo { + path: string + branch: string | null + commit: string + rebased?: boolean + rebaseSourceBranch?: string + sourceBranch?: string +} + +interface AddWorktreeOutput { + path: string + branch: string | null + commit: string + rebased: boolean + rebaseSourceBranch: string | null + kind: WorktreeKind + owner?: string + ttl?: string + effectiveTtl?: string + ports?: Record + dbName?: string + locked: boolean +} + +interface AddOutputContext { + warnings: string[] + setupWarnings: string[] + worktreeInfo?: CreatedWorktreeInfo + lifecycle?: AddLifecycleResult +} + +function buildWorktreeOutput( + worktreeInfo: CreatedWorktreeInfo, + lifecycle: AddLifecycleResult +): AddWorktreeOutput { + return { + path: worktreeInfo.path, + branch: worktreeInfo.branch, + commit: worktreeInfo.commit, + rebased: worktreeInfo.rebased || false, + rebaseSourceBranch: worktreeInfo.rebaseSourceBranch || null, + 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, + } +} + +function lifecycleWarnings(lifecycle?: AddLifecycleResult): string[] { + if (!lifecycle) return [] + return [...(lifecycle.notice ? [lifecycle.notice] : []), ...lifecycle.warnings] +} + /** * Add a new git worktree * @@ -69,6 +292,28 @@ export default class AddWorktree extends Command { default: false, }), + // Lifecycle flags + 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', + 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 control flags 'skip-rsync': Flags.boolean({ description: 'Skip rsync operation (ignore config)', @@ -107,6 +352,7 @@ export default class AddWorktree extends Command { async run(): Promise { const { flags, args } = await this.parse(AddWorktree) + const outputContext: AddOutputContext = { warnings: [], setupWarnings: [] } // Use positional arg as branch if --branch is not provided if (args.branch && !flags.branch) { @@ -118,12 +364,11 @@ export default class AddWorktree extends Command { if (flags.branch) { const branchValidation = validateBranchName(flags.branch) if (!branchValidation.valid) { - ErrorHelper.validation( - this, + this.failValidation( `Invalid branch name '${flags.branch}': ${branchValidation.reason}`, - flags.json + flags.json, + outputContext.warnings ) - return } } @@ -136,10 +381,10 @@ export default class AddWorktree extends Command { const gitHelper = createGitHelper() const isRepo = await gitHelper.isRepository() if (!isRepo) { - ErrorHelper.validation( - this, + this.failValidation( 'Not a git repository. Run this command from within a git repository.', - flags.json + flags.json, + outputContext.warnings ) } @@ -147,8 +392,10 @@ export default class AddWorktree extends Command { const config = await this.loadAndMergeConfig( flags as Record, gitHelper, - spinner + spinner, + outputContext.warnings ) + gitHelper.setRetryConfig(config.concurrency.retry) // Get git root for path resolution const gitRoot = await gitHelper.getRepositoryRoot() @@ -158,7 +405,8 @@ export default class AddWorktree extends Command { flags as Record, spinner, config, - gitRoot + gitRoot, + outputContext.warnings ) const worktreeInfo = await this.createWorktree( @@ -166,8 +414,11 @@ export default class AddWorktree extends Command { gitHelper, resolvedPath, spinner, - config + config, + outputContext.warnings ) + outputContext.worktreeInfo = worktreeInfo + const setupResult = await this.runSetup( flags as Record, config, @@ -175,26 +426,89 @@ export default class AddWorktree extends Command { resolvedPath, spinner ) + outputContext.setupWarnings = setupResult.warnings + + // A linked worktree root can still enumerate peers if discovering the main path fails. + 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, + sourceBranch: worktreeInfo.sourceBranch, + worktreeBranch: worktreeInfo.branch, + }) + outputContext.lifecycle = lifecycle + if (!flags.json) { + 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, config, worktreeInfo, resolvedPath, - spinner + spinner, + lifecycle.kind, + lifecycle.effectiveTtl, + lifecycle.ports, + lifecycle.dbName, + outputContext.warnings ) this.formatOutput( flags as Record, worktreeInfo, setupResult, + lifecycle, postCommandResults, Date.now() - startTime, - chalk + chalk, + outputContext.warnings ) } catch (error) { - await this.handleError(error, flags as Record, chalk, spinner) + await this.handleError(error, flags as Record, chalk, spinner, outputContext) } } + 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 failOperation(error: Error, context: string, isJson: boolean, warnings: string[]): never { + if (!isJson) return ErrorHelper.operation(this, error, context, false) + + this.log( + JSON.stringify( + { + success: false, + error: `${context}: ${error.message}`, + context, + details: error.message, + warnings, + }, + null, + 2 + ) + ) + this.exit(1) + } + /** * Initialize UI components (spinner and chalk) */ @@ -216,7 +530,8 @@ export default class AddWorktree extends Command { flags: Record, spinner: Awaited> | null, config: Awaited>, - gitRoot: string + gitRoot: string, + warnings: string[] = [] ): Promise<{ gitHelper: ReturnType; resolvedPath: string }> { if (spinner) { spinner.start('Validating path...') @@ -234,11 +549,7 @@ export default class AddWorktree extends Command { const fs = await import('fs-extra') // Validate: require either --branch or --path (or both) if (!branchArg && !pathArg) { - ErrorHelper.validation( - this, - 'Either --branch or --path is required.', - flags.json as boolean | undefined - ) + this.failValidation('Either --branch or --path is required.', Boolean(flags.json), warnings) } const path = await import('path') @@ -261,10 +572,10 @@ export default class AddWorktree extends Command { } } else { // No path flag and no usable config default - ErrorHelper.validation( - this, + this.failValidation( 'Path is required. Provide --path flag or set worktree.defaultPath in config.', - flags.json as boolean | undefined + Boolean(flags.json), + warnings ) } @@ -274,11 +585,7 @@ export default class AddWorktree extends Command { : path.resolve(gitRoot, worktreePath) if (await fs.pathExists(resolvedPath)) { - ErrorHelper.validation( - this, - `Path already exists: ${resolvedPath}`, - flags.json as boolean | undefined - ) + this.failValidation(`Path already exists: ${resolvedPath}`, Boolean(flags.json), warnings) } // Ensure parent directory exists (needed for useProjectSubfolder and nested defaultPath) @@ -286,15 +593,15 @@ export default class AddWorktree extends Command { // Validate force flag requires branch if (flags.force && !branchArg) { - ErrorHelper.validation( - this, + this.failValidation( 'The --force flag requires --branch to be specified.\n\n' + 'The --force flag resets an existing branch to a new commit.\n' + 'Without a branch name, there is nothing to force-reset.\n\n' + 'Options:\n' + ' • Add --branch to specify the branch to reset\n' + ' • Remove --force if creating a new worktree without resetting', - flags.json as boolean | undefined + Boolean(flags.json), + warnings ) } @@ -303,14 +610,14 @@ export default class AddWorktree extends Command { // Check if branch already exists const branchExists = await gitHelper.branchExists(branchArg) if (branchExists) { - ErrorHelper.validation( - this, + this.failValidation( `Branch '${branchArg}' already exists.\n\n` + 'Options:\n' + ` • Use --force to reset '${branchArg}' to commit ${commitArg.substring(0, 7)}\n` + ' • Choose a different branch name with --branch \n' + ' • Omit --branch to checkout the commit in detached HEAD state', - flags.json as boolean | undefined + Boolean(flags.json), + warnings ) } } @@ -324,7 +631,8 @@ export default class AddWorktree extends Command { private async loadAndMergeConfig( flags: Record, gitHelper: ReturnType, - spinner: Awaited> | null + spinner: Awaited> | null, + warnings: string[] = [] ): Promise>> { if (spinner) { spinner.text = 'Loading configuration...' @@ -344,10 +652,10 @@ export default class AddWorktree extends Command { config.rsync.enabled = false // Warn if rsync-specific flags were provided alongside --skip-rsync if (flags['rsync-flags'] || flags['rsync-exclude']) { - ErrorHelper.warn( - this, + this.emitWarning( '--rsync-flags and --rsync-exclude are ignored when --skip-rsync is set', - flags.json as boolean | undefined + Boolean(flags.json), + warnings ) } } @@ -372,6 +680,11 @@ export default class AddWorktree extends Command { 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 + } return config } @@ -384,14 +697,9 @@ export default class AddWorktree extends Command { gitHelper: ReturnType, resolvedPath: string, spinner: Awaited> | null, - config: Awaited> - ): Promise<{ - path: string - branch: string | null - commit: string - rebased?: boolean - rebaseSourceBranch?: string - }> { + config: Awaited>, + warnings: string[] = [] + ): Promise { if (spinner) { spinner.text = 'Creating worktree...' } @@ -414,12 +722,7 @@ export default class AddWorktree extends Command { skipPostCreate: true, }) } catch (error) { - ErrorHelper.operation( - this, - error as Error, - 'Failed to create worktree', - flags.json as boolean | undefined - ) + this.failOperation(error as Error, 'Failed to create worktree', Boolean(flags.json), warnings) } // Determine if we should rebase @@ -446,10 +749,10 @@ export default class AddWorktree extends Command { worktreeResult.commit = newCommit.trim() } else { // Warn but don't fail - ErrorHelper.warn( - this, + this.emitWarning( `Failed to rebase ${worktreeResult.branch} onto ${sourceBranch}. You may need to rebase manually.`, - flags.json as boolean | undefined + Boolean(flags.json), + warnings ) } } @@ -460,6 +763,7 @@ export default class AddWorktree extends Command { commit: worktreeResult.commit, rebased, rebaseSourceBranch: rebased ? sourceBranch! : undefined, + sourceBranch: sourceBranch ?? undefined, } } @@ -489,7 +793,11 @@ export default class AddWorktree extends Command { // Register a SIGINT (Ctrl+C) handler so an interruption mid-setup triggers // the same transactional rollback as a thrown error (otherwise the signal // would bypass the catch block and leave a partial worktree behind). - const interruptHandler = this.createSetupInterruptHandler(orchestrator, spinner) + const interruptHandler = this.createSetupInterruptHandler( + orchestrator, + spinner, + Boolean(flags.json) + ) // Node's signal listeners are `() => void`; wrap the async handler in a // void-returning fire-and-forget listener (it ends in process.exit anyway). const sigintListener = (): void => { @@ -535,7 +843,8 @@ export default class AddWorktree extends Command { */ createSetupInterruptHandler( orchestrator: ReturnType, - spinner: Awaited> | null + spinner: Awaited> | null, + isJson = false ): () => Promise { return async (): Promise => { if (spinner) { @@ -547,7 +856,9 @@ export default class AddWorktree extends Command { // Rollback already swallows its own errors and returns warnings; this // guard only protects against unexpected throws so we still exit 130. } - this.log('\nInterrupted — rolled back') + if (!isJson) { + this.log('\nInterrupted — rolled back') + } // 130 = 128 + SIGINT(2), the conventional exit code for Ctrl+C. process.exit(130) } @@ -611,7 +922,12 @@ export default class AddWorktree extends Command { commit: string }, resolvedPath: string, - spinner: Awaited> | null + spinner: Awaited> | null, + kind: WorktreeKind, + ttl?: string, + ports?: Record, + dbName?: string, + warnings: string[] = [] ): Promise { const scripts = normalizePostCommandScripts(config, 'add') @@ -630,7 +946,8 @@ export default class AddWorktree extends Command { config.postCommandsSourcePath, scripts, isJson, - spinner + spinner, + warnings ) if (!allowed) { return [] @@ -646,6 +963,10 @@ export default class AddWorktree extends Command { worktreePath: worktreeInfo.path, branch: worktreeInfo.branch, commit: worktreeInfo.commit, + kind, + ttl, + ...(ports ? { ports } : {}), + ...(dbName ? { dbName } : {}), }) } @@ -659,7 +980,8 @@ export default class AddWorktree extends Command { sourcePath: string | undefined, scripts: Array<{ name?: string; command: string }>, isJson: boolean, - spinner: Awaited> | null + spinner: Awaited> | null, + warnings: string[] = [] ): Promise { const envTrust = isEnvTrustEnabled(process.env.PANDO_TRUST_CONFIG) @@ -692,14 +1014,14 @@ export default class AddWorktree extends Command { } if (decision === 'skip') { - ErrorHelper.warn( - this, + 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 + isJson, + warnings ) return false } @@ -716,16 +1038,18 @@ export default class AddWorktree extends Command { spinner.stop() } - 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}`) + 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('') } - this.log('') const { confirm } = await import('@inquirer/prompts') const approved = await confirm({ @@ -734,10 +1058,10 @@ export default class AddWorktree extends Command { }) if (!approved) { - ErrorHelper.warn( - this, + this.emitWarning( `Skipped ${scripts.length} post-command script(s); config file not trusted.`, - isJson + isJson, + warnings ) return false } @@ -750,10 +1074,10 @@ export default class AddWorktree extends Command { } catch { // Non-fatal: failing to persist trust just means we'll prompt again // next time. Still allow this run since the user approved it. - ErrorHelper.warn( - this, + this.emitWarning( 'Could not persist trust decision; will prompt again next time.', - isJson + isJson, + warnings ) } } @@ -770,33 +1094,24 @@ export default class AddWorktree extends Command { */ private formatOutput( flags: Record, - worktreeInfo: { - path: string - branch: string | null - commit: string - rebased?: boolean - rebaseSourceBranch?: string - }, + worktreeInfo: CreatedWorktreeInfo, setupResult: Awaited< ReturnType['setupNewWorktree']> > & { details: AddCommandDetails }, + lifecycle: AddLifecycleResult, postCommandResults: PostCommandResult[], duration: number, - chalk: Awaited | null + chalk: Awaited | null, + warnings: string[] = [] ): void { if (flags.json) { - // JSON output + const worktreeOutput = buildWorktreeOutput(worktreeInfo, lifecycle) + this.log( JSON.stringify( { success: true, - worktree: { - path: worktreeInfo.path, - branch: worktreeInfo.branch, - commit: worktreeInfo.commit, - rebased: worktreeInfo.rebased || false, - rebaseSourceBranch: worktreeInfo.rebaseSourceBranch || null, - }, + worktree: worktreeOutput, setup: { rsync: setupResult.rsyncResult ? { @@ -824,7 +1139,7 @@ export default class AddWorktree extends Command { }, postCommands: postCommandResults, duration, - warnings: setupResult.warnings, + warnings: [...setupResult.warnings, ...warnings, ...lifecycleWarnings(lifecycle)], ...(flags.details ? { details: setupResult.details } : {}), }, null, @@ -847,6 +1162,23 @@ export default class AddWorktree extends Command { output.push(chalk.gray(` Branch: ${branchInfo}`)) } output.push(chalk.gray(` Commit: ${worktreeInfo.commit.substring(0, 7)}`)) + const lifecycleStatus = [ + ...(lifecycle.locked ? ['locked'] : []), + ...(lifecycle.owner ? [`owner ${lifecycle.owner}`] : []), + ...(lifecycle.ttl ? [`ttl ${lifecycle.ttl}`] : []), + ] + output.push( + chalk.gray( + ` Kind: ${lifecycle.kind}${lifecycleStatus.length > 0 ? ` (${lifecycleStatus.join(', ')})` : ''}` + ) + ) + const resourceStatus = [ + ...Object.entries(lifecycle.ports ?? {}).map(([name, port]) => `${name}=${port}`), + ...(lifecycle.dbName ? [`db=${lifecycle.dbName}`] : []), + ] + if (resourceStatus.length > 0) { + output.push(chalk.gray(` Resources: ${resourceStatus.join(', ')}`)) + } output.push('') // Rsync results @@ -972,12 +1304,19 @@ export default class AddWorktree extends Command { error: unknown, flags: Record, chalk: Awaited | null, - spinner: Awaited> | null + spinner: Awaited> | null, + outputContext: AddOutputContext = { warnings: [], setupWarnings: [] } ): Promise { if (isOclifExitError(error)) { throw error } + const warnings = [ + ...outputContext.setupWarnings, + ...outputContext.warnings, + ...lifecycleWarnings(outputContext.lifecycle), + ] + if (spinner) { spinner.fail('Failed') } @@ -989,8 +1328,17 @@ export default class AddWorktree extends Command { { success: false, error: error.message, + ...(outputContext.worktreeInfo && outputContext.lifecycle + ? { + worktree: buildWorktreeOutput( + outputContext.worktreeInfo, + outputContext.lifecycle + ), + } + : {}), postCommands: error.results, failedPostCommand: error.result, + warnings, }, null, 2 @@ -1034,7 +1382,7 @@ export default class AddWorktree extends Command { success: false, error: setupError.message, rolledBack: result.rolledBack, - warnings: result.warnings, + warnings: [...result.warnings, ...warnings], duration: result.duration, symlinkConflicts: result.symlinkResult?.conflicts || [], }, @@ -1079,6 +1427,7 @@ export default class AddWorktree extends Command { success: false, error: 'rsync is not installed', hint: 'Install rsync or use --skip-rsync flag', + warnings, }, null, 2 @@ -1111,6 +1460,7 @@ export default class AddWorktree extends Command { success: false, error: 'symlink conflicts', conflicts: error.conflicts, + warnings, }, null, 2 @@ -1142,6 +1492,7 @@ export default class AddWorktree extends Command { { success: false, error: error instanceof Error ? error.message : String(error), + warnings, }, null, 2 diff --git a/src/commands/clean.ts b/src/commands/clean.ts index ab77288..2503218 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -294,6 +294,7 @@ export default class CleanWorktree extends Command { // 2. Load config const gitRoot = await gitHelper.getRepositoryRoot() const config = await loadConfig({ gitRoot }) + gitHelper.setRetryConfig(config.concurrency.retry) // Apply config with flag precedence (flag > config > default) const shouldFetch = flags.fetch || config.clean.fetch diff --git a/src/commands/config/init.ts b/src/commands/config/init.ts index e11e6da..add14cd 100644 --- a/src/commands/config/init.ts +++ b/src/commands/config/init.ts @@ -279,6 +279,9 @@ export default class ConfigInit extends Command { symlink: { ...DEFAULT_CONFIG.symlink }, worktree: { ...DEFAULT_CONFIG.worktree }, clean: { ...DEFAULT_CONFIG.clean }, + reap: { ...DEFAULT_CONFIG.reap }, + concurrency: { retry: { ...DEFAULT_CONFIG.concurrency.retry } }, + ports: { ...DEFAULT_CONFIG.ports }, postCommands: { ...DEFAULT_CONFIG.postCommands }, } @@ -380,6 +383,33 @@ export default class ConfigInit extends Command { value: DEFAULT_CONFIG.worktree.targetBranch, }) } + + if (existing.worktree.defaultKind !== undefined) { + merged.worktree.defaultKind = existing.worktree.defaultKind + } else { + added.push({ + path: 'worktree.defaultKind', + value: DEFAULT_CONFIG.worktree.defaultKind, + }) + } + + if (existing.worktree.ephemeralTtl !== undefined) { + merged.worktree.ephemeralTtl = existing.worktree.ephemeralTtl + } else { + added.push({ + path: 'worktree.ephemeralTtl', + value: DEFAULT_CONFIG.worktree.ephemeralTtl, + }) + } + + if (existing.worktree.autoLockActive !== undefined) { + merged.worktree.autoLockActive = existing.worktree.autoLockActive + } else { + added.push({ + path: 'worktree.autoLockActive', + value: DEFAULT_CONFIG.worktree.autoLockActive, + }) + } } else { added.push({ path: 'worktree', value: DEFAULT_CONFIG.worktree }) } @@ -395,6 +425,88 @@ export default class ConfigInit extends Command { added.push({ path: 'clean', value: DEFAULT_CONFIG.clean }) } + // Merge reap section + if (existing.reap) { + if (existing.reap.requireMerged !== undefined) { + merged.reap.requireMerged = existing.reap.requireMerged + } else { + added.push({ path: 'reap.requireMerged', value: DEFAULT_CONFIG.reap.requireMerged }) + } + } else { + added.push({ path: 'reap', value: DEFAULT_CONFIG.reap }) + } + + // Merge concurrency section + if (existing.concurrency) { + if (existing.concurrency.retry) { + if (existing.concurrency.retry.maxAttempts !== undefined) { + merged.concurrency.retry.maxAttempts = existing.concurrency.retry.maxAttempts + } else { + added.push({ + path: 'concurrency.retry.maxAttempts', + value: DEFAULT_CONFIG.concurrency.retry.maxAttempts, + }) + } + + if (existing.concurrency.retry.baseMs !== undefined) { + merged.concurrency.retry.baseMs = existing.concurrency.retry.baseMs + } else { + added.push({ + path: 'concurrency.retry.baseMs', + value: DEFAULT_CONFIG.concurrency.retry.baseMs, + }) + } + + if (existing.concurrency.retry.capMs !== undefined) { + merged.concurrency.retry.capMs = existing.concurrency.retry.capMs + } else { + added.push({ + path: 'concurrency.retry.capMs', + value: DEFAULT_CONFIG.concurrency.retry.capMs, + }) + } + } else { + added.push({ path: 'concurrency.retry', value: DEFAULT_CONFIG.concurrency.retry }) + } + } else { + added.push({ path: 'concurrency', value: DEFAULT_CONFIG.concurrency }) + } + + // Merge ports section + if (existing.ports) { + if (existing.ports.enabled !== undefined) { + merged.ports.enabled = existing.ports.enabled + } else { + added.push({ path: 'ports.enabled', value: DEFAULT_CONFIG.ports.enabled }) + } + + if (existing.ports.range !== undefined) { + merged.ports.range = existing.ports.range + } else { + added.push({ path: 'ports.range', value: DEFAULT_CONFIG.ports.range }) + } + + if (existing.ports.names !== undefined) { + merged.ports.names = existing.ports.names + } else { + added.push({ path: 'ports.names', value: DEFAULT_CONFIG.ports.names }) + } + + if (existing.ports.dbStrategy !== undefined) { + merged.ports.dbStrategy = existing.ports.dbStrategy + } else { + added.push({ path: 'ports.dbStrategy', value: DEFAULT_CONFIG.ports.dbStrategy }) + } + + if (existing.ports.dbBaseName !== undefined) { + merged.ports.dbBaseName = existing.ports.dbBaseName + } else { + added.push({ path: 'ports.dbBaseName', value: DEFAULT_CONFIG.ports.dbBaseName }) + } + } else { + added.push({ path: 'ports', value: DEFAULT_CONFIG.ports }) + } + if (existing.postCommands) { merged.postCommands = existing.postCommands } else { @@ -456,6 +568,12 @@ export default class ConfigInit extends Command { '# remote: Delete both local and remote branches', '# targetBranch - Target branch for merge checks (used by clean command)', '# Default: "main"', + '# defaultKind - Lifecycle kind for new worktrees: auto, ephemeral, or long-lived', + '# Default: "auto"', + '# ephemeralTtl - Time-to-live assigned to ephemeral worktrees', + '# Default: "4h"', + '# autoLockActive - Automatically lock active worktrees against reaping', + '# Default: true', '', ].join('\n') @@ -468,6 +586,37 @@ export default class ConfigInit extends Command { '', ].join('\n') + const reapComment = [ + '', + '# Reap Configuration', + '# Controls safeguards applied while reaping worktrees', + '# requireMerged - Require a worktree branch to be merged before reaping it', + '# Default: true', + '', + ].join('\n') + + const concurrencyRetryComment = [ + '', + '# Concurrency Retry Configuration', + '# Controls retry backoff for concurrent lifecycle operations', + '# maxAttempts - Maximum number of attempts before failing (default: 5)', + '# baseMs - Initial retry delay in milliseconds (default: 100)', + '# capMs - Maximum retry delay in milliseconds (default: 2000)', + '', + ].join('\n') + + const portsComment = [ + '', + '# Port Allocation Configuration', + '# Controls deterministic port and database allocation for worktrees', + '# enabled - Enable port allocation (default: false)', + '# range - Inclusive port range available for allocation (default: "3100-3199")', + '# names - Logical service names that receive allocated ports (default: ["web"])', + '# dbStrategy - Database naming strategy (default: "named")', + '# dbBaseName - Base name used to derive worktree database names (default: "dev")', + '', + ].join('\n') + const postCommandsComment = [ '', '# Post-command scripts', @@ -484,6 +633,9 @@ export default class ConfigInit extends Command { result = result.replace('[symlink]', symlinkComment + '[symlink]') result = result.replace('[worktree]', worktreeComment + '[worktree]') result = result.replace('[clean]', cleanComment + '[clean]') + result = result.replace('[reap]', reapComment + '[reap]') + result = result.replace('[concurrency.retry]', concurrencyRetryComment + '[concurrency.retry]') + result = result.replace('[ports]', portsComment + '[ports]') result = result.replace('[postCommands]', postCommandsComment + '[postCommands]') return result } diff --git a/src/commands/health.ts b/src/commands/health.ts index cccd893..1954bb7 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -2,6 +2,7 @@ import { Command } from '@oclif/core' import { createGitHelper } from '../utils/git.js' import { jsonFlag } from '../utils/common-flags.js' import { ErrorHelper } from '../utils/errors.js' +import { readMetadata, type WorktreeMetadata } from '../utils/worktreeMetadata.js' /** * Worktree health check status @@ -10,6 +11,11 @@ interface WorktreeHealth { path: string branch: string | null status: 'clean' | 'detached' | 'uncommitted' | 'behind' | 'gone' | 'error' + kind: WorktreeMetadata['kind'] | null + ageMs: number + ttl: string | null + locked: boolean + owner: string | null message?: string details?: { uncommittedFiles?: number @@ -88,10 +94,17 @@ export default class Health extends Command { const healthResults: WorktreeHealth[] = [] for (const worktree of worktrees) { - let health: WorktreeHealth = { + const metadata = await readMetadata(worktree.path) + const ageMs = await gitHelper.getWorktreeAgeMs(worktree.path, metadata) + const health: WorktreeHealth = { path: worktree.path, branch: worktree.branch, status: 'clean', + kind: metadata.kind ?? null, + ageMs, + ttl: metadata.ttl ?? null, + locked: Boolean(worktree.isLocked), + owner: metadata.owner ?? null, } // Skip detached HEAD worktrees for deep checks @@ -243,6 +256,9 @@ export default class Health extends Command { if (item.branch) { this.log(` ${chalk.gray(`Branch: ${item.branch}`)}`) } + this.log( + ` ${chalk.gray(`Lifecycle: ${item.kind ?? 'unknown'}, ${item.locked ? 'locked' : 'unlocked'}`)}` + ) if (item.message) { this.log(` ${section.color(item.message)}`) } diff --git a/src/commands/list.ts b/src/commands/list.ts index e5c6780..7a65cf5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,7 +1,48 @@ import { Command, Flags } from '@oclif/core' -import { createGitHelper } from '../utils/git.js' +import { createGitHelper, type WorktreeInfo } from '../utils/git.js' import { jsonFlag } from '../utils/common-flags.js' import { ErrorHelper } from '../utils/errors.js' +import { readMetadata, type WorktreeMetadata } from '../utils/worktreeMetadata.js' + +interface LifecycleWorktreeInfo extends Omit { + kind: WorktreeMetadata['kind'] | null + createdAt: string | null + owner: string | null + ttl: string | null + ageMs: number + locked: boolean +} + +function formatAge(ageMs: number): string { + const seconds = Math.floor(ageMs / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + return `${Math.floor(hours / 24)}d` +} + +async function addLifecycleDetails( + worktrees: WorktreeInfo[], + gitHelper: ReturnType +): Promise { + return Promise.all( + worktrees.map(async ({ isLocked, ...worktree }) => { + const metadata = await readMetadata(worktree.path) + const ageMs = await gitHelper.getWorktreeAgeMs(worktree.path, metadata) + return { + ...worktree, + kind: metadata.kind ?? null, + createdAt: metadata.createdAt ?? null, + owner: metadata.owner ?? null, + ttl: metadata.ttl ?? null, + ageMs, + locked: Boolean(isLocked), + } + }) + ) +} /** * List all git worktrees @@ -57,16 +98,18 @@ export default class ListWorktree extends Command { return } + const worktreesWithLifecycle = await addLifecycleDetails(worktrees, gitHelper) + // 4-5. Format and output based on flags if (flags.json) { // JSON output - this.log(JSON.stringify({ worktrees }, null, 2)) + this.log(JSON.stringify({ worktrees: worktreesWithLifecycle }, null, 2)) } else { // Human-readable output with chalk const chalk = (await import('chalk')).default - this.log(chalk.bold(`Found ${worktrees.length} worktree(s):\n`)) + this.log(chalk.bold(`Found ${worktreesWithLifecycle.length} worktree(s):\n`)) - for (const worktree of worktrees) { + for (const worktree of worktreesWithLifecycle) { // Path (always show) this.log(chalk.cyan(` ${worktree.path}`)) @@ -80,6 +123,11 @@ export default class ListWorktree extends Command { // Verbose mode: show commit hash and prunable status if (flags.verbose) { this.log(chalk.gray(` Commit: ${worktree.commit}`)) + this.log( + chalk.gray( + ` Lifecycle: kind ${worktree.kind ?? 'unknown'}, owner ${worktree.owner ?? '-'}, age ${formatAge(worktree.ageMs)}, ${worktree.locked ? 'locked' : 'unlocked'}` + ) + ) if (worktree.isPrunable) { this.log(chalk.red(` Status: prunable (directory deleted)`)) } diff --git a/src/commands/lock.ts b/src/commands/lock.ts new file mode 100644 index 0000000..6883c5e --- /dev/null +++ b/src/commands/lock.ts @@ -0,0 +1,112 @@ +import { Args, Command, Flags } from '@oclif/core' +import { realpathSync } from 'node:fs' +import path from 'node:path' +import { jsonFlag, pathFlag } from '../utils/common-flags.js' +import { ErrorHelper, isOclifExitError } from '../utils/errors.js' +import { createGitHelper, type WorktreeInfo } from '../utils/git.js' + +function canonicalizePath(targetPath: string): string { + const resolvedPath = path.resolve(targetPath) + try { + return realpathSync.native(resolvedPath) + } catch { + return resolvedPath + } +} + +export function resolveLockTarget( + target: string, + worktrees: WorktreeInfo[], + repositoryRoot: string, + cwd = process.cwd() +): WorktreeInfo | null { + const possiblePaths = new Set([ + canonicalizePath(path.resolve(cwd, target)), + canonicalizePath(path.resolve(repositoryRoot, target)), + ]) + const pathMatch = worktrees.find((worktree) => possiblePaths.has(canonicalizePath(worktree.path))) + if (pathMatch) return pathMatch + + return worktrees.find((worktree) => worktree.branch === target) ?? null +} + +export default class LockWorktree extends Command { + static description = 'Lock a Git worktree against pruning or moving' + + static examples = [ + '<%= config.bin %> <%= command.id %> ../feature-x', + '<%= config.bin %> <%= command.id %> feature/x --reason "active session"', + '<%= config.bin %> <%= command.id %> --path ../feature-x --json', + ] + + static args = { + worktree: Args.string({ + description: 'Worktree path or branch name', + required: false, + }), + } + + static flags = { + path: pathFlag, + reason: Flags.string({ + description: 'Reason recorded with the Git worktree lock', + }), + json: jsonFlag, + } + + async run(): Promise { + const { args, flags } = await this.parse(LockWorktree) + + try { + if (flags.path !== undefined && args.worktree !== undefined) { + ErrorHelper.validation(this, 'Specify a worktree argument or --path, not both.', flags.json) + } + + const target = flags.path ?? args.worktree + if (target === undefined || target.trim() === '') { + ErrorHelper.validation(this, 'A worktree path or branch name is required.', flags.json) + } + + const gitHelper = createGitHelper() + if (!(await gitHelper.isRepository())) { + ErrorHelper.validation(this, 'Not a git repository', flags.json) + } + + const repositoryRoot = await gitHelper.getRepositoryRoot() + const worktrees = await gitHelper.listWorktrees() + const worktree = resolveLockTarget(target, worktrees, repositoryRoot) + if (!worktree) { + ErrorHelper.validation(this, `Worktree not found: ${target}`, flags.json) + } + + await gitHelper.lockWorktree(worktree.path, flags.reason) + + if (flags.json) { + this.log( + JSON.stringify( + { + success: true, + path: worktree.path, + branch: worktree.branch, + reason: flags.reason ?? null, + locked: true, + }, + null, + 2 + ) + ) + } else { + const chalk = (await import('chalk')).default + this.log(chalk.green(`✓ Locked worktree ${worktree.path}`)) + } + } catch (error) { + if (isOclifExitError(error)) throw error + ErrorHelper.operation( + this, + error instanceof Error ? error : new Error(String(error)), + 'Failed to lock worktree', + flags.json + ) + } + } +} diff --git a/src/commands/reap.ts b/src/commands/reap.ts new file mode 100644 index 0000000..1fb60b1 --- /dev/null +++ b/src/commands/reap.ts @@ -0,0 +1,422 @@ +import { Command, Flags } from '@oclif/core' +import { confirm } from '@inquirer/prompts' +import path from 'node:path' +import { simpleGit } from 'simple-git' +import { loadConfig } from '../config/loader.js' +import type { PandoConfig } from '../config/schema.js' +import { forceFlag, jsonFlag } from '../utils/common-flags.js' +import { ErrorHelper, isOclifExitError } from '../utils/errors.js' +import { createGitHelper, type GitHelper } from '../utils/git.js' +import { enumerateAll, type WorktreeMetadata } from '../utils/worktreeMetadata.js' + +export interface ReapSelectionWorktree { + worktreePath: string + metadata: WorktreeMetadata + branch?: string | null + isLocked?: boolean + /** Supplying age directly lets callers retain GitHelper's metadata/mtime fallback. */ + ageMs?: number +} + +export interface ReapCandidate { + path: string + branch: string | null + kind: 'ephemeral' + ageMs: number +} + +export interface ReapSkipped { + path: string + reason: string +} + +interface ReapError { + path: string + error: string +} + +interface ReapResult { + status: 'success' | 'error' | 'nothing_to_reap' | 'cancelled' + dryRun: boolean + reapable: ReapCandidate[] + reaped: ReapCandidate[] + skipped: ReapSkipped[] + errors: ReapError[] + warnings: string[] +} + +export interface ReapSelectionOptions { + now: number + config: { worktree: Pick } + owner?: string +} + +interface ReapCleanOptions { + targetBranch: string + requireMerged: boolean +} + +interface ReapCleanDependencies { + hasUncommittedChanges(path: string): Promise + isReapClean(path: string, targetBranch: string): Promise +} + +/** Parse a deliberately small, predictable TTL syntax into milliseconds. */ +export function parseTtl(ttl: string): number | null { + const match = /^(\d+(?:\.\d+)?)([smhd])?$/i.exec(ttl.trim()) + if (!match) return null + + const amountText = match[1] + if (amountText === undefined) return null + + const amount = Number(amountText) + const suffix = match[2]?.toLowerCase() + const multiplier = + suffix === 's' + ? 1000 + : suffix === 'm' + ? 60_000 + : suffix === 'h' + ? 3_600_000 + : suffix === 'd' + ? 86_400_000 + : 1 + const milliseconds = amount * multiplier + + return Number.isFinite(milliseconds) ? milliseconds : null +} + +/** + * Select only lifecycle-expired worktrees. Safety-related exclusions are kept + * out of the result so callers cannot accidentally reinterpret them as skipped + * cleanup work. + */ +export function selectReapable( + worktrees: ReapSelectionWorktree[], + options: ReapSelectionOptions +): ReapCandidate[] { + const selected: ReapCandidate[] = [] + + for (const worktree of worktrees) { + const { metadata } = worktree + if (metadata.kind !== 'ephemeral' || worktree.isLocked) continue + if (options.owner !== undefined && metadata.owner !== options.owner) continue + + const effectiveTtl = metadata.ttl ?? options.config.worktree.ephemeralTtl + if (effectiveTtl === undefined) continue + const ttlMs = parseTtl(effectiveTtl) + if (ttlMs === null) continue + + let ageMs = worktree.ageMs + if (ageMs === undefined && metadata.createdAt !== undefined) { + const createdAtMs = Date.parse(metadata.createdAt) + if (Number.isFinite(createdAtMs)) ageMs = Math.max(0, options.now - createdAtMs) + } + + // Unknown age is treated as new rather than risking premature removal. + if (ageMs === undefined || !Number.isFinite(ageMs) || ageMs <= ttlMs) continue + + selected.push({ + path: worktree.worktreePath, + branch: worktree.branch ?? null, + kind: 'ephemeral', + ageMs, + }) + } + + return selected +} + +/** Partition expired candidates without weakening the dirty-worktree invariant. */ +export async function partitionReapClean( + candidates: ReapCandidate[], + options: ReapCleanOptions, + dependencies: ReapCleanDependencies +): Promise<{ clean: ReapCandidate[]; skipped: ReapSkipped[] }> { + const clean: ReapCandidate[] = [] + const skipped: ReapSkipped[] = [] + + for (const candidate of candidates) { + try { + if (await dependencies.hasUncommittedChanges(candidate.path)) { + skipped.push({ path: candidate.path, reason: 'dirty: has uncommitted changes' }) + continue + } + + if (options.requireMerged) { + const isCleanAndMerged = await dependencies.isReapClean( + candidate.path, + options.targetBranch + ) + if (!isCleanAndMerged) { + skipped.push({ + path: candidate.path, + reason: `unmerged into '${options.targetBranch}' or cleanliness could not be verified`, + }) + continue + } + } + + clean.push(candidate) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + skipped.push({ path: candidate.path, reason: `cleanliness check failed: ${message}` }) + } + } + + return { clean, skipped } +} + +export default class ReapWorktree extends Command { + static description = 'Reclaim expired ephemeral worktrees that are safe to remove' + + static examples = [ + '<%= config.bin %> <%= command.id %> --dry-run', + '<%= config.bin %> <%= command.id %> --force', + '<%= config.bin %> <%= command.id %> --owner session-123', + '<%= config.bin %> <%= command.id %> --json', + ] + + static flags = { + 'dry-run': Flags.boolean({ + description: 'Show what would be reaped without acting', + default: false, + }), + owner: Flags.string({ + description: 'Only reap worktrees owned by this session', + }), + force: forceFlag, + json: jsonFlag, + } + + private emptyResult(dryRun: boolean): ReapResult { + return { + status: 'success', + dryRun, + reapable: [], + reaped: [], + skipped: [], + errors: [], + warnings: [], + } + } + + private async outputResult(result: ReapResult, isJson: boolean): Promise { + const hasError = result.status === 'error' || result.errors.length > 0 + + if (isJson) { + this.log(JSON.stringify(result, null, 2)) + if (hasError) this.exit(1) + return + } + + const chalk = (await import('chalk')).default + + if (result.dryRun && result.reapable.length > 0) { + this.log(chalk.yellow('\nWorktrees that would be reaped:')) + for (const candidate of result.reapable) { + this.log(` ${chalk.cyan(candidate.path)} (${candidate.branch ?? 'detached'})`) + } + } else if (result.reaped.length > 0) { + this.log( + chalk.green( + `\n✓ Reaped ${result.reaped.length} worktree${result.reaped.length === 1 ? '' : 's'}:` + ) + ) + for (const candidate of result.reaped) { + this.log(` ${chalk.cyan(candidate.path)} (${candidate.branch ?? 'detached'})`) + } + } + + if (result.skipped.length > 0) { + this.log(chalk.yellow('\n⚠ Expired worktrees skipped for safety:')) + for (const skipped of result.skipped) { + this.log(` ${chalk.cyan(skipped.path)}: ${skipped.reason}`) + } + } + + for (const warning of result.warnings) this.warn(warning) + + if (result.status === 'nothing_to_reap') { + this.log(chalk.green('No expired ephemeral worktrees are eligible for reaping.')) + } else if (result.status === 'cancelled') { + this.log(chalk.yellow('Reaping cancelled.')) + } + + if (result.errors.length > 0) { + this.log(chalk.red('\n✗ Some worktrees could not be reaped:')) + for (const error of result.errors) { + this.log(` ${chalk.cyan(error.path)}: ${chalk.yellow(error.error)}`) + } + } + + if (hasError) this.exit(1) + } + + private async reapCandidates( + gitHelper: GitHelper, + candidates: ReapCandidate[], + result: ReapResult + ): Promise { + for (const candidate of candidates) { + const currentWorktrees = await gitHelper.listWorktrees() + const currentWorktree = currentWorktrees.find( + (worktree) => path.resolve(worktree.path) === path.resolve(candidate.path) + ) + if (currentWorktree?.isLocked) { + result.skipped.push({ path: candidate.path, reason: 'locked after selection' }) + continue + } + + try { + // Never pass force: Git's own dirty-tree guard protects against races + // between selection and removal. + await gitHelper.removeWorktree(candidate.path) + result.reaped.push(candidate) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + result.errors.push({ path: candidate.path, error: message }) + result.skipped.push({ path: candidate.path, reason: `removal failed: ${message}` }) + continue + } + + if (candidate.branch) { + try { + if (await gitHelper.branchExists(candidate.branch)) { + // A normal delete preserves commits if repository state changed + // after the cleanliness check. + await gitHelper.deleteBranch(candidate.branch) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + result.warnings.push( + `Reaped ${candidate.path}, but kept branch '${candidate.branch}': ${message}` + ) + } + } + } + + if (result.errors.length > 0 && result.reaped.length === 0) result.status = 'error' + } + + async run(): Promise { + const { flags } = await this.parse(ReapWorktree) + const result = this.emptyResult(flags['dry-run'] ?? false) + + try { + const gitHelper = createGitHelper() + if (!(await gitHelper.isRepository())) { + ErrorHelper.validation(this, 'Not a git repository', flags.json) + } + + const gitRoot = await gitHelper.getRepositoryRoot() + const mainWorktreePath = await gitHelper.getMainWorktreePath() + const config = await loadConfig({ gitRoot }) + gitHelper.setRetryConfig(config.concurrency.retry) + const [metadataEntries, listedWorktrees] = await Promise.all([ + enumerateAll(mainWorktreePath), + gitHelper.listWorktrees(), + ]) + const listedByPath = new Map( + listedWorktrees.map((worktree) => [path.resolve(worktree.path), worktree]) + ) + const now = Date.now() + const snapshots: ReapSelectionWorktree[] = [] + + for (const entry of metadataEntries) { + if (path.resolve(entry.worktreePath) === path.resolve(mainWorktreePath)) continue + + const listed = listedByPath.get(path.resolve(entry.worktreePath)) + let ageMs: number + try { + ageMs = await gitHelper.getWorktreeAgeMs(entry.worktreePath) + } catch { + // An unknown age must never make a worktree eligible. + ageMs = 0 + } + + snapshots.push({ + worktreePath: entry.worktreePath, + metadata: entry.metadata, + branch: listed?.branch ?? null, + // A metadata/listing mismatch is repository uncertainty, not proof + // that a worktree is unlocked. + isLocked: listed === undefined ? true : (listed.isLocked ?? false), + ageMs, + }) + } + + const candidates = selectReapable(snapshots, { + now, + config, + ...(flags.owner !== undefined ? { owner: flags.owner } : {}), + }) + const partition = await partitionReapClean( + candidates, + { + targetBranch: config.worktree.targetBranch ?? 'main', + requireMerged: config.reap.requireMerged, + }, + { + // GitHelper's general-purpose status check is intentionally lenient + // on status errors; reaping instead needs an observable failure so + // partitionReapClean can fail closed. + hasUncommittedChanges: async (worktreePath) => { + const status = await simpleGit(worktreePath).status() + return !status.isClean() + }, + isReapClean: (worktreePath, targetBranch) => + gitHelper.isReapClean(worktreePath, targetBranch), + } + ) + result.reapable = partition.clean + result.skipped.push(...partition.skipped) + + if (candidates.length === 0) { + result.status = 'nothing_to_reap' + await this.outputResult(result, flags.json ?? false) + return + } + + if (flags['dry-run']) { + await this.outputResult(result, flags.json ?? false) + return + } + + if (partition.clean.length === 0) { + await this.outputResult(result, flags.json ?? false) + return + } + + if (!flags.force && !flags.json) { + if (!process.stdin.isTTY) { + result.status = 'cancelled' + result.warnings.push('Non-interactive reaping requires --force (or --json).') + await this.outputResult(result, false) + return + } + + const confirmed = await confirm({ + message: `Reap ${partition.clean.length} expired worktree${partition.clean.length === 1 ? '' : 's'}?`, + default: false, + }) + if (!confirmed) { + result.status = 'cancelled' + await this.outputResult(result, false) + return + } + } + + await this.reapCandidates(gitHelper, partition.clean, result) + await this.outputResult(result, flags.json ?? false) + } catch (error) { + if (isOclifExitError(error)) throw error + ErrorHelper.operation( + this, + error instanceof Error ? error : new Error(String(error)), + 'Failed to reap worktrees', + flags.json + ) + } + } +} diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 6cf9969..31f1b66 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -5,6 +5,7 @@ import { jsonFlag, forceFlag, pathFlag } from '../utils/common-flags.js' import { ErrorHelper, isOclifExitError } from '../utils/errors.js' import { loadConfig } from '../config/loader.js' import type { DeleteBranchOption } from '../config/schema.js' +import { readMetadata, type WorktreeMetadata } from '../utils/worktreeMetadata.js' import { checkbox, confirm } from '@inquirer/prompts' /** @@ -248,6 +249,7 @@ export default class RemoveWorktree extends Command { // 2. Load config for branch deletion settings const gitRoot = await gitHelper.getRepositoryRoot() const config = await loadConfig({ gitRoot }) + gitHelper.setRetryConfig(config.concurrency.retry) // Determine delete-branch option (--keep-branch takes precedence, then flag, then config) const deleteBranchOption: DeleteBranchOption = flags['keep-branch'] @@ -318,6 +320,7 @@ export default class RemoveWorktree extends Command { success: boolean error?: string branch?: string | null + metadata?: WorktreeMetadata branchDeletion?: { localDeleted: boolean remoteDeleted: boolean @@ -328,6 +331,7 @@ export default class RemoveWorktree extends Command { }> = [] for (const worktreePath of pathsToRemove) { + let metadata: WorktreeMetadata | undefined try { // Find worktree info const worktree = worktrees.find((w) => w.path === worktreePath) @@ -340,6 +344,9 @@ export default class RemoveWorktree extends Command { continue } + // Git deletes per-worktree config during removal, so retain it for JSON consumers first. + metadata = await readMetadata(worktree.path) + // Check for uncommitted changes (unless --force) let forceRemove = flags.force if (!forceRemove) { @@ -352,6 +359,7 @@ export default class RemoveWorktree extends Command { success: false, error: 'Has uncommitted changes (use --force to remove anyway)', branch: worktree.branch, + metadata, }) continue } @@ -372,6 +380,7 @@ export default class RemoveWorktree extends Command { success: false, error: 'Has uncommitted changes (use --force to remove anyway)', branch: worktree.branch, + metadata, }) continue } @@ -399,6 +408,7 @@ export default class RemoveWorktree extends Command { path: worktreePath, success: true, branch: worktree.branch, + metadata, branchDeletion, }) } catch (error) { @@ -406,6 +416,7 @@ export default class RemoveWorktree extends Command { path: worktreePath, success: false, error: error instanceof Error ? error.message : String(error), + metadata, }) } } @@ -424,6 +435,7 @@ export default class RemoveWorktree extends Command { success: result.success, path: result.path, branch: result.branch, + metadata: result.metadata, forced: flags.force, deleteBranchOption: deleteBranchOption, branchDeletion: result.branchDeletion, diff --git a/src/commands/unlock.ts b/src/commands/unlock.ts new file mode 100644 index 0000000..1588dd0 --- /dev/null +++ b/src/commands/unlock.ts @@ -0,0 +1,108 @@ +import { Args, Command } from '@oclif/core' +import { realpathSync } from 'node:fs' +import path from 'node:path' +import { jsonFlag, pathFlag } from '../utils/common-flags.js' +import { ErrorHelper, isOclifExitError } from '../utils/errors.js' +import { createGitHelper, type WorktreeInfo } from '../utils/git.js' + +function canonicalizePath(targetPath: string): string { + const resolvedPath = path.resolve(targetPath) + try { + return realpathSync.native(resolvedPath) + } catch { + return resolvedPath + } +} + +export function resolveUnlockTarget( + target: string, + worktrees: WorktreeInfo[], + repositoryRoot: string, + cwd = process.cwd() +): WorktreeInfo | null { + const possiblePaths = new Set([ + canonicalizePath(path.resolve(cwd, target)), + canonicalizePath(path.resolve(repositoryRoot, target)), + ]) + const pathMatch = worktrees.find((worktree) => possiblePaths.has(canonicalizePath(worktree.path))) + if (pathMatch) return pathMatch + + return worktrees.find((worktree) => worktree.branch === target) ?? null +} + +export default class UnlockWorktree extends Command { + static description = 'Unlock a Git worktree' + + static examples = [ + '<%= config.bin %> <%= command.id %> ../feature-x', + '<%= config.bin %> <%= command.id %> feature/x', + '<%= config.bin %> <%= command.id %> --path ../feature-x --json', + ] + + static args = { + worktree: Args.string({ + description: 'Worktree path or branch name', + required: false, + }), + } + + static flags = { + path: pathFlag, + json: jsonFlag, + } + + async run(): Promise { + const { args, flags } = await this.parse(UnlockWorktree) + + try { + if (flags.path !== undefined && args.worktree !== undefined) { + ErrorHelper.validation(this, 'Specify a worktree argument or --path, not both.', flags.json) + } + + const target = flags.path ?? args.worktree + if (target === undefined || target.trim() === '') { + ErrorHelper.validation(this, 'A worktree path or branch name is required.', flags.json) + } + + const gitHelper = createGitHelper() + if (!(await gitHelper.isRepository())) { + ErrorHelper.validation(this, 'Not a git repository', flags.json) + } + + const repositoryRoot = await gitHelper.getRepositoryRoot() + const worktrees = await gitHelper.listWorktrees() + const worktree = resolveUnlockTarget(target, worktrees, repositoryRoot) + if (!worktree) { + ErrorHelper.validation(this, `Worktree not found: ${target}`, flags.json) + } + + await gitHelper.unlockWorktree(worktree.path) + + if (flags.json) { + this.log( + JSON.stringify( + { + success: true, + path: worktree.path, + branch: worktree.branch, + locked: false, + }, + null, + 2 + ) + ) + } else { + const chalk = (await import('chalk')).default + this.log(chalk.green(`✓ Unlocked worktree ${worktree.path}`)) + } + } catch (error) { + if (isOclifExitError(error)) throw error + ErrorHelper.operation( + this, + error instanceof Error ? error : new Error(String(error)), + 'Failed to unlock worktree', + flags.json + ) + } + } +} diff --git a/src/config/env.ts b/src/config/env.ts index ffe20a8..d8ae9c1 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -12,6 +12,7 @@ import type { PartialPandoConfig } from './schema.js' * * Supports: * - Boolean values: true/false, 1/0, yes/no + * - Number values: decimal numeric strings * - String arrays: comma-separated values * - Nested properties: PANDO_SECTION_KEY */ @@ -43,9 +44,27 @@ const ENV_VAR_MAP: Record = { PANDO_WORKTREE_DELETE_BRANCH_ON_REMOVE: 'worktree.deleteBranchOnRemove', PANDO_WORKTREE_USE_PROJECT_SUBFOLDER: 'worktree.useProjectSubfolder', PANDO_WORKTREE_TARGET_BRANCH: 'worktree.targetBranch', + PANDO_WORKTREE_DEFAULT_KIND: 'worktree.defaultKind', + PANDO_WORKTREE_EPHEMERAL_TTL: 'worktree.ephemeralTtl', + PANDO_WORKTREE_AUTO_LOCK_ACTIVE: 'worktree.autoLockActive', // Clean settings PANDO_CLEAN_FETCH: 'clean.fetch', + + // Reap settings + PANDO_REAP_REQUIRE_MERGED: 'reap.requireMerged', + + // Concurrency retry settings + PANDO_CONCURRENCY_RETRY_MAX_ATTEMPTS: 'concurrency.retry.maxAttempts', + PANDO_CONCURRENCY_RETRY_BASE_MS: 'concurrency.retry.baseMs', + PANDO_CONCURRENCY_RETRY_CAP_MS: 'concurrency.retry.capMs', + + // Port allocation settings + PANDO_PORTS_ENABLED: 'ports.enabled', + PANDO_PORTS_RANGE: 'ports.range', + PANDO_PORTS_NAMES: 'ports.names', + PANDO_PORTS_DB_STRATEGY: 'ports.dbStrategy', + PANDO_PORTS_DB_BASE_NAME: 'ports.dbBaseName', } /** @@ -107,6 +126,7 @@ export function setNestedProperty( * Determines the type and parses accordingly: * - Arrays: comma-separated strings * - Booleans: true/false/1/0/yes/no + * - Numbers: decimal numeric strings * - Strings: as-is * * @param key - Environment variable key @@ -115,11 +135,16 @@ export function setNestedProperty( */ export function parseEnvValue(key: string, value: string): unknown { // Array fields - if (key.includes('FLAGS') || key.includes('EXCLUDE') || key.includes('PATTERNS')) { + if ( + key.includes('FLAGS') || + key.includes('EXCLUDE') || + key.includes('PATTERNS') || + key.includes('NAMES') + ) { return parseArray(value) } - // Boolean fields ('TRACKED' covers ONLY_UNTRACKED and ALLOW_TRACKED) + // 'TRACKED' covers ONLY_UNTRACKED and ALLOW_TRACKED. if ( key.includes('ENABLED') || key.includes('RELATIVE') || @@ -127,11 +152,19 @@ export function parseEnvValue(key: string, value: string): unknown { key.includes('REBASE') || key.includes('USE_') || key.includes('TRACKED') || + key.includes('ACTIVE') || + key.includes('REQUIRE_MERGED') || key.endsWith('_FETCH') ) { return parseBoolean(value) } + if (key.includes('MAX_ATTEMPTS') || key.endsWith('_MS')) { + const parsed = Number(value) + // Preserving malformed input lets schema validation report the configured field. + return Number.isNaN(parsed) ? value : parsed + } + // String fields return value } @@ -193,7 +226,12 @@ export function listSupportedEnvVars(): Array<{ name: string; path: string; type // Determine type based on key patterns let type = 'string' - if (name.includes('FLAGS') || name.includes('EXCLUDE') || name.includes('PATTERNS')) { + if ( + name.includes('FLAGS') || + name.includes('EXCLUDE') || + name.includes('PATTERNS') || + name.includes('NAMES') + ) { type = 'array' } else if ( name.includes('ENABLED') || @@ -201,9 +239,14 @@ export function listSupportedEnvVars(): Array<{ name: string; path: string; type name.includes('BEFORE') || name.includes('REBASE') || name.includes('USE_') || + name.includes('TRACKED') || + name.includes('ACTIVE') || + name.includes('REQUIRE_MERGED') || name.endsWith('_FETCH') ) { type = 'boolean' + } else if (name.includes('MAX_ATTEMPTS') || name.endsWith('_MS')) { + type = 'number' } return { name, path, type } diff --git a/src/config/schema.ts b/src/config/schema.ts index 6dc4543..87fd3b8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -69,6 +69,9 @@ export const WorktreeConfigSchema = z.object({ deleteBranchOnRemove: DeleteBranchOptionSchema.default('local'), useProjectSubfolder: z.boolean().default(false), targetBranch: z.string().default('main'), + defaultKind: z.enum(['auto', 'ephemeral', 'long-lived']).default('auto'), + ephemeralTtl: z.string().default('4h'), + autoLockActive: z.boolean().default(true), }) /** @@ -80,6 +83,9 @@ export const WorktreeConfigSchemaPartial = z.object({ deleteBranchOnRemove: DeleteBranchOptionSchema.optional(), useProjectSubfolder: z.boolean().optional(), targetBranch: z.string().optional(), + defaultKind: z.enum(['auto', 'ephemeral', 'long-lived']).optional(), + ephemeralTtl: z.string().optional(), + autoLockActive: z.boolean().optional(), }) /** @@ -96,6 +102,74 @@ export const CleanConfigSchemaPartial = z.object({ fetch: z.boolean().optional(), }) +/** + * Reap configuration schema + */ +export const ReapConfigSchema = z.object({ + requireMerged: z.boolean().default(true), +}) + +/** + * Reap configuration schema without defaults (for partial config validation) + */ +export const ReapConfigSchemaPartial = z.object({ + requireMerged: z.boolean().optional(), +}) + +/** + * Retry configuration schema + */ +export const RetryConfigSchema = z.object({ + maxAttempts: z.number().int().positive().default(5), + baseMs: z.number().int().nonnegative().default(100), + capMs: z.number().int().positive().default(2000), +}) + +/** + * Retry configuration schema without defaults (for partial config validation) + */ +export const RetryConfigSchemaPartial = z.object({ + maxAttempts: z.number().int().positive().optional(), + baseMs: z.number().int().nonnegative().optional(), + capMs: z.number().int().positive().optional(), +}) + +/** + * Concurrency configuration schema + */ +export const ConcurrencyConfigSchema = z.object({ + retry: RetryConfigSchema, +}) + +/** + * Concurrency configuration schema without defaults (for partial config validation) + */ +export const ConcurrencyConfigSchemaPartial = z.object({ + retry: RetryConfigSchemaPartial.optional(), +}) + +/** + * Port allocation configuration schema + */ +export const PortsConfigSchema = z.object({ + enabled: z.boolean().default(false), + range: z.string().default('3100-3199'), + names: z.array(z.string()).default(['web']), + dbStrategy: z.enum(['named']).default('named'), + dbBaseName: z.string().default('dev'), +}) + +/** + * Port allocation configuration schema without defaults (for partial config validation) + */ +export const PortsConfigSchemaPartial = z.object({ + enabled: z.boolean().optional(), + range: z.string().optional(), + names: z.array(z.string()).optional(), + dbStrategy: z.enum(['named']).optional(), + dbBaseName: z.string().optional(), +}) + /** * Post-command script configuration schema */ @@ -122,6 +196,9 @@ export const PandoConfigSchema = z.object({ symlink: SymlinkConfigSchema, worktree: WorktreeConfigSchema, clean: CleanConfigSchema, + reap: ReapConfigSchema, + concurrency: ConcurrencyConfigSchema, + ports: PortsConfigSchema, postCommands: PostCommandsConfigSchema, }) @@ -133,6 +210,9 @@ export const PartialPandoConfigSchema = z.object({ symlink: SymlinkConfigSchemaPartial.optional(), worktree: WorktreeConfigSchemaPartial.optional(), clean: CleanConfigSchemaPartial.optional(), + reap: ReapConfigSchemaPartial.optional(), + concurrency: ConcurrencyConfigSchemaPartial.optional(), + ports: PortsConfigSchemaPartial.optional(), postCommands: PostCommandsConfigSchemaPartial, }) @@ -261,6 +341,24 @@ export interface WorktreeConfig { * @default 'main' */ targetBranch?: string + + /** + * Default lifecycle kind assigned to new worktrees + * @default 'auto' + */ + defaultKind?: 'auto' | 'ephemeral' | 'long-lived' + + /** + * Time-to-live assigned to ephemeral worktrees + * @default '4h' + */ + ephemeralTtl?: string + + /** + * Automatically lock active worktrees against reaping + * @default true + */ + autoLockActive?: boolean } /** @@ -276,6 +374,91 @@ export interface CleanConfig { fetch: boolean } +/** + * Reap configuration options + * + * Controls safeguards applied while reaping worktrees + */ +export interface ReapConfig { + /** + * Require a worktree branch to be merged before reaping it + * @default true + */ + requireMerged: boolean +} + +/** + * Retry configuration options + * + * Controls retry backoff for concurrent lifecycle operations + */ +export interface RetryConfig { + /** + * Maximum number of attempts before failing + * @default 5 + */ + maxAttempts: number + + /** + * Initial retry delay in milliseconds + * @default 100 + */ + baseMs: number + + /** + * Maximum retry delay in milliseconds + * @default 2000 + */ + capMs: number +} + +/** + * Concurrency configuration options + */ +export interface ConcurrencyConfig { + /** + * Retry behavior for concurrent lifecycle operations + */ + retry: RetryConfig +} + +/** + * Port allocation configuration options + * + * Controls deterministic port and database allocation for worktrees + */ +export interface PortsConfig { + /** + * Whether port allocation is enabled + * @default false + */ + enabled: boolean + + /** + * Inclusive port range available for allocation + * @default '3100-3199' + */ + range: string + + /** + * Logical service names that receive allocated ports + * @default ['web'] + */ + names: string[] + + /** + * Database naming strategy + * @default 'named' + */ + dbStrategy: 'named' + + /** + * Base name used when deriving worktree database names + * @default 'dev' + */ + dbBaseName: string +} + export type PostCommandScript = z.infer /** @@ -294,6 +477,9 @@ export interface PandoConfig { symlink: SymlinkConfig worktree: WorktreeConfig clean: CleanConfig + reap: ReapConfig + concurrency: ConcurrencyConfig + ports: PortsConfig postCommands: PostCommandsConfig } @@ -305,6 +491,11 @@ export type PartialPandoConfig = { symlink?: Partial worktree?: Partial clean?: Partial + reap?: Partial + concurrency?: { + retry?: Partial + } + ports?: Partial postCommands?: PostCommandsConfig } @@ -412,31 +603,15 @@ export class ConfigParseFailureError extends Error { * * Used when no configuration files are found */ -export const DEFAULT_CONFIG: PandoConfig = { - rsync: { - enabled: true, - // `.git` is excluded programmatically in fileOps.buildArgs (non-configurable). - flags: ['--archive'], - exclude: [], - onlyUntracked: true, - }, - symlink: { - patterns: [], - relative: true, - beforeRsync: true, - allowTracked: true, - }, - worktree: { - rebaseOnAdd: true, - deleteBranchOnRemove: 'local', - useProjectSubfolder: false, - targetBranch: 'main', - }, - clean: { - fetch: false, - }, - postCommands: {}, -} +export const DEFAULT_CONFIG = PandoConfigSchema.parse({ + rsync: {}, + symlink: {}, + worktree: {}, + clean: {}, + reap: {}, + concurrency: { retry: {} }, + ports: {}, +}) // ============================================================================ // Validation Functions diff --git a/src/utils/git.ts b/src/utils/git.ts index 7a0a8f4..3c49345 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,4 +1,7 @@ +import { stat } from 'node:fs/promises' import { simpleGit, type SimpleGit } from 'simple-git' +import { withGitRetry, type GitRetryOptions } from './gitRetry.js' +import { readMetadata, type WorktreeMetadata } from './worktreeMetadata.js' /** * Git utility wrapper for worktree and branch operations @@ -13,6 +16,7 @@ export interface WorktreeInfo { commit: string isPrunable: boolean isExistingBranch?: boolean + isLocked?: boolean } export interface BranchInfo { @@ -59,9 +63,15 @@ export interface StaleWorktreeInfo extends WorktreeInfo { export class GitHelper { private git: SimpleGit + private retryConfig: GitRetryOptions | undefined - constructor(baseDir?: string) { + constructor(baseDir?: string, retryConfig?: GitRetryOptions) { this.git = simpleGit(baseDir) + this.retryConfig = retryConfig + } + + setRetryConfig(cfg?: GitRetryOptions): void { + this.retryConfig = cfg } /** @@ -157,7 +167,7 @@ export class GitHelper { } // Execute worktree add command - await this.git.raw(args) + await withGitRetry(() => this.git.raw(args), this.retryConfig) // Get the commit hash for the new worktree, not the source checkout. const commitHash = await this.getWorktreeCommit(path) @@ -197,6 +207,8 @@ export class GitHelper { currentWorktree.branch = null } else if (line.startsWith('prunable')) { currentWorktree.isPrunable = true + } else if (line === 'locked' || line.startsWith('locked ')) { + currentWorktree.isLocked = true } else if (line === '' && currentWorktree.path) { // Empty line marks end of worktree entry worktrees.push({ @@ -204,6 +216,7 @@ export class GitHelper { branch: currentWorktree.branch || null, commit: currentWorktree.commit || '', isPrunable: currentWorktree.isPrunable || false, + ...(currentWorktree.isLocked ? { isLocked: true } : {}), }) currentWorktree = {} } @@ -216,6 +229,7 @@ export class GitHelper { branch: currentWorktree.branch || null, commit: currentWorktree.commit || '', isPrunable: currentWorktree.isPrunable || false, + ...(currentWorktree.isLocked ? { isLocked: true } : {}), }) } @@ -266,7 +280,103 @@ export class GitHelper { args.push(path) - await this.git.raw(args) + await withGitRetry(() => this.git.raw(args), this.retryConfig) + } + + /** + * Lock a worktree so Git will not prune or move it + */ + async lockWorktree(worktreePath: string, reason?: string): Promise { + const args = ['worktree', 'lock'] + if (reason !== undefined) { + args.push('--reason', reason) + } + args.push(worktreePath) + + await withGitRetry(async () => { + try { + await this.git.raw(args) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + // Lifecycle cleanup may replay a completed lock operation after interruption. + if (/is already locked\b/i.test(message)) return + throw error + } + }, this.retryConfig) + } + + /** + * Unlock a worktree + */ + async unlockWorktree(worktreePath: string): Promise { + await withGitRetry(async () => { + try { + await this.git.raw(['worktree', 'unlock', worktreePath]) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + // Lifecycle cleanup may replay a completed unlock operation after interruption. + if (/is not locked\b/i.test(message)) return + throw error + } + }, this.retryConfig) + } + + /** + * Get the age of a worktree, preferring its lifecycle metadata + */ + async getWorktreeAgeMs(worktreePath: string, knownMetadata?: WorktreeMetadata): Promise { + try { + // Callers that already read the metadata (list/health) can pass it to + // avoid a second `git config --worktree` read per worktree. + const { createdAt } = knownMetadata ?? (await readMetadata(worktreePath)) + if (createdAt !== undefined) { + const createdAtMs = Date.parse(createdAt) + const ageMs = Date.now() - createdAtMs + if (Number.isFinite(ageMs)) return Math.max(0, ageMs) + } + } catch { + // Older or partially-created worktrees may not have readable metadata. + } + + try { + const worktreeStat = await stat(worktreePath) + const ageMs = Date.now() - worktreeStat.mtimeMs + return Number.isFinite(ageMs) ? Math.max(0, ageMs) : 0 + } catch { + return 0 + } + } + + /** + * Check whether a worktree can be reaped without losing work or commits + */ + async isReapClean(worktreePath: string, targetBranch: string): Promise { + try { + const mergeTarget = targetBranch.trim() + if (!mergeTarget) return false + + const status = await simpleGit(worktreePath).status() + if (!status.isClean()) return false + + await this.git.raw(['show-ref', '--verify', '--quiet', `refs/heads/${mergeTarget}`]) + + const worktree = (await this.listWorktrees()).find(({ path }) => path === worktreePath) + if (!worktree?.branch) return false + + return await this.isBranchMerged(worktree.branch, `refs/heads/${mergeTarget}`) + } catch { + // Reaping must never proceed when repository state cannot be established. + return false + } + } + + /** + * Infer the agent session that owns a worktree + */ + inferOwner(): string { + // Use `||` on trimmed values so an empty/whitespace CLAUDE_SESSION_ID still + // falls back to PANDO_SESSION (nullish-coalescing would stop at ''). + return process.env.CLAUDE_SESSION_ID?.trim() || process.env.PANDO_SESSION?.trim() || '' } /** @@ -299,7 +409,7 @@ export class GitHelper { args.push(startPoint) } - await this.git.raw(args) + await withGitRetry(() => this.git.raw(args), this.retryConfig) // Get commit hash for the new branch const commit = await this.git.raw(['rev-parse', name]) @@ -319,7 +429,7 @@ export class GitHelper { const args = ['branch', force ? '-D' : '-d', name] try { - await this.git.raw(args) + await withGitRetry(() => this.git.raw(args), this.retryConfig) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -373,7 +483,7 @@ export class GitHelper { const output = await this.git.raw(['branch', '--merged', target]) const mergedBranches = output .split('\n') - .map((line) => line.trim().replace(/^\*\s*/, '')) + .map((line) => line.replace(/^[*+]?\s*/, '').trim()) .filter(Boolean) return mergedBranches.includes(name) @@ -534,7 +644,7 @@ export class GitHelper { */ async forceUpdateBranch(branch: string, commit: string): Promise { try { - await this.git.raw(['branch', '-f', branch, commit]) + await withGitRetry(() => this.git.raw(['branch', '-f', branch, commit]), this.retryConfig) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) throw new Error(`Failed to update branch '${branch}': ${errorMessage}`) @@ -548,7 +658,7 @@ export class GitHelper { */ async resetHard(commit: string): Promise { try { - await this.git.raw(['reset', '--hard', commit]) + await withGitRetry(() => this.git.raw(['reset', '--hard', commit]), this.retryConfig) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) throw new Error(`Failed to reset to '${commit}': ${errorMessage}`) @@ -855,7 +965,7 @@ export class GitHelper { * @param remote - Remote name (default: 'origin') */ async fetchWithPrune(remote: string = 'origin'): Promise { - await this.git.fetch([remote, '--prune']) + await withGitRetry(() => this.git.fetch([remote, '--prune']), this.retryConfig) } /** @@ -1071,6 +1181,6 @@ export class GitHelper { /** * Create a new GitHelper instance */ -export function createGitHelper(baseDir?: string): GitHelper { - return new GitHelper(baseDir) +export function createGitHelper(baseDir?: string, retryConfig?: GitRetryOptions): GitHelper { + return new GitHelper(baseDir, retryConfig) } diff --git a/src/utils/gitRetry.ts b/src/utils/gitRetry.ts new file mode 100644 index 0000000..60a4a83 --- /dev/null +++ b/src/utils/gitRetry.ts @@ -0,0 +1,91 @@ +export interface GitRetryOptions { + maxAttempts?: number + baseMs?: number + capMs?: number +} + +const DEFAULT_MAX_ATTEMPTS = 5 +const DEFAULT_BASE_MS = 100 +const DEFAULT_CAP_MS = 2000 + +const EXIT_CODE_PATTERN = + /\bexit(?:ed)?\s+(?:with\s+)?(?:(?:code|status)\s+)?128\b(?=[^\w\s]|\s*(?:[\r\n]|$))/i +const LOCK_PATTERN = /\.lock\b|cannot lock/i +// A ref directory/file conflict ("...'refs/heads/foo' exists; cannot create +// 'refs/heads/foo/bar'") is a permanent namespace clash, not lock contention — +// retrying never helps, so exclude it even when it carries exit code 128. +const REF_DF_CONFLICT_PATTERN = /exists; cannot create|cannot create '?refs\//i +const CANNOT_LOCK_PATTERN = /\bcannot lock\b/i +const ANOTHER_GIT_PROCESS_PATTERN = /\banother git process seems to be running\b/i +const DEFINITIVE_LOCK_PATTERN = + /\bunable to create\s+(['"])[^'"\r\n]*\.lock\1|\banother git process seems to be running\b/i +const LOCK_FILE_EXISTS_PATTERN = /\.lock\b/i +const FILE_EXISTS_PATTERN = /\bfile exists\b/i +const PERMANENT_FAILURE_PATTERN = + /permission denied|operation not permitted|read-only file system|access denied|authorization|publickey|authentication|could not read username|not enough space|no space left on device|disk quota exceeded|\b(?:EACCES|EPERM|ENOSPC|EDQUOT)\b/i + +type GitError = Error & { + code?: unknown + exitCode?: unknown +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isLockSignature(message: string, hasExitCode128: boolean): boolean { + const hasContentionSignal = + LOCK_FILE_EXISTS_PATTERN.test(message) || + FILE_EXISTS_PATTERN.test(message) || + ANOTHER_GIT_PROCESS_PATTERN.test(message) + + return ( + DEFINITIVE_LOCK_PATTERN.test(message) || + (CANNOT_LOCK_PATTERN.test(message) && hasContentionSignal) || + (LOCK_FILE_EXISTS_PATTERN.test(message) && FILE_EXISTS_PATTERN.test(message)) || + (hasExitCode128 && LOCK_PATTERN.test(message)) + ) +} + +export function isTransientLockError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const gitError = error as GitError + const permanentCode = + typeof gitError.code === 'string' && /^(EACCES|EPERM|ENOSPC|EDQUOT)$/i.test(gitError.code) + // Git wrappers expose status via properties or only embed it in the message. + const hasExitCode128 = + gitError.exitCode === 128 || gitError.code === 128 || EXIT_CODE_PATTERN.test(error.message) + + return ( + isLockSignature(error.message, hasExitCode128) && + !REF_DF_CONFLICT_PATTERN.test(error.message) && + !PERMANENT_FAILURE_PATTERN.test(error.message) && + !permanentCode + ) +} + +export async function withGitRetry( + fn: () => Promise, + opts: GitRetryOptions = {} +): Promise { + const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS + const baseMs = opts.baseMs ?? DEFAULT_BASE_MS + const capMs = opts.capMs ?? DEFAULT_CAP_MS + + for (let attempt = 1; ; attempt += 1) { + try { + return await fn() + } catch (error: unknown) { + if (!isTransientLockError(error) || attempt >= maxAttempts) { + throw error + } + + // Full jitter mitigates thundering-herd contention on the same lock. + const backoffLimit = Math.min(capMs, baseMs * 2 ** Math.min(attempt - 1, 30)) + await delay(Math.random() * backoffLimit) + } + } +} diff --git a/src/utils/portAllocator.ts b/src/utils/portAllocator.ts new file mode 100644 index 0000000..7b998ab --- /dev/null +++ b/src/utils/portAllocator.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { createServer } from 'node:net' +import { enumerateAll, unsetPort, writeMetadata } from './worktreeMetadata.js' + +type PortProbe = (port: number) => Promise +type AddressProbeResult = 'free' | 'taken' | 'unsupported' + +export interface AllocateOptions { + range: string + names: string[] + mainRepoPath: string + isPortFree?: PortProbe +} + +const SERVICE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]*$/ +const MAX_RECONCILE_ATTEMPTS = 3 + +function parseRange(range: string): [start: number, end: number] | undefined { + const match = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(range) + if (!match) return undefined + + const start = Number(match[1]) + const end = Number(match[2]) + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 1 || + end > 65_535 || + start > end + ) { + return undefined + } + + return [start, end] +} + +function probeAddress( + port: number, + host: string, + allowUnsupported: boolean +): Promise { + return new Promise((resolve) => { + const server = createServer() + server.unref() + server.once('error', (error: NodeJS.ErrnoException) => { + const result = + allowUnsupported && (error.code === 'EAFNOSUPPORT' || error.code === 'EADDRNOTAVAIL') + ? 'unsupported' + : 'taken' + + // An errored listener may still retain resources; ignore any close error. + server.close(() => resolve(result)) + }) + server.listen(port, host, () => { + // Waiting for close prevents the transient probe itself from retaining the port. + server.close(() => resolve('free')) + }) + }) +} + +async function probePort(port: number): Promise { + const ipv4 = await probeAddress(port, '127.0.0.1', false) + const ipv6 = await probeAddress(port, '::1', true) + return ipv4 === 'free' && (ipv6 === 'free' || ipv6 === 'unsupported') +} + +async function findFreePort( + start: number, + end: number, + taken: Set, + isPortFree: PortProbe +): Promise { + for (let port = start; port <= end; port += 1) { + if (taken.has(port)) continue + + let free = false + try { + free = await isPortFree(port) + } catch { + // Probe failures cannot establish availability, so reserving is the safe default. + } + + if (!free) { + taken.add(port) + continue + } + + return port + } + + return undefined +} + +function plainPorts(ports: Record): Record { + return Object.fromEntries(Object.entries(ports)) +} + +export async function allocate( + worktreePath: string, + opts: AllocateOptions +): Promise> { + const ports: Record = Object.create(null) as Record + const range = parseRange(opts.range) + const taken = new Set() + const isPortFree = opts.isPortFree ?? probePort + + if (range) { + const worktrees = await enumerateAll(opts.mainRepoPath) + for (const { metadata } of worktrees) { + for (const port of Object.values(metadata.ports ?? {})) taken.add(port) + } + + const [start, end] = range + + for (const name of opts.names) { + if (!SERVICE_NAME_PATTERN.test(name) || Object.hasOwn(ports, name)) continue + + const port = await findFreePort(start, end, taken, isPortFree) + if (port === undefined) continue + + ports[name] = port + taken.add(port) + } + } + + await writeMetadata(worktreePath, { ports: plainPorts(ports) }) + + if (range && Object.keys(ports).length > 0) { + const [start, end] = range + + for (let attempt = 0; attempt < MAX_RECONCILE_ATTEMPTS; attempt += 1) { + const worktrees = await enumerateAll(opts.mainRepoPath) + const peerPorts = new Set() + + for (const { worktreePath: peerPath, metadata } of worktrees) { + if (peerPath === worktreePath) continue + for (const port of Object.values(metadata.ports ?? {})) peerPorts.add(port) + } + + const collidingNames = Object.entries(ports) + .filter(([, port]) => peerPorts.has(port)) + .map(([name]) => name) + if (collidingNames.length === 0) break + + for (const port of peerPorts) taken.add(port) + for (const name of collidingNames) { + const replacement = await findFreePort(start, end, taken, isPortFree) + if (replacement === undefined) { + delete ports[name] + await unsetPort(worktreePath, name) + continue + } + + ports[name] = replacement + taken.add(replacement) + } + + await writeMetadata(worktreePath, { ports: plainPorts(ports) }) + } + } + + return plainPorts(ports) +} + +export function deriveDbName(base: string, branch: string): string { + const sanitizedBranch = branch + .replace(/[^a-zA-Z0-9]+/g, '_') + .toLowerCase() + .replace(/^_+|_+$/g, '') + const suffix = sanitizedBranch || createHash('sha1').update(branch).digest('hex').slice(0, 8) + return `${base}_${suffix}` +} diff --git a/src/utils/postCommands.ts b/src/utils/postCommands.ts index b5190bf..0874d84 100644 --- a/src/utils/postCommands.ts +++ b/src/utils/postCommands.ts @@ -12,6 +12,10 @@ export interface PostCommandContext { worktreePath: string branch: string | null commit: string + kind?: 'ephemeral' | 'long-lived' + ttl?: string + ports?: Record + dbName?: string } export interface PostCommandResult { @@ -79,6 +83,13 @@ async function runPostCommandScript( const startTime = Date.now() return new Promise((resolve) => { + // Allocator names allow hyphens, so hook variables uppercase names and map hyphens to underscores. + const portEnv = Object.fromEntries( + Object.entries(context.ports ?? {}).map(([name, port]) => [ + `PANDO_PORT_${name.toUpperCase().replace(/-/g, '_')}`, + String(port), + ]) + ) const child = spawn(script.command, { cwd: context.cwd, shell: true, @@ -88,6 +99,10 @@ async function runPostCommandScript( PANDO_WORKTREE_PATH: context.worktreePath, PANDO_BRANCH: context.branch ?? '', PANDO_COMMIT: context.commit, + ...(context.kind ? { PANDO_KIND: context.kind } : {}), + ...(context.ttl !== undefined ? { PANDO_TTL: context.ttl } : {}), + ...portEnv, + ...(context.dbName ? { PANDO_DB_NAME: context.dbName } : {}), }, }) diff --git a/src/utils/worktreeMetadata.ts b/src/utils/worktreeMetadata.ts new file mode 100644 index 0000000..374797f --- /dev/null +++ b/src/utils/worktreeMetadata.ts @@ -0,0 +1,257 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { simpleGit, type SimpleGit } from 'simple-git' + +export interface WorktreeMetadata { + kind?: 'ephemeral' | 'long-lived' + createdAt?: string + sourceBranch?: string + owner?: string + ttl?: string + ports?: Record + dbName?: string +} + +const PANDO_KEY_PATTERN = '^pando\\.' +const MIGRATED_CORE_KEYS = ['core.worktree', 'core.bare', 'core.sparseCheckout'] as const +// git lowercases config keys on read; this mirrors the allocator's write-time +// name guard (git-config-valid: leading letter, then letters/digits/hyphen). +const VALID_PORT_NAME = /^[a-z][a-z0-9-]*$/ + +function parseMetadata(output: string): WorktreeMetadata { + const metadata: WorktreeMetadata = {} + + for (const record of output.split('\0')) { + if (!record) continue + + const separatorIndex = record.indexOf('\n') + if (separatorIndex === -1) continue + + const rawKey = record.slice(0, separatorIndex) + if (!rawKey) continue + + const key = rawKey.toLowerCase() + const value = record.slice(separatorIndex + 1) + + switch (key) { + case 'pando.kind': { + if (value === 'ephemeral' || value === 'long-lived') metadata.kind = value + break + } + case 'pando.createdat': { + metadata.createdAt = value + break + } + case 'pando.sourcebranch': { + metadata.sourceBranch = value + break + } + case 'pando.owner': { + metadata.owner = value + break + } + case 'pando.ttl': { + metadata.ttl = value + break + } + case 'pando.db.name': { + metadata.dbName = value + break + } + default: { + if (key.startsWith('pando.port.')) { + const name = key.slice('pando.port.'.length) + const port = Number(value) + // Only accept git-config-valid names (matches the allocator's write + // guard) and use a null-prototype map so a key like `__proto__` or + // `constructor` cannot pollute the prototype of the ports object. + if (VALID_PORT_NAME.test(name) && value.trim() && Number.isFinite(port)) { + metadata.ports ??= Object.create(null) as Record + metadata.ports[name] = port + } + } + } + } + } + + return metadata +} + +async function readMetadataFile(git: SimpleGit, configPath: string): Promise { + try { + const output = await git.raw([ + 'config', + '--file', + configPath, + '--get-regexp', + '--null', + PANDO_KEY_PATTERN, + ]) + return parseMetadata(output) + } catch { + // A worktree without metadata normally has no config.worktree file or matching keys. + return {} + } +} + +export async function readMetadata(worktreePath: string): Promise { + try { + const output = await simpleGit(worktreePath).raw([ + 'config', + '--worktree', + '--get-regexp', + '--null', + PANDO_KEY_PATTERN, + ]) + return parseMetadata(output) + } catch { + // Metadata must remain optional for repositories not yet using worktree config. + return {} + } +} + +export async function unsetPort(worktreePath: string, name: string): Promise { + try { + await simpleGit(worktreePath).raw(['config', '--worktree', '--unset', `pando.port.${name}`]) + } catch { + // An absent port key is already in the desired state. + } +} + +export async function writeMetadata( + worktreePath: string, + patch: Partial +): Promise { + const git = simpleGit(worktreePath) + const values: Array<[key: string, value: string]> = [] + + if (patch.kind !== undefined) values.push(['pando.kind', patch.kind]) + if (patch.createdAt !== undefined) values.push(['pando.createdAt', patch.createdAt]) + if (patch.sourceBranch !== undefined) values.push(['pando.sourceBranch', patch.sourceBranch]) + if (patch.owner !== undefined) values.push(['pando.owner', patch.owner]) + if (patch.ttl !== undefined) values.push(['pando.ttl', patch.ttl]) + if (patch.dbName !== undefined) values.push(['pando.db.name', patch.dbName]) + if (patch.ports !== undefined) { + for (const [name, port] of Object.entries(patch.ports)) { + values.push([`pando.port.${name}`, String(port)]) + } + } + + for (const [key, value] of values) { + await git.raw(['config', '--worktree', key, value]) + } +} + +function parseWorktreePaths(output: string): string[] { + return output + .split('\0') + .filter((attribute) => attribute.startsWith('worktree ')) + .map((attribute) => attribute.slice('worktree '.length)) +} + +async function linkedConfigPath( + worktreePath: string, + gitCommonDir: string +): Promise { + try { + const dotGit = await readFile(path.join(worktreePath, '.git'), 'utf8') + const gitDirValue = /^gitdir:\s*(.+?)\s*$/im.exec(dotGit)?.[1] + if (!gitDirValue) return undefined + + // The id is authoritative in the linked worktree's .git pointer; using the + // common dir avoids accidentally asking Git to read the current worktree. + const gitDir = path.resolve(worktreePath, gitDirValue) + const id = path.basename(gitDir) + return path.join(gitCommonDir, 'worktrees', id, 'config.worktree') + } catch { + return undefined + } +} + +export async function enumerateAll( + mainRepoPath: string +): Promise> { + const git = simpleGit(mainRepoPath) + const worktreeOutput = await git.raw(['worktree', 'list', '--porcelain', '-z']) + const worktreePaths = parseWorktreePaths(worktreeOutput) + const commonDirOutput = await git.raw(['rev-parse', '--git-common-dir']) + const gitCommonDir = path.resolve(mainRepoPath, commonDirOutput.trim()) + const entries: Array<{ worktreePath: string; metadata: WorktreeMetadata }> = [] + + for (const [index, worktreePath] of worktreePaths.entries()) { + // Git's porcelain format guarantees that the main worktree is listed first. + const configPath = + index === 0 + ? path.join(gitCommonDir, 'config.worktree') + : await linkedConfigPath(worktreePath, gitCommonDir) + const metadata = configPath ? await readMetadataFile(git, configPath) : {} + entries.push({ worktreePath, metadata }) + } + + return entries +} + +export async function ensureWorktreeConfigEnabled( + repoPath: string +): Promise<{ enabled: boolean; migrated: string[]; notice?: string }> { + const git = simpleGit(repoPath) + + try { + const current = await git.raw(['config', '--get', 'extensions.worktreeConfig']) + if (current.trim().toLowerCase() === 'true') return { enabled: true, migrated: [] } + } catch { + // An absent setting is the expected state before first-time enablement. + } + + const settings: Array<{ name: string; value: string }> = [] + for (const name of MIGRATED_CORE_KEYS) { + try { + const value = (await git.raw(['config', '--local', '--get', name])).trim() + // simple-git resolves an absent config key to '' instead of throwing, so + // the catch below never fires for a missing key. Writing an empty value + // back via `git config --worktree` — notably core.worktree — bricks the + // repository (`fatal: cannot chdir to ''`), poisoning every later git + // command. Only migrate keys that actually hold a value. + if (value) settings.push({ name, value }) + } catch { + // Missing core settings need no per-worktree override. + } + } + + await git.raw(['config', 'extensions.worktreeConfig', 'true']) + for (const { name, value } of settings) { + await git.raw(['config', '--worktree', name, value]) + await git.raw(['config', '--local', '--unset', name]) + } + + const migrated = settings.map(({ name }) => name) + const summary = migrated.length > 0 ? migrated.join(', ') : 'none' + return { + enabled: true, + migrated, + notice: `Enabled extensions.worktreeConfig (migrated: ${summary})`, + } +} + +export async function assertGitVersion(minMajor = 2, minMinor = 38): Promise { + let output: string + try { + output = await simpleGit().raw(['--version']) + } catch { + return + } + + const match = /git version\s+(\d+)\.(\d+)(?:\.(\d+))?/i.exec(output) + const majorText = match?.[1] + const minorText = match?.[2] + if (!majorText || !minorText) return + + const major = Number(majorText) + const minor = Number(minorText) + if (major < minMajor || (major === minMajor && minor < minMinor)) { + const found = [majorText, minorText, match?.[3]].filter(Boolean).join('.') + throw new Error( + `pando worktree metadata requires git >= ${minMajor}.${minMinor} (found ${found})` + ) + } +} diff --git a/test/commands/add.test.ts b/test/commands/add.test.ts index 32afdbb..e4a5f3a 100644 --- a/test/commands/add.test.ts +++ b/test/commands/add.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import AddWorktree from '../../src/commands/add' +import AddWorktree, { resolveWorktreeKind, setupLifecycleMetadata } from '../../src/commands/add' import type { WorktreeSetupOrchestrator } from '../../src/utils/worktreeSetup' import type { PandoConfig } from '../../src/config/schema' @@ -19,12 +19,17 @@ import type { PandoConfig } from '../../src/config/schema' // --------------------------------------------------------------------------- const mockGitHelper = { + setRetryConfig: vi.fn(), isRepository: vi.fn(), getRepositoryRoot: vi.fn(), getCurrentBranch: vi.fn(), + getMainWorktreePath: vi.fn(), branchExists: vi.fn(), addWorktree: vi.fn(), rebaseBranchInWorktree: vi.fn(), + getMainBranch: vi.fn(), + inferOwner: vi.fn(), + lockWorktree: vi.fn(), } vi.mock('../../src/utils/git.js', () => ({ @@ -36,6 +41,19 @@ vi.mock('../../src/config/loader.js', () => ({ loadConfig: vi.fn(), })) +vi.mock('../../src/utils/worktreeMetadata.js', () => ({ + 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/configTrust.js', () => ({ computeConfigHash: vi.fn(), decidePostCommandTrust: vi.fn(), @@ -73,7 +91,17 @@ import { isConfigTrusted, computeConfigHash, } from '../../src/utils/configTrust.js' -import { normalizePostCommandScripts, runPostCommandScripts } from '../../src/utils/postCommands.js' +import { + normalizePostCommandScripts, + PostCommandError, + runPostCommandScripts, +} from '../../src/utils/postCommands.js' +import { + assertGitVersion, + ensureWorktreeConfigEnabled, + writeMetadata, +} from '../../src/utils/worktreeMetadata.js' +import { allocate } from '../../src/utils/portAllocator.js' // --------------------------------------------------------------------------- // Helpers @@ -88,8 +116,19 @@ function baseConfig(overrides: Partial = {}): PandoConfig { 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', + }, ...overrides, } as PandoConfig } @@ -131,12 +170,591 @@ function stubParse( beforeEach(() => { vi.clearAllMocks() + vi.mocked(assertGitVersion).mockResolvedValue(undefined) + vi.mocked(ensureWorktreeConfigEnabled).mockResolvedValue({ enabled: true, migrated: [] }) + vi.mocked(writeMetadata).mockResolvedValue(undefined) + vi.mocked(allocate).mockResolvedValue({}) + mockGitHelper.getMainWorktreePath.mockResolvedValue('/repo') }) // --------------------------------------------------------------------------- // Branch-name validation // --------------------------------------------------------------------------- +describe('add: lifecycle kind resolution', () => { + const emptyEnv: NodeJS.ProcessEnv = {} + + it('registers mutually exclusive lifecycle flags', () => { + expect(AddWorktree.flags.ephemeral.exclusive).toContain('long-lived') + expect(AddWorktree.flags['long-lived'].exclusive).toContain('ephemeral') + expect(AddWorktree.flags).toHaveProperty('ttl') + expect(AddWorktree.flags).toHaveProperty('owner') + expect(AddWorktree.flags).toHaveProperty('ports') + }) + + it('uses explicit flags before config and inference', () => { + expect(resolveWorktreeKind({ ephemeral: true }, 'long-lived', '/repo/wt', emptyEnv)).toBe( + 'ephemeral' + ) + expect( + resolveWorktreeKind({ 'long-lived': true }, 'ephemeral', '/repo/.claude/worktrees/task', { + CLAUDE_SESSION_ID: 'session', + }) + ).toBe('long-lived') + }) + + it('uses a configured kind before automatic inference', () => { + expect(resolveWorktreeKind({}, 'long-lived', '/repo/.claude/worktrees/task', emptyEnv)).toBe( + 'long-lived' + ) + expect(resolveWorktreeKind({}, 'ephemeral', '/repo/wt', emptyEnv)).toBe('ephemeral') + }) + + it('infers ephemeral kind from Claude worktree paths and agent environment signals', () => { + expect(resolveWorktreeKind({}, 'auto', '/repo/.claude/worktrees/task', emptyEnv)).toBe( + 'ephemeral' + ) + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', { CLAUDE_SESSION_ID: '' })).toBe('ephemeral') + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', { PANDO_SESSION: 'p1' })).toBe('ephemeral') + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', { PANDO_EPHEMERAL: 'YES' })).toBe( + 'ephemeral' + ) + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', emptyEnv)).toBe('long-lived') + }) + + it('parses PANDO_EPHEMERAL as a boolean', () => { + for (const value of ['false', '0', 'no', 'FALSE', 'No']) { + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', { PANDO_EPHEMERAL: value })).toBe( + 'long-lived' + ) + } + for (const value of ['true', '1', 'yes', 'TRUE', 'Yes']) { + expect(resolveWorktreeKind({}, 'auto', '/repo/wt', { PANDO_EPHEMERAL: value })).toBe( + 'ephemeral' + ) + } + }) +}) + +describe('add: lifecycle metadata setup', () => { + const dependencies = () => ({ + assertGitVersion: vi.fn().mockResolvedValue(undefined), + ensureWorktreeConfigEnabled: vi.fn().mockResolvedValue({ enabled: true, migrated: [] }), + writeMetadata: vi.fn().mockResolvedValue(undefined), + allocate: vi.fn().mockResolvedValue({}), + }) + + const worktreeConfig = { + defaultKind: 'auto' as const, + ephemeralTtl: '4h', + autoLockActive: true, + } + const portsConfig = { + enabled: false, + range: '3100-3199', + names: ['web'], + dbStrategy: 'named' as const, + dbBaseName: 'dev', + } + + it('does not auto-lock for an explicit owner without an active session', async () => { + const lockWorktree = vi.fn().mockResolvedValue(undefined) + const deps = dependencies() + const result = await setupLifecycleMetadata( + { + flags: { owner: 'manual-owner' }, + worktreeConfig, + portsConfig, + gitHelper: { + inferOwner: vi.fn().mockReturnValue(''), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree, + }, + gitRoot: '/repo', + mainRepoPath: '/repo', + resolvedPath: '/repo/wt', + sourceBranch: 'develop', + worktreeBranch: 'feature', + env: {}, + createdAt: '2026-07-22T12:00:00.000Z', + }, + deps + ) + + expect(result).toMatchObject({ owner: 'manual-owner', locked: false }) + expect(deps.writeMetadata).toHaveBeenCalledWith('/repo/wt', { + kind: 'long-lived', + createdAt: '2026-07-22T12:00:00.000Z', + sourceBranch: 'develop', + owner: 'manual-owner', + }) + expect(lockWorktree).not.toHaveBeenCalled() + }) + + it('auto-locks when an actual agent session is present and auto-lock is enabled', async () => { + const lockWorktree = vi.fn().mockResolvedValue(undefined) + const result = await setupLifecycleMetadata( + { + flags: { owner: 'agent-7' }, + worktreeConfig, + portsConfig, + gitHelper: { + inferOwner: vi.fn().mockReturnValue('agent-7'), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree, + }, + gitRoot: '/repo', + mainRepoPath: '/repo', + resolvedPath: '/repo/wt', + sourceBranch: 'develop', + worktreeBranch: 'feature', + env: { CLAUDE_SESSION_ID: '' }, + }, + dependencies() + ) + + expect(result).toMatchObject({ kind: 'ephemeral', owner: 'agent-7', locked: true }) + expect(lockWorktree).toHaveBeenCalledWith('/repo/wt', 'pando: active session agent-7') + }) + + it('allocates ports, derives a database name, and writes both when enabled', async () => { + const deps = dependencies() + deps.allocate.mockResolvedValue({ web: 3100, api: 3101 }) + + const result = await setupLifecycleMetadata( + { + flags: {}, + worktreeConfig, + portsConfig: { + ...portsConfig, + enabled: true, + names: ['web', 'api'], + dbBaseName: 'pando', + }, + gitHelper: { + inferOwner: vi.fn().mockReturnValue(''), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree: vi.fn(), + }, + gitRoot: '/repo', + mainRepoPath: '/repo/main', + resolvedPath: '/repo/wt', + sourceBranch: 'main', + worktreeBranch: 'Feature/Ports', + env: {}, + }, + deps + ) + + expect(deps.allocate).toHaveBeenCalledWith('/repo/wt', { + range: '3100-3199', + names: ['web', 'api'], + mainRepoPath: '/repo/main', + }) + expect(deps.writeMetadata).toHaveBeenLastCalledWith('/repo/wt', { + dbName: 'pando_feature_ports', + }) + expect(result).toMatchObject({ + ports: { web: 3100, api: 3101 }, + dbName: 'pando_feature_ports', + warnings: [], + }) + }) + + it('uses the path basename for a detached worktree database name', async () => { + const deps = dependencies() + + const result = await setupLifecycleMetadata( + { + flags: {}, + worktreeConfig, + portsConfig: { ...portsConfig, enabled: true }, + gitHelper: { + inferOwner: vi.fn().mockReturnValue(''), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree: vi.fn(), + }, + gitRoot: '/repo', + mainRepoPath: '/repo/main', + resolvedPath: '/repo/worktrees/detached-preview', + sourceBranch: 'develop', + worktreeBranch: null, + env: {}, + }, + deps + ) + + expect(result.dbName).toBe('dev_detached_preview') + expect(deps.writeMetadata).toHaveBeenLastCalledWith('/repo/worktrees/detached-preview', { + dbName: 'dev_detached_preview', + }) + }) + + it('does not allocate ports when allocation is disabled', 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/main', + resolvedPath: '/repo/wt', + sourceBranch: 'main', + worktreeBranch: 'feature', + env: {}, + }, + deps + ) + + expect(deps.allocate).not.toHaveBeenCalled() + expect(result).not.toHaveProperty('ports') + expect(result).not.toHaveProperty('dbName') + }) + + it('keeps allocation failures non-fatal and warns about skipped names', async () => { + const failedDeps = dependencies() + failedDeps.allocate.mockRejectedValue(new Error('port metadata denied')) + const exhaustedDeps = dependencies() + exhaustedDeps.allocate.mockResolvedValue({ web: 3100 }) + const enabledPorts = { ...portsConfig, enabled: true, names: ['web', 'api'] } + const options = { + flags: {}, + worktreeConfig, + portsConfig: enabledPorts, + gitHelper: { + inferOwner: vi.fn().mockReturnValue(''), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree: vi.fn(), + }, + gitRoot: '/repo', + mainRepoPath: '/repo/main', + resolvedPath: '/repo/wt', + sourceBranch: 'main', + worktreeBranch: 'main', + env: {}, + } + + await expect(setupLifecycleMetadata(options, failedDeps)).resolves.toMatchObject({ + locked: false, + warnings: [expect.stringContaining('port metadata denied')], + }) + await expect(setupLifecycleMetadata(options, exhaustedDeps)).resolves.toMatchObject({ + ports: { web: 3100 }, + dbName: 'dev_main', + warnings: [expect.stringContaining('api')], + }) + }) + + it('keeps lock failures non-fatal and reports the actual unlocked state', async () => { + const lockWorktree = vi.fn().mockRejectedValue(new Error('lock denied')) + await expect( + setupLifecycleMetadata( + { + flags: { owner: 'agent-7' }, + worktreeConfig, + portsConfig, + gitHelper: { + inferOwner: vi.fn().mockReturnValue('agent-7'), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree, + }, + gitRoot: '/repo', + mainRepoPath: '/repo', + resolvedPath: '/repo/wt', + sourceBranch: 'develop', + worktreeBranch: 'feature', + env: { PANDO_SESSION: 'session-7' }, + }, + dependencies() + ) + ).resolves.toMatchObject({ + owner: 'agent-7', + locked: false, + warnings: [expect.stringContaining('lock denied')], + }) + }) + + it('includes lifecycle metadata in the single JSON success result', () => { + const { command, logSpy } = createCommand() + const setupResult = { + rsyncResult: undefined, + symlinkResult: undefined, + skipWorktreeResult: undefined, + cleanTree: true, + warnings: ['setup warning'], + details: {}, + } + + ;( + command as unknown as { + formatOutput: ( + flags: Record, + worktree: Record, + setup: Record, + lifecycle: Record, + postCommands: unknown[], + duration: number, + chalk: null + ) => void + } + ).formatOutput( + { json: true }, + { path: '/repo/wt', branch: 'feature', commit: 'abc1234' }, + setupResult, + { + kind: 'ephemeral', + owner: 'agent-7', + ttl: '30m', + effectiveTtl: '30m', + ports: { web: 3100 }, + dbName: 'dev_feature', + locked: true, + notice: 'Enabled extensions.worktreeConfig (migrated: none)', + warnings: ['metadata warning'], + }, + [], + 10, + null + ) + + expect(logSpy).toHaveBeenCalledTimes(1) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.worktree).toMatchObject({ + path: '/repo/wt', + kind: 'ephemeral', + owner: 'agent-7', + ttl: '30m', + effectiveTtl: '30m', + ports: { web: 3100 }, + dbName: 'dev_feature', + locked: true, + }) + expect(output.warnings).toEqual([ + 'setup warning', + 'Enabled extensions.worktreeConfig (migrated: none)', + 'metadata warning', + ]) + }) + + it('keeps metadata failures non-fatal after worktree creation', async () => { + const deps = dependencies() + deps.writeMetadata.mockRejectedValue(new Error('config.worktree denied')) + const lockWorktree = vi.fn().mockResolvedValue(undefined) + + await expect( + setupLifecycleMetadata( + { + flags: { ephemeral: true, ttl: '30m' }, + worktreeConfig, + portsConfig, + gitHelper: { + inferOwner: vi.fn().mockReturnValue('agent-7'), + getMainBranch: vi.fn().mockResolvedValue('main'), + lockWorktree, + }, + gitRoot: '/repo', + mainRepoPath: '/repo', + resolvedPath: '/repo/wt', + sourceBranch: 'develop', + worktreeBranch: 'feature', + env: {}, + }, + deps + ) + ).resolves.toMatchObject({ + kind: 'ephemeral', + owner: 'agent-7', + ttl: '30m', + effectiveTtl: '30m', + locked: false, + warnings: [expect.stringContaining('config.worktree denied')], + }) + expect(lockWorktree).not.toHaveBeenCalled() + }) +}) + +describe('add: JSON document consistency', () => { + const setupResult = { + rsyncResult: undefined, + symlinkResult: undefined, + skipWorktreeResult: undefined, + cleanTree: true, + warnings: ['setup warning'], + details: {}, + } + + function prepareRun(command: AddWorktree, config: PandoConfig): void { + vi.mocked(loadConfig).mockResolvedValue(config) + mockGitHelper.isRepository.mockResolvedValue(true) + mockGitHelper.getRepositoryRoot.mockResolvedValue('/repo') + mockGitHelper.getCurrentBranch.mockResolvedValue('main') + mockGitHelper.addWorktree.mockResolvedValue({ + path: '/repo/wt', + branch: 'feature', + commit: 'abc1234', + isExistingBranch: false, + }) + mockGitHelper.inferOwner.mockReturnValue('') + + const internals = command as unknown as { + runSetup: () => Promise + } + vi.spyOn(internals, 'runSetup').mockResolvedValue(setupResult) + } + + it('emits one parseable JSON document with flag, trust, and lifecycle warnings', async () => { + const { command, logSpy, warnSpy } = createCommand() + stubParse(command, { + path: '/repo/wt', + branch: 'feature', + ephemeral: true, + owner: 'agent-7', + ttl: '30m', + 'skip-rsync': true, + 'rsync-flags': ['--checksum'], + json: true, + }) + prepareRun( + command, + baseConfig({ + postCommands: { add: ['echo hi'] }, + postCommandsSourcePath: '/repo/.pando.toml', + } as Partial) + ) + vi.mocked(ensureWorktreeConfigEnabled).mockResolvedValue({ + enabled: true, + migrated: [], + notice: 'Enabled extensions.worktreeConfig (migrated: none)', + }) + vi.mocked(writeMetadata).mockRejectedValue(new Error('metadata denied')) + vi.mocked(normalizePostCommandScripts).mockReturnValue([{ command: 'echo hi' }]) + vi.mocked(isEnvTrustEnabled).mockReturnValue(false) + vi.mocked(computeConfigHash).mockResolvedValue('deadbeef') + vi.mocked(isConfigTrusted).mockResolvedValue(false) + vi.mocked(decidePostCommandTrust).mockReturnValue('skip') + + await command.run() + + expect(warnSpy).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledTimes(1) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.success).toBe(true) + expect(output.worktree).toMatchObject({ + kind: 'ephemeral', + owner: 'agent-7', + ttl: '30m', + effectiveTtl: '30m', + locked: false, + }) + expect(output.warnings).toEqual( + expect.arrayContaining([ + 'setup warning', + '--rsync-flags and --rsync-exclude are ignored when --skip-rsync is set', + expect.stringContaining('untrusted config file'), + 'Enabled extensions.worktreeConfig (migrated: none)', + expect.stringContaining('metadata denied'), + ]) + ) + }) + + it('keeps --ports allocation failures non-fatal in the command result', async () => { + const { command, logSpy, warnSpy } = createCommand() + stubParse(command, { + path: '/repo/wt', + branch: 'feature', + ports: true, + json: true, + }) + prepareRun(command, baseConfig()) + vi.mocked(allocate).mockRejectedValue(new Error('allocator unavailable')) + vi.mocked(normalizePostCommandScripts).mockReturnValue([]) + + await command.run() + + expect(warnSpy).not.toHaveBeenCalled() + expect(mockGitHelper.getMainWorktreePath).toHaveBeenCalledTimes(1) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.success).toBe(true) + expect(output.warnings).toEqual( + expect.arrayContaining([expect.stringContaining('allocator unavailable')]) + ) + }) + + it('retains lifecycle fields and warnings when a post-command fails', async () => { + const { command, logSpy, warnSpy } = createCommand() + stubParse(command, { + path: '/repo/wt', + branch: 'feature', + ephemeral: true, + owner: 'agent-7', + ttl: '30m', + json: true, + }) + prepareRun( + command, + baseConfig({ + ports: { + enabled: true, + range: '3100-3199', + names: ['web'], + dbStrategy: 'named', + dbBaseName: 'dev', + }, + }) + ) + vi.mocked(allocate).mockResolvedValue({ web: 3100 }) + vi.mocked(ensureWorktreeConfigEnabled).mockResolvedValue({ + enabled: true, + migrated: [], + notice: 'lifecycle notice', + }) + + const failedResult = { + name: 'failing-script', + command: 'exit 2', + cwd: '/repo/wt', + exitCode: 2, + signal: null, + stdout: '', + stderr: 'failed', + success: false, + duration: 1, + } + const internals = command as unknown as { + runPostCommands: () => Promise + } + vi.spyOn(internals, 'runPostCommands').mockRejectedValue( + new PostCommandError('Post-command script failed: exit 2', failedResult, [failedResult]) + ) + + await expect(command.run()).rejects.toThrow() + + expect(warnSpy).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledTimes(1) + const output = JSON.parse(logSpy.mock.calls[0]?.[0] as string) + expect(output.success).toBe(false) + expect(output.worktree).toMatchObject({ + path: '/repo/wt', + kind: 'ephemeral', + owner: 'agent-7', + ttl: '30m', + effectiveTtl: '30m', + ports: { web: 3100 }, + dbName: 'dev_feature', + locked: false, + }) + expect(output.warnings).toEqual(['setup warning', 'lifecycle notice']) + expect(output.failedPostCommand).toEqual(failedResult) + }) +}) + describe('add: branch-name validation', () => { it('rejects an invalid branch name (positional arg) before doing any git work', async () => { const { command, errorSpy } = createCommand() @@ -374,7 +992,8 @@ describe('add: post-command trust gate wiring', () => { function callRunPostCommands( command: AddWorktree, config: PandoConfig, - flags: Record + flags: Record, + resources: { ports?: Record; dbName?: string } = {} ): Promise { return ( command as unknown as { @@ -383,10 +1002,24 @@ describe('add: post-command trust gate wiring', () => { c: PandoConfig, w: typeof worktreeInfo, p: string, - s: null + s: null, + k: 'ephemeral' | 'long-lived', + t?: string, + ports?: Record, + dbName?: string ) => Promise } - ).runPostCommands(flags, config, worktreeInfo, '/wt', null) + ).runPostCommands( + flags, + config, + worktreeInfo, + '/wt', + null, + 'ephemeral', + '4h', + resources.ports, + resources.dbName + ) } it('runs post-commands when the trust decision is "run"', async () => { @@ -411,10 +1044,26 @@ describe('add: post-command trust gate wiring', () => { const config = baseConfig({ postCommandsSourcePath: '/repo/.pando.toml', } as Partial) - const result = await callRunPostCommands(command, config, { json: false }) + 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) }) diff --git a/test/commands/clean.test.ts b/test/commands/clean.test.ts index 2872ab0..804bc6a 100644 --- a/test/commands/clean.test.ts +++ b/test/commands/clean.test.ts @@ -11,6 +11,7 @@ import CleanWorktree from '../../src/commands/clean.js' // Mock the git module vi.mock('../../src/utils/git.js', () => { const mockGitHelper = { + setRetryConfig: vi.fn(), isRepository: vi.fn(), getRepositoryRoot: vi.fn(), getStaleWorktrees: vi.fn(), @@ -37,6 +38,7 @@ vi.mock('../../src/config/loader.js', () => ({ targetBranch: 'main', }, clean: { fetch: false }, + concurrency: { retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 } }, }), })) @@ -53,6 +55,7 @@ describe('clean', () => { function _getMockGitHelper() { return { + setRetryConfig: vi.fn(), isRepository: vi.fn(), getRepositoryRoot: vi.fn(), getStaleWorktrees: vi.fn(), diff --git a/test/commands/config/init.test.ts b/test/commands/config/init.test.ts index 490e96f..b96762d 100644 --- a/test/commands/config/init.test.ts +++ b/test/commands/config/init.test.ts @@ -82,6 +82,9 @@ describe('config init', () => { expect(config).toHaveProperty('rsync') expect(config).toHaveProperty('symlink') expect(config).toHaveProperty('worktree') + expect(config).toHaveProperty('reap') + expect(config).toHaveProperty('concurrency.retry') + expect(config).toHaveProperty('ports') // Check rsync defaults const rsync = config.rsync as Record @@ -99,6 +102,22 @@ describe('config init', () => { const worktree = config.worktree as Record expect(worktree.rebaseOnAdd).toBe(true) expect(worktree.deleteBranchOnRemove).toBe('local') + expect(worktree.defaultKind).toBe('auto') + expect(worktree.ephemeralTtl).toBe('4h') + expect(worktree.autoLockActive).toBe(true) + + // Check lifecycle, retry, and port defaults + expect(config.reap).toEqual({ requireMerged: true }) + expect(config.concurrency).toEqual({ + retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 }, + }) + expect(config.ports).toEqual({ + enabled: false, + range: '3100-3199', + names: ['web'], + dbStrategy: 'named', + dbBaseName: 'dev', + }) }) it('should include helpful comments in output', async () => { @@ -121,6 +140,12 @@ describe('config init', () => { expect(content).toContain('# Symlink Configuration') expect(content).toContain('# Patterns support glob syntax') expect(content).toContain('# Worktree Configuration') + expect(content).toContain('[reap]') + expect(content).toContain('# Reap Configuration') + expect(content).toContain('[concurrency.retry]') + expect(content).toContain('# Concurrency Retry Configuration') + expect(content).toContain('[ports]') + expect(content).toContain('# Port Allocation Configuration') }) it('should output JSON when --json flag is used', async () => { @@ -242,6 +267,88 @@ patterns = ["node_modules"] expect(config.worktree).toBeDefined() }) + it('should add lifecycle defaults to a legacy config', async () => { + const configPath = path.join(tempDir, '.pando.toml') + + await fs.writeFile( + configPath, + `[rsync] +enabled = true +flags = ["--archive"] +exclude = [] +onlyUntracked = true + +[symlink] +patterns = [] +relative = true +beforeRsync = true +allowTracked = true + +[worktree] +rebaseOnAdd = true +deleteBranchOnRemove = "local" +useProjectSubfolder = false +targetBranch = "main" + +[clean] +fetch = false + +[postCommands] +` + ) + + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { json: true, global: false, 'git-root': false, force: false, merge: true }, + args: {}, + } as any) + + await command.run() + + const jsonOutput = logSpy.mock.calls[0]?.[0] as string + const result = JSON.parse(jsonOutput) + const addedPaths = result.added.map((setting: { path: string }) => setting.path) + + expect(result.action).toBe('merged') + expect(addedPaths).toEqual([ + 'worktree.defaultKind', + 'worktree.ephemeralTtl', + 'worktree.autoLockActive', + 'reap', + 'concurrency', + 'ports', + ]) + + const config = parseToml(await fs.readFile(configPath, 'utf-8')) as Record + expect(config.worktree).toMatchObject({ + defaultKind: 'auto', + ephemeralTtl: '4h', + autoLockActive: true, + }) + expect(config.reap).toEqual({ requireMerged: true }) + expect(config.concurrency.retry).toEqual({ maxAttempts: 5, baseMs: 100, capMs: 2000 }) + expect(config.ports).toMatchObject({ enabled: false, range: '3100-3199' }) + }) + + it('should preserve customized lifecycle values while merging defaults', async () => { + const configPath = path.join(tempDir, '.pando.toml') + await fs.writeFile(configPath, '[worktree]\nephemeralTtl = "2h"\n') + + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { json: true, global: false, 'git-root': false, force: false, merge: true }, + args: {}, + } as any) + + await command.run() + + const config = parseToml(await fs.readFile(configPath, 'utf-8')) as Record + expect(config.worktree.ephemeralTtl).toBe('2h') + + const jsonOutput = logSpy.mock.calls[0]?.[0] as string + const result = JSON.parse(jsonOutput) + const addedPaths = result.added.map((setting: { path: string }) => setting.path) + expect(addedPaths).not.toContain('worktree.ephemeralTtl') + }) + it('should report nothing added when config is complete', async () => { const _configPath = path.join(tempDir, '.pando.toml') diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts index 274520b..cea1ea6 100644 --- a/test/commands/health.test.ts +++ b/test/commands/health.test.ts @@ -2,10 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Health from '../../src/commands/health' import { createGitHelper } from '../../src/utils/git' import { ErrorHelper } from '../../src/utils/errors' +import { readMetadata } from '../../src/utils/worktreeMetadata' // Mock the dependencies vi.mock('../../src/utils/git') vi.mock('../../src/utils/errors') +vi.mock('../../src/utils/worktreeMetadata', () => ({ + readMetadata: vi.fn(), +})) vi.mock('chalk', () => ({ default: { bold: (str: string) => `**${str}**`, @@ -30,6 +34,7 @@ type MockGitHelper = { getTrackingBranch: MockFn remoteBranchExists: MockFn countCommitsBetween: MockFn + getWorktreeAgeMs: MockFn } describe('Health Command', () => { @@ -62,7 +67,9 @@ describe('Health Command', () => { getTrackingBranch: vi.fn().mockResolvedValue(null), remoteBranchExists: vi.fn(), countCommitsBetween: vi.fn(), + getWorktreeAgeMs: vi.fn().mockResolvedValue(60_000), } + vi.mocked(readMetadata).mockResolvedValue({}) mockErrorHelper = { validation: vi.fn(), @@ -104,12 +111,14 @@ describe('Health Command', () => { }) it('should detect uncommitted changes in worktrees', async () => { + vi.mocked(readMetadata).mockResolvedValue({ kind: 'ephemeral' }) mockGitHelper.listWorktrees.mockResolvedValue([ { path: '/worktree1', branch: 'feature/auth', commit: 'abc123', isPrunable: false, + isLocked: true, }, ]) mockGitHelper.hasUncommittedChanges.mockResolvedValue(true) @@ -122,6 +131,9 @@ describe('Health Command', () => { expect(mockGitHelper.hasUncommittedChanges).toHaveBeenCalledWith('/worktree1') expect(mockGitHelper.getUncommittedFileCount).toHaveBeenCalledWith('/worktree1') expect(command.log).toHaveBeenCalledWith(expect.stringContaining('2 files modified')) + expect(command.log).toHaveBeenCalledWith( + expect.stringContaining('Lifecycle: ephemeral, locked') + ) }) it('should detect branches behind upstream', async () => { @@ -291,12 +303,19 @@ describe('Health Command', () => { }) it('should output JSON format when --json flag is used', async () => { + vi.mocked(readMetadata).mockResolvedValue({ + kind: 'ephemeral', + owner: 'agent-7', + ttl: '4h', + }) + mockGitHelper.getWorktreeAgeMs.mockResolvedValue(3_600_000) mockGitHelper.listWorktrees.mockResolvedValue([ { path: '/worktree1', branch: 'feature/auth', commit: 'abc123', isPrunable: false, + isLocked: true, }, ]) mockGitHelper.hasUncommittedChanges.mockResolvedValue(false) @@ -319,7 +338,14 @@ describe('Health Command', () => { gone: 0, errors: 0, }) - expect(report.worktrees[0].status).toBe('clean') + expect(report.worktrees[0]).toMatchObject({ + status: 'clean', + kind: 'ephemeral', + ageMs: 3_600_000, + ttl: '4h', + locked: true, + owner: 'agent-7', + }) }) it('should handle errors gracefully', async () => { diff --git a/test/commands/list.test.ts b/test/commands/list.test.ts index e369328..8ff2ab9 100644 --- a/test/commands/list.test.ts +++ b/test/commands/list.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import ListWorktree from '../../src/commands/list' import * as gitUtils from '../../src/utils/git' import type { WorktreeInfo } from '../../src/utils/git' +import { readMetadata } from '../../src/utils/worktreeMetadata' + +vi.mock('../../src/utils/worktreeMetadata', () => ({ + readMetadata: vi.fn(), +})) /** * Tests for list command @@ -55,6 +60,8 @@ describe('list', () => { // Reset all mocks before each test vi.restoreAllMocks() + vi.mocked(readMetadata).mockResolvedValue({}) + // Create command instance command = new ListWorktree([], {} as any) @@ -77,6 +84,7 @@ describe('list', () => { vi.spyOn(gitUtils, 'createGitHelper').mockReturnValue({ isRepository: vi.fn().mockResolvedValue(true), listWorktrees: vi.fn().mockResolvedValue(mockWorktrees), + getWorktreeAgeMs: vi.fn().mockResolvedValue(60_000), } as any) // Mock parse to return empty flags @@ -94,13 +102,27 @@ describe('list', () => { const calls = logSpy.mock.calls const outputs = calls.map((call: any) => call[0]).join('\n') expect(outputs).toContain('Found 3 worktree(s)') + expect(outputs).not.toContain('Lifecycle:') }) it('should output JSON format when --json flag is used', async () => { + vi.mocked(readMetadata).mockResolvedValue({ + kind: 'ephemeral', + createdAt: '2026-07-22T11:00:00.000Z', + owner: 'agent-7', + ttl: '4h', + }) // Mock the GitHelper vi.spyOn(gitUtils, 'createGitHelper').mockReturnValue({ isRepository: vi.fn().mockResolvedValue(true), - listWorktrees: vi.fn().mockResolvedValue(mockWorktrees), + listWorktrees: vi + .fn() + .mockResolvedValue([ + mockWorktrees[0], + { ...mockWorktrees[1], isLocked: true }, + mockWorktrees[2], + ]), + getWorktreeAgeMs: vi.fn().mockResolvedValue(3_600_000), } as any) // Mock parse to return json flag @@ -117,13 +139,23 @@ describe('list', () => { const jsonOutput = logSpy.mock.calls[0][0] const parsed = JSON.parse(jsonOutput) expect(parsed.worktrees).toHaveLength(3) + expect(parsed.worktrees[1]).toMatchObject({ + kind: 'ephemeral', + createdAt: '2026-07-22T11:00:00.000Z', + owner: 'agent-7', + ttl: '4h', + ageMs: 3_600_000, + locked: true, + }) }) it('should show verbose information when --verbose flag is set', async () => { + vi.mocked(readMetadata).mockResolvedValue({ kind: 'long-lived', owner: 'developer' }) // Mock the GitHelper vi.spyOn(gitUtils, 'createGitHelper').mockReturnValue({ isRepository: vi.fn().mockResolvedValue(true), listWorktrees: vi.fn().mockResolvedValue(mockWorktrees), + getWorktreeAgeMs: vi.fn().mockResolvedValue(3_600_000), } as any) // Mock parse to return verbose flag @@ -138,6 +170,7 @@ describe('list', () => { const calls = logSpy.mock.calls const outputs = calls.map((call: any) => call[0]).join('\n') expect(outputs).toContain('Commit: abc123def456') + expect(outputs).toContain('Lifecycle: kind long-lived, owner developer, age 1h, unlocked') }) it('should handle empty worktree list', async () => { @@ -187,6 +220,7 @@ describe('list', () => { vi.spyOn(gitUtils, 'createGitHelper').mockReturnValue({ isRepository: vi.fn().mockResolvedValue(true), listWorktrees: vi.fn().mockResolvedValue(mockPrunableWorktrees), + getWorktreeAgeMs: vi.fn().mockResolvedValue(60_000), } as any) // Mock parse to return empty flags @@ -208,6 +242,7 @@ describe('list', () => { vi.spyOn(gitUtils, 'createGitHelper').mockReturnValue({ isRepository: vi.fn().mockResolvedValue(true), listWorktrees: vi.fn().mockResolvedValue(mockPrunableWorktrees), + getWorktreeAgeMs: vi.fn().mockResolvedValue(60_000), } as any) // Mock parse to return verbose flag diff --git a/test/commands/lock.test.ts b/test/commands/lock.test.ts new file mode 100644 index 0000000..1d7a139 --- /dev/null +++ b/test/commands/lock.test.ts @@ -0,0 +1,112 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LockWorktree, { resolveLockTarget } from '../../src/commands/lock.js' +import type { WorktreeInfo } from '../../src/utils/git.js' + +const mockGitHelper = { + isRepository: vi.fn(), + getRepositoryRoot: vi.fn(), + listWorktrees: vi.fn(), + lockWorktree: vi.fn(), +} + +vi.mock('../../src/utils/git.js', () => ({ + GitHelper: vi.fn(() => mockGitHelper), + createGitHelper: vi.fn(() => mockGitHelper), +})) + +const worktrees: WorktreeInfo[] = [ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/repo/worktrees/feature', branch: 'feature/x', commit: 'b', isPrunable: false }, +] + +function createCommand(argv: string[]): LockWorktree { + const command = new LockWorktree(argv, { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + vi.spyOn(command, 'log').mockImplementation(() => {}) + vi.spyOn(command, 'error').mockImplementation(((message: string | Error) => { + throw new Error(typeof message === 'string' ? message : message.message) + }) as never) + return command +} + +describe('resolveLockTarget', () => { + it('resolves paths relative to cwd and the repository', () => { + expect( + resolveLockTarget('../worktrees/feature', worktrees, '/repo', '/repo/subdir')?.path + ).toBe('/repo/worktrees/feature') + expect(resolveLockTarget('worktrees/feature', worktrees, '/repo', '/elsewhere')?.path).toBe( + '/repo/worktrees/feature' + ) + }) + + it('canonicalizes symlinked paths before matching', () => { + const temporaryRoot = mkdtempSync(path.join(tmpdir(), 'pando-lock-')) + const actualPath = path.join(temporaryRoot, 'actual') + const linkedPath = path.join(temporaryRoot, 'linked') + + try { + mkdirSync(actualPath) + symlinkSync(actualPath, linkedPath, 'dir') + const symlinkedWorktrees: WorktreeInfo[] = [ + { path: actualPath, branch: 'feature/x', commit: 'b', isPrunable: false }, + ] + + expect(resolveLockTarget(linkedPath, symlinkedWorktrees, temporaryRoot)?.path).toBe( + actualPath + ) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } + }) + + it('resolves exact branch names', () => { + expect(resolveLockTarget('feature/x', worktrees, '/repo')?.path).toBe('/repo/worktrees/feature') + }) +}) + +describe('lock command', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGitHelper.isRepository.mockResolvedValue(true) + mockGitHelper.getRepositoryRoot.mockResolvedValue('/repo') + mockGitHelper.listWorktrees.mockResolvedValue(worktrees) + mockGitHelper.lockWorktree.mockResolvedValue(undefined) + }) + + it('locks a worktree resolved from a path flag with a reason', async () => { + const command = createCommand([ + '--path', + 'worktrees/feature', + '--reason', + 'active session', + '--json', + ]) + + await command.run() + + expect(mockGitHelper.lockWorktree).toHaveBeenCalledWith( + '/repo/worktrees/feature', + 'active session' + ) + expect(command.log).toHaveBeenCalledWith(expect.stringContaining('"locked": true')) + }) + + it('locks a worktree resolved from a positional branch name', async () => { + const command = createCommand(['feature/x']) + + await command.run() + + expect(mockGitHelper.lockWorktree).toHaveBeenCalledWith('/repo/worktrees/feature', undefined) + }) + + it('rejects a target that is not an actual worktree', async () => { + const command = createCommand(['missing']) + + await expect(command.run()).rejects.toThrow('Worktree not found') + expect(mockGitHelper.lockWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/test/commands/reap.test.ts b/test/commands/reap.test.ts new file mode 100644 index 0000000..a85e317 --- /dev/null +++ b/test/commands/reap.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ReapWorktree, { + parseTtl, + partitionReapClean, + selectReapable, + type ReapCandidate, + type ReapSelectionWorktree, +} from '../../src/commands/reap.js' + +const { mockGitHelper, mockEnumerateAll, mockStatus } = vi.hoisted(() => ({ + mockGitHelper: { + setRetryConfig: vi.fn(), + isRepository: vi.fn(), + getRepositoryRoot: vi.fn(), + getMainWorktreePath: vi.fn(), + listWorktrees: vi.fn(), + getWorktreeAgeMs: vi.fn(), + hasUncommittedChanges: vi.fn(), + isReapClean: vi.fn(), + removeWorktree: vi.fn(), + branchExists: vi.fn(), + deleteBranch: vi.fn(), + }, + mockEnumerateAll: vi.fn(), + mockStatus: vi.fn(), +})) + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(() => ({ status: mockStatus })), +})) + +vi.mock('../../src/utils/git.js', () => ({ + GitHelper: vi.fn(() => mockGitHelper), + createGitHelper: vi.fn(() => mockGitHelper), +})) + +vi.mock('../../src/utils/worktreeMetadata.js', () => ({ + enumerateAll: mockEnumerateAll, +})) + +vi.mock('../../src/config/loader.js', () => ({ + loadConfig: vi.fn().mockResolvedValue({ + worktree: { ephemeralTtl: '4h', targetBranch: 'main' }, + reap: { requireMerged: true }, + concurrency: { retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 } }, + }), +})) + +vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })) + +const now = Date.parse('2026-07-22T12:00:00.000Z') +const config = { worktree: { ephemeralTtl: '4h' } } + +function selectionWorktree( + path: string, + overrides: Partial = {} +): ReapSelectionWorktree { + return { + worktreePath: path, + metadata: { kind: 'ephemeral', owner: 'session-a' }, + ageMs: 5 * 60 * 60 * 1000, + branch: path.slice(1), + ...overrides, + } +} + +function candidate(path: string): ReapCandidate { + return { path, branch: path.slice(1), kind: 'ephemeral', ageMs: 20_000 } +} + +describe('parseTtl', () => { + it.each([ + ['90s', 90_000], + ['30m', 1_800_000], + ['4h', 14_400_000], + ['2d', 172_800_000], + ['1500', 1500], + ])('parses %s', (ttl, expected) => { + expect(parseTtl(ttl)).toBe(expected) + }) + + it.each(['', 'four hours', '4w', '-1h', '1h30m', 'Infinity'])('rejects %s', (ttl) => { + expect(parseTtl(ttl)).toBeNull() + }) +}) + +describe('selectReapable', () => { + it('applies the kind, age, lock, and owner selection matrix', () => { + const worktrees = [ + selectionWorktree('/eligible'), + selectionWorktree('/long-lived', { metadata: { kind: 'long-lived', owner: 'session-a' } }), + selectionWorktree('/young', { ageMs: 60_000 }), + selectionWorktree('/locked', { isLocked: true }), + selectionWorktree('/other-owner', { + metadata: { kind: 'ephemeral', owner: 'session-b' }, + }), + ] + + expect(selectReapable(worktrees, { now, config, owner: 'session-a' })).toEqual([ + expect.objectContaining({ path: '/eligible', kind: 'ephemeral' }), + ]) + }) + + it('uses createdAt with now when an observed age is not provided', () => { + const old = selectionWorktree('/old', { + ageMs: undefined, + metadata: { + kind: 'ephemeral', + createdAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + }, + }) + + expect(selectReapable([old], { now, config })).toHaveLength(1) + }) + + it('honors metadata TTL overrides and fails closed on invalid TTLs', () => { + const expiredOverride = selectionWorktree('/override', { + ageMs: 31 * 60 * 1000, + metadata: { kind: 'ephemeral', ttl: '30m' }, + }) + const invalid = selectionWorktree('/invalid', { + metadata: { kind: 'ephemeral', ttl: 'soon' }, + }) + + expect(selectReapable([expiredOverride, invalid], { now, config })).toEqual([ + expect.objectContaining({ path: '/override' }), + ]) + }) + + it('requires age to be strictly greater than TTL', () => { + const atTtl = selectionWorktree('/at-ttl', { ageMs: 4 * 60 * 60 * 1000 }) + expect(selectReapable([atTtl], { now, config })).toEqual([]) + }) +}) + +describe('partitionReapClean', () => { + it('partitions clean, dirty, unmerged, and errored candidates fail-closed', async () => { + const dependencies = { + hasUncommittedChanges: vi.fn(async (path: string) => { + if (path === '/dirty') return true + if (path === '/error') throw new Error('status unavailable') + return false + }), + isReapClean: vi.fn(async (path: string) => path !== '/unmerged'), + } + + const result = await partitionReapClean( + [candidate('/clean'), candidate('/dirty'), candidate('/unmerged'), candidate('/error')], + { targetBranch: 'main', requireMerged: true }, + dependencies + ) + + expect(result.clean.map(({ path }) => path)).toEqual(['/clean']) + expect(result.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/dirty', reason: expect.stringContaining('dirty') }), + expect.objectContaining({ path: '/unmerged', reason: expect.stringContaining('unmerged') }), + expect.objectContaining({ path: '/error', reason: expect.stringContaining('failed') }), + ]) + ) + }) + + it('checks only uncommitted changes when merged branches are not required', async () => { + const dependencies = { + hasUncommittedChanges: vi.fn().mockResolvedValue(false), + isReapClean: vi.fn().mockResolvedValue(false), + } + + const result = await partitionReapClean( + [candidate('/unmerged-but-clean')], + { targetBranch: 'main', requireMerged: false }, + dependencies + ) + + expect(result.clean).toHaveLength(1) + expect(dependencies.isReapClean).not.toHaveBeenCalled() + }) +}) + +describe('reap command', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGitHelper.isRepository.mockResolvedValue(true) + mockGitHelper.getRepositoryRoot.mockResolvedValue('/repo') + mockGitHelper.getMainWorktreePath.mockResolvedValue('/repo') + mockGitHelper.listWorktrees.mockResolvedValue([ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/wt/old', branch: 'old', commit: 'b', isPrunable: false }, + { path: '/wt/locked', branch: 'locked', commit: 'c', isPrunable: false, isLocked: true }, + ]) + mockEnumerateAll.mockResolvedValue([ + { worktreePath: '/repo', metadata: { kind: 'long-lived' } }, + { worktreePath: '/wt/old', metadata: { kind: 'ephemeral' } }, + { worktreePath: '/wt/locked', metadata: { kind: 'ephemeral' } }, + ]) + mockGitHelper.getWorktreeAgeMs.mockResolvedValue(5 * 60 * 60 * 1000) + mockStatus.mockResolvedValue({ isClean: () => true }) + mockGitHelper.isReapClean.mockResolvedValue(true) + mockGitHelper.removeWorktree.mockResolvedValue(undefined) + mockGitHelper.branchExists.mockResolvedValue(true) + mockGitHelper.deleteBranch.mockResolvedValue(undefined) + }) + + it('reaps only clean, expired, unlocked ephemeral worktrees in JSON mode', async () => { + const command = new ReapWorktree(['--json'], { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + + await command.run() + + expect(mockGitHelper.removeWorktree).toHaveBeenCalledWith('/wt/old') + expect(mockGitHelper.removeWorktree).not.toHaveBeenCalledWith('/wt/locked') + expect(mockGitHelper.deleteBranch).toHaveBeenCalledWith('old') + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(payload.reaped).toEqual([expect.objectContaining({ path: '/wt/old' })]) + expect(payload.errors).toEqual([]) + }) + + it('exits 1 after emitting one JSON document when only some removals fail', async () => { + mockGitHelper.listWorktrees.mockResolvedValue([ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/wt/old', branch: 'old', commit: 'b', isPrunable: false }, + { path: '/wt/other', branch: 'other', commit: 'c', isPrunable: false }, + ]) + mockEnumerateAll.mockResolvedValue([ + { worktreePath: '/repo', metadata: { kind: 'long-lived' } }, + { worktreePath: '/wt/old', metadata: { kind: 'ephemeral' } }, + { worktreePath: '/wt/other', metadata: { kind: 'ephemeral' } }, + ]) + mockGitHelper.removeWorktree.mockImplementation(async (worktreePath: string) => { + if (worktreePath === '/wt/old') throw new Error('removal failed') + }) + const command = new ReapWorktree(['--json'], { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + + await expect(command.run()).rejects.toMatchObject({ oclif: { exit: 1 } }) + + expect(logSpy).toHaveBeenCalledTimes(1) + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(payload.status).toBe('success') + expect(payload.reaped).toEqual([expect.objectContaining({ path: '/wt/other' })]) + expect(payload.errors).toEqual([ + expect.objectContaining({ path: '/wt/old', error: 'removal failed' }), + ]) + }) + + it('skips a worktree that was locked after candidate selection', async () => { + mockGitHelper.listWorktrees + .mockResolvedValueOnce([ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/wt/old', branch: 'old', commit: 'b', isPrunable: false }, + ]) + .mockResolvedValueOnce([ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { + path: '/wt/old', + branch: 'old', + commit: 'b', + isPrunable: false, + isLocked: true, + }, + ]) + mockEnumerateAll.mockResolvedValue([ + { worktreePath: '/repo', metadata: { kind: 'long-lived' } }, + { worktreePath: '/wt/old', metadata: { kind: 'ephemeral' } }, + ]) + const command = new ReapWorktree(['--json'], { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + + await command.run() + + expect(mockGitHelper.listWorktrees).toHaveBeenCalledTimes(2) + expect(mockGitHelper.removeWorktree).not.toHaveBeenCalled() + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(payload.skipped).toContainEqual({ + path: '/wt/old', + reason: 'locked after selection', + }) + }) + + it('revalidates each candidate lock immediately before its removal', async () => { + let secondLocked = false + mockGitHelper.listWorktrees.mockImplementation(async () => [ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/wt/first', branch: 'first', commit: 'b', isPrunable: false }, + { + path: '/wt/second', + branch: 'second', + commit: 'c', + isPrunable: false, + isLocked: secondLocked, + }, + ]) + mockEnumerateAll.mockResolvedValue([ + { worktreePath: '/repo', metadata: { kind: 'long-lived' } }, + { worktreePath: '/wt/first', metadata: { kind: 'ephemeral' } }, + { worktreePath: '/wt/second', metadata: { kind: 'ephemeral' } }, + ]) + mockGitHelper.removeWorktree.mockImplementation(async (worktreePath: string) => { + if (worktreePath === '/wt/first') secondLocked = true + }) + const command = new ReapWorktree(['--json'], { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + + await command.run() + + expect(mockGitHelper.listWorktrees).toHaveBeenCalledTimes(3) + expect(mockGitHelper.removeWorktree).toHaveBeenCalledTimes(1) + expect(mockGitHelper.removeWorktree).toHaveBeenCalledWith('/wt/first') + expect(mockGitHelper.removeWorktree).not.toHaveBeenCalledWith('/wt/second') + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(payload.reaped).toEqual([expect.objectContaining({ path: '/wt/first' })]) + expect(payload.skipped).toContainEqual({ + path: '/wt/second', + reason: expect.stringContaining('locked'), + }) + }) + + it('does not mutate anything during a dry run and reports unsafe candidates', async () => { + mockStatus.mockResolvedValue({ isClean: () => false }) + const command = new ReapWorktree(['--dry-run', '--json'], { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + const logSpy = vi.spyOn(command, 'log').mockImplementation(() => {}) + + await command.run() + + expect(mockGitHelper.removeWorktree).not.toHaveBeenCalled() + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(payload.skipped[0].reason).toContain('dirty') + }) +}) diff --git a/test/commands/remove.test.ts b/test/commands/remove.test.ts index 9bf122f..aaee697 100644 --- a/test/commands/remove.test.ts +++ b/test/commands/remove.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { Errors } from '@oclif/core' import { WorktreeInfo } from '../../src/utils/git.js' import RemoveWorktree from '../../src/commands/remove.js' +import { readMetadata } from '../../src/utils/worktreeMetadata.js' /** * Tests for remove command @@ -12,6 +13,7 @@ import RemoveWorktree from '../../src/commands/remove.js' // Mock the git module vi.mock('../../src/utils/git.js', () => { const mockGitHelper = { + setRetryConfig: vi.fn(), isRepository: vi.fn(), getRepositoryRoot: vi.fn(), listWorktrees: vi.fn(), @@ -30,12 +32,17 @@ vi.mock('../../src/utils/git.js', () => { } }) +vi.mock('../../src/utils/worktreeMetadata.js', () => ({ + readMetadata: vi.fn().mockResolvedValue({}), +})) + // Mock the config loader vi.mock('../../src/config/loader.js', () => ({ loadConfig: vi.fn().mockResolvedValue({ rsync: { enabled: true, flags: ['--archive'], exclude: [] }, symlink: { patterns: [], relative: true, beforeRsync: true }, worktree: { rebaseOnAdd: true, deleteBranchOnRemove: 'local' }, + concurrency: { retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 } }, }), })) @@ -94,6 +101,7 @@ describe('remove', () => { // Reset all mocks vi.clearAllMocks() + vi.mocked(readMetadata).mockResolvedValue({}) }) it('should successfully remove a worktree', async () => { @@ -129,12 +137,31 @@ describe('remove', () => { mockGitHelper.listWorktrees.mockResolvedValue(mockWorktrees) mockGitHelper.hasUncommittedChanges.mockResolvedValue(false) mockGitHelper.removeWorktree.mockResolvedValue(undefined) + vi.mocked(readMetadata).mockResolvedValue({ + kind: 'ephemeral', + owner: 'agent-7', + ports: { web: 3100 }, + dbName: 'dev_feature', + }) command.argv = ['--path', '/path/to/feature', '--json'] await command.run() - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/"success"\s*:\s*true/)) - expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/"path"\s*:\s*"\/path\/to\/feature"/)) + expect(readMetadata).toHaveBeenCalledWith('/path/to/feature') + expect(vi.mocked(readMetadata).mock.invocationCallOrder[0]).toBeLessThan( + mockGitHelper.removeWorktree.mock.invocationCallOrder[0] + ) + const payload = parseLoggedJsonObjects(logSpy.mock.calls)[0] + expect(payload).toMatchObject({ + success: true, + path: '/path/to/feature', + metadata: { + kind: 'ephemeral', + owner: 'agent-7', + ports: { web: 3100 }, + dbName: 'dev_feature', + }, + }) }) it('should error when not in a git repository', async () => { diff --git a/test/commands/unlock.test.ts b/test/commands/unlock.test.ts new file mode 100644 index 0000000..183ad55 --- /dev/null +++ b/test/commands/unlock.test.ts @@ -0,0 +1,105 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UnlockWorktree, { resolveUnlockTarget } from '../../src/commands/unlock.js' +import type { WorktreeInfo } from '../../src/utils/git.js' + +const mockGitHelper = { + isRepository: vi.fn(), + getRepositoryRoot: vi.fn(), + listWorktrees: vi.fn(), + unlockWorktree: vi.fn(), +} + +vi.mock('../../src/utils/git.js', () => ({ + GitHelper: vi.fn(() => mockGitHelper), + createGitHelper: vi.fn(() => mockGitHelper), +})) + +const worktrees: WorktreeInfo[] = [ + { path: '/repo', branch: 'main', commit: 'a', isPrunable: false }, + { path: '/repo/worktrees/feature', branch: 'feature/x', commit: 'b', isPrunable: false }, +] + +function createCommand(argv: string[]): UnlockWorktree { + const command = new UnlockWorktree(argv, { + runHook: vi.fn().mockResolvedValue({ successes: [] }), + } as never) + vi.spyOn(command, 'log').mockImplementation(() => {}) + vi.spyOn(command, 'error').mockImplementation(((message: string | Error) => { + throw new Error(typeof message === 'string' ? message : message.message) + }) as never) + return command +} + +describe('resolveUnlockTarget', () => { + it('resolves paths relative to cwd and the repository', () => { + expect( + resolveUnlockTarget('../worktrees/feature', worktrees, '/repo', '/repo/subdir')?.path + ).toBe('/repo/worktrees/feature') + expect(resolveUnlockTarget('worktrees/feature', worktrees, '/repo', '/elsewhere')?.path).toBe( + '/repo/worktrees/feature' + ) + }) + + it('canonicalizes symlinked paths before matching', () => { + const temporaryRoot = mkdtempSync(path.join(tmpdir(), 'pando-unlock-')) + const actualPath = path.join(temporaryRoot, 'actual') + const linkedPath = path.join(temporaryRoot, 'linked') + + try { + mkdirSync(actualPath) + symlinkSync(actualPath, linkedPath, 'dir') + const symlinkedWorktrees: WorktreeInfo[] = [ + { path: actualPath, branch: 'feature/x', commit: 'b', isPrunable: false }, + ] + + expect(resolveUnlockTarget(linkedPath, symlinkedWorktrees, temporaryRoot)?.path).toBe( + actualPath + ) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } + }) + + it('resolves exact branch names', () => { + expect(resolveUnlockTarget('feature/x', worktrees, '/repo')?.path).toBe( + '/repo/worktrees/feature' + ) + }) +}) + +describe('unlock command', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGitHelper.isRepository.mockResolvedValue(true) + mockGitHelper.getRepositoryRoot.mockResolvedValue('/repo') + mockGitHelper.listWorktrees.mockResolvedValue(worktrees) + mockGitHelper.unlockWorktree.mockResolvedValue(undefined) + }) + + it('unlocks a worktree resolved from a path flag', async () => { + const command = createCommand(['--path', 'worktrees/feature', '--json']) + + await command.run() + + expect(mockGitHelper.unlockWorktree).toHaveBeenCalledWith('/repo/worktrees/feature') + expect(command.log).toHaveBeenCalledWith(expect.stringContaining('"locked": false')) + }) + + it('unlocks a worktree resolved from a positional branch name', async () => { + const command = createCommand(['feature/x']) + + await command.run() + + expect(mockGitHelper.unlockWorktree).toHaveBeenCalledWith('/repo/worktrees/feature') + }) + + it('rejects a target that is not an actual worktree', async () => { + const command = createCommand(['missing']) + + await expect(command.run()).rejects.toThrow('Worktree not found') + expect(mockGitHelper.unlockWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/test/config/env.test.ts b/test/config/env.test.ts index 0e88934..5a08f57 100644 --- a/test/config/env.test.ts +++ b/test/config/env.test.ts @@ -188,6 +188,13 @@ describe('parseEnvValue', () => { expect(parseEnvValue('PANDO_SYMLINK_ALLOW_TRACKED', 'no')).toBe(false) }) + it('should preserve a non-numeric number value for schema validation', () => { + const parsed = parseEnvValue('PANDO_CONCURRENCY_RETRY_MAX_ATTEMPTS', 'abc') + + expect(parsed).toBe('abc') + expect(Number.isNaN(parsed)).toBe(false) + }) + it('should return string for unknown keys', () => { expect(parseEnvValue('PANDO_UNKNOWN_KEY', 'value')).toBe('value') }) @@ -272,6 +279,28 @@ describe('parseEnvVars', () => { }) }) + it('should parse lifecycle values using their schema-compatible types', () => { + const env = { + PANDO_WORKTREE_DEFAULT_KIND: 'ephemeral', + PANDO_WORKTREE_AUTO_LOCK_ACTIVE: 'false', + PANDO_CONCURRENCY_RETRY_MAX_ATTEMPTS: '9', + PANDO_PORTS_NAMES: 'web,api', + } + + expect(parseEnvVars(env)).toEqual({ + worktree: { + defaultKind: 'ephemeral', + autoLockActive: false, + }, + concurrency: { + retry: { maxAttempts: 9 }, + }, + ports: { + names: ['web', 'api'], + }, + }) + }) + it('should parse mixed variables', () => { const env = { PANDO_RSYNC_ENABLED: 'true', diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts new file mode 100644 index 0000000..6ed0b80 --- /dev/null +++ b/test/config/schema.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_CONFIG, + PandoConfigSchema, + PartialPandoConfigSchema, +} from '../../src/config/schema' + +describe('configuration schema', () => { + it('applies the lifecycle defaults from empty section objects', () => { + const config = PandoConfigSchema.parse({ + rsync: {}, + symlink: {}, + worktree: {}, + clean: {}, + reap: {}, + concurrency: { retry: {} }, + ports: {}, + }) + + expect(config).toEqual(DEFAULT_CONFIG) + expect(config.worktree).toMatchObject({ + defaultKind: 'auto', + ephemeralTtl: '4h', + autoLockActive: true, + }) + expect(config.reap).toEqual({ requireMerged: true }) + expect(config.concurrency).toEqual({ + retry: { maxAttempts: 5, baseMs: 100, capMs: 2000 }, + }) + expect(config.ports).toEqual({ + enabled: false, + range: '3100-3199', + names: ['web'], + dbStrategy: 'named', + dbBaseName: 'dev', + }) + }) + + it('accepts a partial ports object without applying defaults', () => { + expect(PartialPandoConfigSchema.parse({ ports: { range: '4100-4199' } })).toEqual({ + ports: { range: '4100-4199' }, + }) + }) +}) diff --git a/test/plugin/plugin.test.ts b/test/plugin/plugin.test.ts new file mode 100644 index 0000000..a9d40f8 --- /dev/null +++ b/test/plugin/plugin.test.ts @@ -0,0 +1,372 @@ +import { + chmod, + mkdtemp, + mkdir, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const repositoryRoot = process.cwd() +const pluginRoot = path.join(repositoryRoot, 'plugin') +const worktreeCreateScript = path.join(pluginRoot, 'scripts/worktree-create.sh') +const sessionEndScript = path.join(pluginRoot, 'scripts/session-end.sh') + +function commandExists(command: string): boolean { + return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0 +} + +function resolveCommand(command: string): string { + const result = spawnSync('bash', ['-c', 'command -v "$1"', 'bash', command], { + encoding: 'utf8', + }) + const resolved = result.stdout.trim() + if (result.status !== 0 || !path.isAbsolute(resolved)) { + throw new Error(`Could not resolve command: ${command}`) + } + return resolved +} + +const bashTest = commandExists('bash') ? it : it.skip +const jqShellTest = commandExists('bash') && commandExists('jq') ? it : it.skip + +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, 'utf8')) +} + +async function makeTempDirectory(): Promise { + return mkdtemp('/tmp/pando-plugin-test-') +} + +async function makeExecutable(filePath: string, contents: string): Promise { + await writeFile(filePath, contents, 'utf8') + await chmod(filePath, 0o755) +} + +function runHook( + script: string, + cwd: string, + input: Record, + env: NodeJS.ProcessEnv +) { + return spawnSync('bash', [script], { + cwd, + env: { ...process.env, ...env }, + input: JSON.stringify(input), + encoding: 'utf8', + }) +} + +describe('Claude Code plugin manifests', () => { + it('defines an installable marketplace and plugin manifest', async () => { + const marketplace = await readJson(path.join(repositoryRoot, '.claude-plugin/marketplace.json')) + const manifest = await readJson(path.join(pluginRoot, '.claude-plugin/plugin.json')) + + expect(marketplace.name).toBe('pando') + expect(marketplace.owner.name).toBeTruthy() + expect(marketplace.plugins).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'pando', source: './plugin' })]) + ) + + expect(manifest.name).toBe('pando') + expect(manifest).not.toHaveProperty('version') + expect(manifest.hooks).toBe('./hooks/hooks.json') + expect(manifest.userConfig.delegateCreation).toMatchObject({ + type: 'boolean', + default: true, + }) + expect(manifest.userConfig.reapOnSessionEnd).toMatchObject({ + type: 'boolean', + default: true, + }) + }) + + it('registers WorktreeCreate and a time-bounded SessionEnd hook', async () => { + const config = await readJson(path.join(pluginRoot, 'hooks/hooks.json')) + const createHandler = config.hooks.WorktreeCreate[0].hooks[0] + const endHandler = config.hooks.SessionEnd[0].hooks[0] + + expect(createHandler.command).toContain('${CLAUDE_PLUGIN_ROOT}') + expect(createHandler.command).toContain('/scripts/worktree-create.sh') + expect(endHandler.command).toContain('${CLAUDE_PLUGIN_ROOT}') + expect(endHandler.command).toContain('/scripts/session-end.sh') + expect(endHandler.timeout).toBeGreaterThanOrEqual(30) + expect(config.hooks.WorktreeCreate[0]).not.toHaveProperty('matcher') + expect(config.hooks.SessionEnd[0]).not.toHaveProperty('matcher') + }) + + it('ships executable hooks and discoverable skills and commands', async () => { + const [createMode, endMode, skill, statusCommand, reapCommand] = await Promise.all([ + stat(worktreeCreateScript), + stat(sessionEndScript), + readFile(path.join(pluginRoot, 'skills/pando-worktrees/SKILL.md'), 'utf8'), + readFile(path.join(pluginRoot, 'commands/pando-status.md'), 'utf8'), + readFile(path.join(pluginRoot, 'commands/pando-reap.md'), 'utf8'), + ]) + + expect(createMode.mode & 0o111).not.toBe(0) + expect(endMode.mode & 0o111).not.toBe(0) + expect(skill).toMatch(/^---\n/) + expect(skill).toMatch(/\nname: pando-worktrees\n/) + expect(skill).toMatch(/\ndescription: .+\n---\n/) + expect(statusCommand).toMatch(/^---\ndescription: .+\n---\n/) + expect(reapCommand).toMatch(/^---\ndescription: .+\n---\n/) + }) +}) + +describe('plugin hook scripts', () => { + jqShellTest('delegates worktree creation to pando and emits only its path', async () => { + const tempRoot = await makeTempDirectory() + try { + const fakeBin = path.join(tempRoot, 'bin') + const pandoLog = path.join(tempRoot, 'pando.log') + await mkdir(fakeBin) + await makeExecutable( + path.join(fakeBin, 'pando'), + `#!/usr/bin/env bash +set -u +printf '%s\\n' "$*" >> "$PANDO_LOG" +if [ "\${1:-}" = "--version" ]; then + printf '@zyoung-ff/pando/0.1.0 test-node\\n' + exit 0 +fi +if [ "\${1:-}" = "add" ]; then + worktree_path="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--path" ]; then + shift + worktree_path="$1" + fi + shift + done + mkdir -p "$worktree_path" + printf '{"success":true,"worktree":{"path":"%s"}}\\n' "$worktree_path" + exit 0 +fi +exit 1 +` + ) + + const expectedPath = path.join(await realpath(tempRoot), '.claude/worktrees/feature-auth') + const result = runHook( + worktreeCreateScript, + tempRoot, + { name: 'feature-auth', session_id: 'session-123', cwd: tempRoot }, + { + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + PANDO_LOG: pandoLog, + } + ) + + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe(expectedPath) + const calls = await readFile(pandoLog, 'utf8') + expect(calls).toContain('--version') + expect(calls).toContain( + `add feature-auth --path ${expectedPath} --ephemeral --owner session-123 --json` + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + jqShellTest('falls back to fake git after a pando error and still exits zero', async () => { + const tempRoot = await makeTempDirectory() + try { + const fakeBin = path.join(tempRoot, 'bin') + const pandoLog = path.join(tempRoot, 'pando.log') + const gitLog = path.join(tempRoot, 'git.log') + await mkdir(fakeBin) + await makeExecutable( + path.join(fakeBin, 'pando'), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$PANDO_LOG" +if [ "\${1:-}" = "--version" ]; then + printf 'pando/0.1.0 test\\n' + exit 0 +fi +exit 17 +` + ) + await makeExecutable( + path.join(fakeBin, 'git'), + `#!/usr/bin/env bash +set -u +printf '%s\\n' "$*" >> "$GIT_LOG" +if [ "\${1:-}" = "-C" ] && [ "\${3:-}" = "rev-parse" ]; then + printf '%s\\n' "$FAKE_REPO_ROOT" + exit 0 +fi +if [ "\${1:-}" = "-C" ] && [ "\${3:-}" = "worktree" ] && [ "\${4:-}" = "add" ]; then + target="\${!#}" + mkdir -p "$target" + exit 0 +fi +exit 1 +` + ) + + const targetPath = path.join(tempRoot, '.claude/worktrees/fallback-task') + const expectedPath = path.join(await realpath(tempRoot), '.claude/worktrees/fallback-task') + const result = runHook( + worktreeCreateScript, + tempRoot, + { name: 'fallback-task', session_id: 'session-456', cwd: tempRoot }, + { + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + PANDO_LOG: pandoLog, + GIT_LOG: gitLog, + FAKE_REPO_ROOT: tempRoot, + } + ) + + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe(expectedPath) + expect(await readFile(gitLog, 'utf8')).toContain( + `worktree add -b fallback-task ${targetPath}` + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + jqShellTest('emits an existing absolute fallback path when TMPDIR is relative', async () => { + const tempRoot = await makeTempDirectory() + let emittedPath: string | undefined + try { + const fakeBin = path.join(tempRoot, 'bin') + const blockedParent = path.join(tempRoot, 'blocked') + const relativeTmp = path.join(tempRoot, 'reltmp') + await mkdir(fakeBin) + await mkdir(relativeTmp) + await writeFile(blockedParent, 'not a directory', 'utf8') + + await Promise.all( + ['bash', 'cat', 'dirname', 'head', 'jq', 'mkdir', 'mktemp', 'rmdir', 'sed', 'tr'].map( + (command) => symlink(resolveCommand(command), path.join(fakeBin, command)) + ) + ) + await makeExecutable( + path.join(fakeBin, 'git'), + `#!/bin/sh +if [ "\${1:-}" = "-C" ] && [ "\${3:-}" = "rev-parse" ]; then + printf '%s\\n' "$FAKE_REPO_ROOT" + exit 0 +fi +exit 1 +` + ) + + const result = runHook( + worktreeCreateScript, + tempRoot, + { name: 'relative-tmp', session_id: 'session-relative-tmp', cwd: tempRoot }, + { + PATH: fakeBin, + TMPDIR: 'reltmp', + FAKE_REPO_ROOT: path.join(blockedParent, 'repo'), + } + ) + + expect(result.status).toBe(0) + const stdoutLines = result.stdout.split(/\r?\n/).filter(Boolean) + expect(stdoutLines).toHaveLength(1) + emittedPath = stdoutLines[stdoutLines.length - 1] + expect(emittedPath.startsWith('/')).toBe(true) + expect((await stat(emittedPath)).isDirectory()).toBe(true) + } finally { + if (emittedPath && /^pando-(relative-tmp|worktree)\./.test(path.basename(emittedPath))) { + await rm(emittedPath, { recursive: true, force: true }) + } + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + jqShellTest('unlocks owned worktrees and runs owner-scoped session reaping', async () => { + const tempRoot = await makeTempDirectory() + try { + const fakeBin = path.join(tempRoot, 'bin') + const pandoLog = path.join(tempRoot, 'pando.log') + await mkdir(fakeBin) + await makeExecutable( + path.join(fakeBin, 'pando'), + `#!/usr/bin/env bash +set -u +printf '%s\\n' "$*" >> "$PANDO_LOG" +case "\${1:-}" in + --version) + printf 'pando/0.1.0 test\\n' + ;; + list) + printf '%s\\n' '{"worktrees":[{"path":"/tmp/owned-worktree","owner":"session-789","locked":true},{"path":"/tmp/other-worktree","owner":"other","locked":true}]}' + ;; + unlock|reap) + printf '%s\\n' '{"success":true}' + ;; + *) + exit 1 + ;; +esac +` + ) + + const result = runHook( + sessionEndScript, + tempRoot, + { session_id: 'session-789', cwd: tempRoot, reason: 'other' }, + { + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + PANDO_LOG: pandoLog, + } + ) + + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + const calls = await readFile(pandoLog, 'utf8') + expect(calls).toContain('list --json') + expect(calls).toContain('unlock /tmp/owned-worktree --json') + expect(calls).not.toContain('unlock /tmp/other-worktree --json') + expect(calls).toContain('reap --owner session-789 --force --json') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + bashTest('does no session cleanup when reapOnSessionEnd is false', async () => { + const tempRoot = await makeTempDirectory() + try { + const fakeBin = path.join(tempRoot, 'bin') + const pandoLog = path.join(tempRoot, 'pando.log') + await mkdir(fakeBin) + await makeExecutable( + path.join(fakeBin, 'pando'), + `#!/usr/bin/env bash +printf 'called\\n' >> "$PANDO_LOG" +exit 99 +` + ) + + const result = runHook( + sessionEndScript, + tempRoot, + { session_id: 'session-disabled', cwd: tempRoot, reason: 'other' }, + { + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + PANDO_LOG: pandoLog, + CLAUDE_PLUGIN_OPTION_REAPONSESSIONEND: 'false', + } + ) + + expect(result.status).toBe(0) + await expect(readFile(pandoLog, 'utf8')).rejects.toThrow() + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/test/utils/git.test.ts b/test/utils/git.test.ts index 20816a6..af67f09 100644 --- a/test/utils/git.test.ts +++ b/test/utils/git.test.ts @@ -1,6 +1,20 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { GitHelper, WorktreeInfo } from '../../src/utils/git' +const { mockReadMetadata, mockStat } = vi.hoisted(() => ({ + mockReadMetadata: vi.fn(), + mockStat: vi.fn(), +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { ...actual, stat: mockStat } +}) + +vi.mock('../../src/utils/worktreeMetadata', () => ({ + readMetadata: mockReadMetadata, +})) + // Mock simpleGit for worktree-specific operations const mockWorktreeGit = { raw: vi.fn(), @@ -36,6 +50,7 @@ describe('GitHelper', () => { beforeEach(() => { vi.clearAllMocks() + mockReadMetadata.mockResolvedValue({}) mockWorktreeGit.revparse.mockResolvedValue('abc123def456\n') gitHelper = new GitHelper() // Access private git instance through type assertion for testing @@ -233,6 +248,7 @@ branch refs/heads/main worktree /path/to/feature HEAD def789abc012 branch refs/heads/feature-branch +locked pando: active session agent-7 worktree /path/to/detached HEAD 111222333444 @@ -259,6 +275,7 @@ prunable branch: 'feature-branch', commit: 'def789abc012', isPrunable: false, + isLocked: true, }) expect(result[2]).toEqual({ path: '/path/to/detached', @@ -295,6 +312,93 @@ prunable ]) }) + it('should lock a worktree with a reason', async () => { + mockGit.raw = vi.fn().mockResolvedValue('') + + await gitHelper.lockWorktree('/path/to/worktree', 'agent is active') + + expect(mockGit.raw).toHaveBeenCalledWith([ + 'worktree', + 'lock', + '--reason', + 'agent is active', + '/path/to/worktree', + ]) + }) + + it('should treat an already locked worktree as success', async () => { + mockGit.raw = vi + .fn() + .mockRejectedValue(new Error("fatal: '/path/to/worktree' is already locked")) + + await expect(gitHelper.lockWorktree('/path/to/worktree')).resolves.toBeUndefined() + expect(mockGit.raw).toHaveBeenCalledWith(['worktree', 'lock', '/path/to/worktree']) + }) + + it('should not swallow an unrelated lock error containing an already-locked path', async () => { + const error = new Error("fatal: '/tmp/already locked' is not a working tree") + mockGit.raw = vi.fn().mockRejectedValue(error) + + await expect(gitHelper.lockWorktree('/tmp/already locked')).rejects.toBe(error) + }) + + it('should unlock a worktree', async () => { + mockGit.raw = vi.fn().mockResolvedValue('') + + await gitHelper.unlockWorktree('/path/to/worktree') + + expect(mockGit.raw).toHaveBeenCalledWith(['worktree', 'unlock', '/path/to/worktree']) + }) + + it('should treat a worktree that is not locked as successfully unlocked', async () => { + mockGit.raw = vi.fn().mockRejectedValue(new Error("fatal: '/path/to/worktree' is not locked")) + + await expect(gitHelper.unlockWorktree('/path/to/worktree')).resolves.toBeUndefined() + }) + + it('should not swallow an unrelated unlock error containing a not-locked path', async () => { + const error = new Error("fatal: '/tmp/not locked' is not a working tree") + mockGit.raw = vi.fn().mockRejectedValue(error) + + await expect(gitHelper.unlockWorktree('/tmp/not locked')).rejects.toBe(error) + }) + + it('should retry a mutating operation after transient ref lock contention', async () => { + const transientError = Object.assign( + new Error( + "fatal: cannot lock ref 'refs/heads/feature': Unable to create '/repo/.git/refs/heads/feature.lock': File exists" + ), + { exitCode: 128 } + ) + mockGit.raw = vi.fn().mockRejectedValueOnce(transientError).mockResolvedValueOnce('') + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + + try { + await gitHelper.removeWorktree('/path/to/worktree') + } finally { + randomSpy.mockRestore() + } + + expect(mockGit.raw).toHaveBeenCalledTimes(2) + expect(mockGit.raw).toHaveBeenNthCalledWith(1, ['worktree', 'remove', '/path/to/worktree']) + expect(mockGit.raw).toHaveBeenNthCalledWith(2, ['worktree', 'remove', '/path/to/worktree']) + }) + + it('should honor a custom retry attempt limit for mutating operations', async () => { + const transientError = Object.assign( + new Error( + "fatal: cannot lock ref 'refs/heads/feature': Unable to create '/repo/.git/refs/heads/feature.lock': File exists" + ), + { exitCode: 128 } + ) + mockGit.raw = vi.fn().mockRejectedValue(transientError) + gitHelper.setRetryConfig({ maxAttempts: 2, baseMs: 0, capMs: 1 }) + + await expect(gitHelper.removeWorktree('/path/to/worktree')).rejects.toBe(transientError) + + expect(mockGit.raw).toHaveBeenCalledTimes(2) + }) + it('should find worktree by exact branch name', async () => { const _mockWorktrees: WorktreeInfo[] = [ { path: '/path/to/main', branch: 'main', commit: 'abc123', isPrunable: false }, @@ -353,6 +457,146 @@ branch refs/heads/main }) }) + describe('worktree lifecycle', () => { + it('should calculate worktree age from metadata createdAt', async () => { + const now = Date.parse('2026-07-22T12:00:00.000Z') + mockReadMetadata.mockResolvedValue({ createdAt: '2026-07-22T11:00:00.000Z' }) + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) + + try { + await expect(gitHelper.getWorktreeAgeMs('/path/to/worktree')).resolves.toBe(3_600_000) + } finally { + nowSpy.mockRestore() + } + + expect(mockStat).not.toHaveBeenCalled() + }) + + it('should fall back to directory mtime when metadata createdAt is invalid', async () => { + const now = Date.parse('2026-07-22T12:00:00.000Z') + mockReadMetadata.mockResolvedValue({ createdAt: 'not-a-date' }) + mockStat.mockResolvedValue({ mtimeMs: now - 60_000 }) + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) + + try { + await expect(gitHelper.getWorktreeAgeMs('/path/to/worktree')).resolves.toBe(60_000) + } finally { + nowSpy.mockRestore() + } + + expect(mockStat).toHaveBeenCalledWith('/path/to/worktree') + }) + + it('should return zero when worktree age sources are unavailable', async () => { + mockReadMetadata.mockRejectedValue(new Error('metadata unavailable')) + mockStat.mockRejectedValue(new Error('worktree missing')) + + await expect(gitHelper.getWorktreeAgeMs('/path/to/worktree')).resolves.toBe(0) + }) + + it('should report a clean merged worktree as reap clean', async () => { + mockWorktreeGit.status.mockResolvedValue({ isClean: () => true }) + mockGit.raw = vi.fn().mockResolvedValue('') + vi.spyOn(gitHelper, 'listWorktrees').mockResolvedValue([ + { + path: '/path/to/worktree', + branch: 'feature', + commit: 'abc123', + isPrunable: false, + }, + ]) + const mergedSpy = vi.spyOn(gitHelper, 'isBranchMerged').mockResolvedValue(true) + + await expect(gitHelper.isReapClean('/path/to/worktree', 'main')).resolves.toBe(true) + expect(mockWorktreeGit.status).toHaveBeenCalledOnce() + expect(mockGit.raw).toHaveBeenCalledWith([ + 'show-ref', + '--verify', + '--quiet', + 'refs/heads/main', + ]) + expect(mergedSpy).toHaveBeenCalledWith('feature', 'refs/heads/main') + }) + + it('should not report a dirty worktree as reap clean', async () => { + mockWorktreeGit.status.mockResolvedValue({ isClean: () => false }) + const listSpy = vi.spyOn(gitHelper, 'listWorktrees') + + await expect(gitHelper.isReapClean('/path/to/worktree', 'main')).resolves.toBe(false) + expect(listSpy).not.toHaveBeenCalled() + }) + + it('should not report an unmerged worktree as reap clean', async () => { + mockWorktreeGit.status.mockResolvedValue({ isClean: () => true }) + mockGit.raw = vi.fn().mockResolvedValue('') + vi.spyOn(gitHelper, 'listWorktrees').mockResolvedValue([ + { + path: '/path/to/worktree', + branch: 'feature', + commit: 'abc123', + isPrunable: false, + }, + ]) + vi.spyOn(gitHelper, 'isBranchMerged').mockResolvedValue(false) + + await expect(gitHelper.isReapClean('/path/to/worktree', 'main')).resolves.toBe(false) + }) + + it('should fail closed when the direct worktree status check throws', async () => { + const swallowedCheckSpy = vi.spyOn(gitHelper, 'hasUncommittedChanges') + mockWorktreeGit.status.mockRejectedValue(new Error('status failed')) + + await expect(gitHelper.isReapClean('/path/to/worktree', 'main')).resolves.toBe(false) + expect(swallowedCheckSpy).not.toHaveBeenCalled() + }) + + it('should reject an empty target branch', async () => { + await expect(gitHelper.isReapClean('/path/to/worktree', ' ')).resolves.toBe(false) + expect(mockWorktreeGit.status).not.toHaveBeenCalled() + }) + + it('should reject a revision expression as a local branch target', async () => { + mockWorktreeGit.status.mockResolvedValue({ isClean: () => true }) + mockGit.raw = vi.fn().mockRejectedValue(new Error('exact local branch not found')) + const listSpy = vi.spyOn(gitHelper, 'listWorktrees') + + await expect(gitHelper.isReapClean('/path/to/worktree', 'main~1')).resolves.toBe(false) + expect(mockGit.raw).toHaveBeenCalledWith([ + 'show-ref', + '--verify', + '--quiet', + 'refs/heads/main~1', + ]) + expect(listSpy).not.toHaveBeenCalled() + }) + }) + + describe('owner inference', () => { + it('should prefer CLAUDE_SESSION_ID over PANDO_SESSION', () => { + vi.stubEnv('CLAUDE_SESSION_ID', 'claude-session') + vi.stubEnv('PANDO_SESSION', 'pando-session') + + try { + expect(gitHelper.inferOwner()).toBe('claude-session') + } finally { + vi.unstubAllEnvs() + } + }) + + it('should fall back to PANDO_SESSION and then an empty string', () => { + vi.stubEnv('CLAUDE_SESSION_ID', undefined) + vi.stubEnv('PANDO_SESSION', 'pando-session') + + try { + expect(gitHelper.inferOwner()).toBe('pando-session') + vi.stubEnv('PANDO_SESSION', undefined) + expect(gitHelper.inferOwner()).toBe('') + } finally { + vi.unstubAllEnvs() + } + }) + }) + describe('branch operations', () => { it('should create a new branch', async () => { mockGit.raw = vi @@ -482,8 +726,8 @@ branch refs/heads/main expect(mockGit.raw).toHaveBeenCalledWith(['branch', '--merged', 'HEAD']) }) - it('should check if branch is merged into target', async () => { - const mockOutput = ` feature-1 + it('should detect a merged branch checked out in another worktree', async () => { + const mockOutput = `+ feature-1 ` mockGit.raw = vi.fn().mockResolvedValue(mockOutput) @@ -1050,6 +1294,24 @@ branch refs/heads/main expect(mockGit.fetch).toHaveBeenCalledWith(['upstream', '--prune']) }) + + it('should retry fetch after transient ref-lock contention', async () => { + const transientError = new Error( + "fatal: cannot lock ref 'refs/remotes/origin/main': Unable to create '/repo/.git/refs/remotes/origin/main.lock': File exists" + ) + mockGit.fetch = vi.fn().mockRejectedValueOnce(transientError).mockResolvedValueOnce({}) + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + + try { + await gitHelper.fetchWithPrune() + } finally { + randomSpy.mockRestore() + } + + expect(mockGit.fetch).toHaveBeenCalledTimes(2) + expect(mockGit.fetch).toHaveBeenNthCalledWith(1, ['origin', '--prune']) + expect(mockGit.fetch).toHaveBeenNthCalledWith(2, ['origin', '--prune']) + }) }) describe('getMainBranch', () => { diff --git a/test/utils/gitRetry.test.ts b/test/utils/gitRetry.test.ts new file mode 100644 index 0000000..95b6eea --- /dev/null +++ b/test/utils/gitRetry.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isTransientLockError, withGitRetry } from '../../src/utils/gitRetry' + +const REAL_SIMPLE_GIT_LOCK_MESSAGE = + "fatal: cannot lock ref 'refs/heads/feat': Unable to create '/.../feat.lock': File exists.\n\nAnother git process seems to be running ..." + +function lockError(message = "fatal: Unable to create '.git/index.lock': File exists."): Error { + return Object.assign(new Error(message), { exitCode: 128 }) +} + +function realSimpleGitLockError(): Error { + return Object.assign(new Error(REAL_SIMPLE_GIT_LOCK_MESSAGE), { task: {} }) +} + +describe('isTransientLockError', () => { + it.each([ + Object.assign(new Error("fatal: Unable to create '.git/index.lock': File exists."), { + exitCode: 128, + }), + Object.assign(new Error("fatal: cannot lock ref 'refs/heads/main'"), { code: 128 }), + new Error('Git exited with 128: packed-refs.lock already exists'), + new Error("fatal: cannot lock ref 'refs/heads/main'\nProcess exited with code 128"), + ])('classifies exit 128 lock contention as transient', (error) => { + expect(isTransientLockError(error)).toBe(true) + }) + + it.each([ + 'Permission denied', + 'authentication failed', + 'could not read Username for remote', + 'No space left on device', + 'Disk quota exceeded', + ])('rejects lock errors caused by permanent failures: %s', (reason) => { + expect(isTransientLockError(lockError(`cannot lock ref: ${reason}`))).toBe(false) + }) + + it.each([ + Object.assign(new Error("fatal: Unable to create '.git/index.lock': Operation not permitted"), { + exitCode: 128, + }), + Object.assign(lockError(), { code: 'EACCES' }), + ])('rejects permanent lock failures', (error) => { + expect(isTransientLockError(error)).toBe(false) + }) + + it('distinguishes a ref D/F conflict from real lock-file contention without an exit code', () => { + const refConflict = new Error( + "cannot lock ref 'refs/heads/foo/bar': 'refs/heads/foo' exists; cannot create 'refs/heads/foo/bar'" + ) + const lockContention = new Error( + "cannot lock ref 'refs/heads/foo': Unable to create '/repo/.git/refs/heads/foo.lock': File exists" + ) + + expect(isTransientLockError(refConflict)).toBe(false) + expect(isTransientLockError(lockContention)).toBe(true) + }) + + it('does not retry a ref D/F conflict even when it carries exit code 128', () => { + const refConflict = Object.assign( + new Error( + "cannot lock ref 'refs/heads/foo/bar': 'refs/heads/foo' exists; cannot create 'refs/heads/foo/bar'" + ), + { exitCode: 128 } + ) + + expect(isTransientLockError(refConflict)).toBe(false) + }) + + it('rejects a non-definitive lock mention without exit code 128', () => { + const error = Object.assign(new Error('fatal: index.lock already exists'), { exitCode: 1 }) + + expect(isTransientLockError(error)).toBe(false) + }) + + it('rejects a bare unable-to-create message even with exit code 128', () => { + const error = Object.assign(new Error('fatal: unable to create temporary output'), { + exitCode: 128, + }) + + expect(isTransientLockError(error)).toBe(false) + }) + + it('does not treat a numeric count as an exit code', () => { + const error = new Error('migration exited with 128 commits remaining; stale index.lock found') + + expect(isTransientLockError(error)).toBe(false) + }) + + it.each([ + 'fatal: exit code 128: index.lock already exists', + undefined, + { exitCode: 128, message: 'fatal: index.lock already exists' }, + ])('returns false for non-Error input without throwing', (value) => { + expect(isTransientLockError(value)).toBe(false) + }) +}) + +describe('withGitRetry', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('retries a transient lock error and returns the successful result', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const operation = vi.fn().mockRejectedValueOnce(lockError()).mockResolvedValueOnce('success') + + const result = withGitRetry(operation, { baseMs: 100 }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('success') + expect(operation).toHaveBeenCalledTimes(2) + }) + + it('recognizes and retries the real simple-git lock error without an exit code', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const error = realSimpleGitLockError() + const operation = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce('success') + + expect('exitCode' in error).toBe(false) + expect(isTransientLockError(error)).toBe(true) + + const result = withGitRetry(operation) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('success') + expect(operation).toHaveBeenCalledTimes(2) + }) + + it('does not retry a permanent permission failure', async () => { + const error = lockError("fatal: Unable to create '.git/index.lock': Permission denied") + const operation = vi.fn().mockRejectedValue(error) + + await expect(withGitRetry(operation)).rejects.toBe(error) + expect(operation).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('throws the last error after exactly maxAttempts attempts', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + const errors = [ + lockError('cannot lock ref: first'), + lockError('cannot lock ref: second'), + lockError('cannot lock ref: third'), + ] + const operation = vi + .fn() + .mockRejectedValueOnce(errors[0]) + .mockRejectedValueOnce(errors[1]) + .mockRejectedValueOnce(errors[2]) + + const result = withGitRetry(operation, { maxAttempts: 3 }) + const rejection = expect(result).rejects.toBe(errors[2]) + await vi.runAllTimersAsync() + + await rejection + expect(operation).toHaveBeenCalledTimes(3) + }) + + it('applies full jitter within the capped exponential backoff bounds', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const timerSpy = vi.spyOn(globalThis, 'setTimeout') + const operation = vi + .fn() + .mockRejectedValueOnce(lockError()) + .mockRejectedValueOnce(lockError()) + .mockRejectedValueOnce(lockError()) + .mockRejectedValueOnce(lockError()) + .mockResolvedValueOnce('success') + + const result = withGitRetry(operation, { maxAttempts: 5, baseMs: 100, capMs: 250 }) + await vi.runAllTimersAsync() + await expect(result).resolves.toBe('success') + + const bounds = [100, 200, 250, 250] + const delays = timerSpy.mock.calls.map(([, ms]) => ms) + expect(delays).toEqual(bounds.map((bound) => bound * 0.5)) + delays.forEach((delay, index) => { + expect(delay).toBeGreaterThanOrEqual(0) + expect(delay).toBeLessThanOrEqual(bounds[index]) + }) + }) + + it('keeps the delay finite at high attempt indexes', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5) + const timerSpy = vi.spyOn(globalThis, 'setTimeout') + let attempts = 0 + const operation = vi.fn().mockImplementation(async () => { + attempts += 1 + if (attempts <= 1025) { + throw lockError() + } + return 'success' + }) + + const result = withGitRetry(operation, { + maxAttempts: 1026, + baseMs: 1, + capMs: Number.POSITIVE_INFINITY, + }) + await vi.runAllTimersAsync() + await expect(result).resolves.toBe('success') + + const delays = timerSpy.mock.calls.map(([, ms]) => ms) + expect(delays).toHaveLength(1025) + expect(delays.every((ms) => Number.isFinite(ms))).toBe(true) + }) +}) diff --git a/test/utils/portAllocator.test.ts b/test/utils/portAllocator.test.ts new file mode 100644 index 0000000..9acc43f --- /dev/null +++ b/test/utils/portAllocator.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnumerateAll, mockUnsetPort, mockWriteMetadata } = vi.hoisted(() => ({ + mockEnumerateAll: vi.fn(), + mockUnsetPort: vi.fn(), + mockWriteMetadata: vi.fn(), +})) + +vi.mock('../../src/utils/worktreeMetadata.js', () => ({ + enumerateAll: mockEnumerateAll, + unsetPort: mockUnsetPort, + writeMetadata: mockWriteMetadata, +})) + +import { allocate, deriveDbName } from '../../src/utils/portAllocator' + +describe('portAllocator', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnumerateAll.mockResolvedValue([]) + mockUnsetPort.mockResolvedValue(undefined) + mockWriteMetadata.mockResolvedValue(undefined) + }) + + describe('allocate', () => { + it('parses an inclusive range and allocates each port at most once', async () => { + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3100-3101', + names: ['api', 'debug'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({ api: 3100, debug: 3101 }) + + expect(isPortFree.mock.calls).toEqual([[3100], [3101]]) + expect(mockWriteMetadata).toHaveBeenCalledOnce() + expect(mockWriteMetadata).toHaveBeenCalledWith('/repo/worktree', { + ports: { api: 3100, debug: 3101 }, + }) + }) + + it.each(['3100', 'abc-3199', '3100-3199-extra'])( + 'treats malformed range %s as empty', + async (range) => { + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range, + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({}) + + expect(isPortFree).not.toHaveBeenCalled() + expect(mockWriteMetadata).toHaveBeenCalledWith('/repo/worktree', { ports: {} }) + } + ) + + it('treats an inverted range as empty', async () => { + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3199-3100', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({}) + + expect(isPortFree).not.toHaveBeenCalled() + }) + + it('skips ports assigned in live worktree metadata', async () => { + mockEnumerateAll.mockResolvedValue([ + { worktreePath: '/repo', metadata: { ports: { api: 3100 } } }, + { worktreePath: '/repo/other', metadata: { ports: { debug: 3101 } } }, + ]) + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3100-3102', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({ api: 3102 }) + + expect(mockEnumerateAll).toHaveBeenCalledWith('/repo') + expect(isPortFree).toHaveBeenCalledOnce() + expect(isPortFree).toHaveBeenCalledWith(3102) + }) + + it('reconciles when a peer claims the initially persisted port', async () => { + const peer = { + worktreePath: '/repo/peer', + metadata: { ports: { api: 3100 } }, + } + mockEnumerateAll + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([peer]) + .mockResolvedValue([peer]) + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3100-3102', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({ api: 3101 }) + + expect(isPortFree.mock.calls).toEqual([[3100], [3101]]) + expect(mockWriteMetadata.mock.calls).toEqual([ + ['/repo/worktree', { ports: { api: 3100 } }], + ['/repo/worktree', { ports: { api: 3101 } }], + ]) + }) + + it('omits a colliding name when reconciliation cannot find a replacement', async () => { + const peer = { + worktreePath: '/repo/peer', + metadata: { ports: { api: 3100 } }, + } + mockEnumerateAll.mockResolvedValueOnce([]).mockResolvedValue([peer]) + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3100-3100', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({}) + + expect(mockUnsetPort).toHaveBeenCalledOnce() + expect(mockUnsetPort).toHaveBeenCalledWith('/repo/worktree', 'api') + expect(mockWriteMetadata.mock.calls).toEqual([ + ['/repo/worktree', { ports: { api: 3100 } }], + ['/repo/worktree', { ports: {} }], + ]) + }) + + it('picks the lowest port that passes the OS probe', async () => { + const isPortFree = vi.fn(async (port: number) => port !== 3100) + + await expect( + allocate('/repo/worktree', { + range: '3100-3102', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({ api: 3101 }) + + expect(isPortFree.mock.calls).toEqual([[3100], [3101]]) + }) + + it('returns a partial result without throwing when the range is exhausted', async () => { + const isPortFree = vi.fn().mockResolvedValue(true) + + await expect( + allocate('/repo/worktree', { + range: '3100-3100', + names: ['api', 'debug'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({ api: 3100 }) + + expect(mockWriteMetadata).toHaveBeenCalledWith('/repo/worktree', { + ports: { api: 3100 }, + }) + }) + + it('treats probe errors as taken ports instead of throwing', async () => { + const isPortFree = vi.fn().mockRejectedValue(new Error('probe failed')) + + await expect( + allocate('/repo/worktree', { + range: '3100-3101', + names: ['api'], + mainRepoPath: '/repo', + isPortFree, + }) + ).resolves.toEqual({}) + }) + + it('allocates only names accepted by git config and returns a plain object', async () => { + const isPortFree = vi.fn().mockResolvedValue(true) + + const result = await allocate('/repo/worktree', { + range: '3100-3101', + names: ['__proto__', '9foo', 'api_server', '', 'foo bar', 'web', 'foo-bar'], + mainRepoPath: '/repo', + isPortFree, + }) + + expect(result).toEqual({ web: 3100, 'foo-bar': 3101 }) + for (const name of ['__proto__', '9foo', 'api_server', '', 'foo bar']) { + expect(Object.hasOwn(result, name)).toBe(false) + } + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(isPortFree.mock.calls).toEqual([[3100], [3101]]) + expect(mockWriteMetadata).toHaveBeenCalledWith('/repo/worktree', { + ports: { web: 3100, 'foo-bar': 3101 }, + }) + }) + }) + + describe('deriveDbName', () => { + it('sanitizes, lowercases, and collapses branch separators', () => { + expect(deriveDbName('base', 'feature///Foo___Bar---baz')).toBe('base_feature_foo_bar_baz') + }) + + it.each([ + ['日本語', 'base_c12140a0'], + ['+++', 'base_86c3ea5a'], + ['___', 'base_bf295750'], + ])('uses a stable hash when %s has no ASCII alphanumeric suffix', (branch, expected) => { + expect(deriveDbName('base', branch)).toBe(expected) + }) + }) +}) diff --git a/test/utils/postCommands.test.ts b/test/utils/postCommands.test.ts index 233ca5e..c5a452d 100644 --- a/test/utils/postCommands.test.ts +++ b/test/utils/postCommands.test.ts @@ -56,20 +56,29 @@ describe('postCommands', () => { tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pando-post-command-'))) const results = await runPostCommandScripts( - [{ name: 'write-marker', command: 'pwd && printf "$PANDO_BRANCH" > branch.txt' }], + [ + { + name: 'write-marker', + command: + 'pwd && printf "$PANDO_BRANCH" > branch.txt && printf "$PANDO_KIND:$PANDO_TTL" > lifecycle.txt', + }, + ], { commandName: 'add', cwd: tempDir, worktreePath: tempDir, branch: 'feature/post-command', commit: 'abc123', + kind: 'ephemeral', + ttl: '4h', } ) expect(results).toHaveLength(1) expect(results[0]).toMatchObject({ name: 'write-marker', - command: 'pwd && printf "$PANDO_BRANCH" > branch.txt', + command: + 'pwd && printf "$PANDO_BRANCH" > branch.txt && printf "$PANDO_KIND:$PANDO_TTL" > lifecycle.txt', cwd: tempDir, exitCode: 0, signal: null, @@ -79,6 +88,68 @@ describe('postCommands', () => { await expect(fs.readFile(path.join(tempDir, 'branch.txt'), 'utf8')).resolves.toBe( 'feature/post-command' ) + await expect(fs.readFile(path.join(tempDir, 'lifecycle.txt'), 'utf8')).resolves.toBe( + 'ephemeral:4h' + ) + }) + + it('injects allocated ports with env-safe names and the database name', async () => { + tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pando-post-command-'))) + + await runPostCommandScripts( + [ + { + command: 'printf "$PANDO_PORT_WEB_API:$PANDO_PORT_WORKER:$PANDO_DB_NAME" > resources.txt', + }, + ], + { + commandName: 'add', + cwd: tempDir, + worktreePath: tempDir, + branch: 'feature/ports', + commit: 'abc123', + ports: { 'web-api': 4310, worker: 4311 }, + dbName: 'dev_feature_ports', + } + ) + + await expect(fs.readFile(path.join(tempDir, 'resources.txt'), 'utf8')).resolves.toBe( + '4310:4311:dev_feature_ports' + ) + }) + + it('does not add port or database variables without allocation context', async () => { + tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pando-post-command-'))) + const previousPort = process.env.PANDO_PORT_WEB_API + const previousDbName = process.env.PANDO_DB_NAME + delete process.env.PANDO_PORT_WEB_API + delete process.env.PANDO_DB_NAME + + try { + await runPostCommandScripts( + [ + { + command: 'printf "${PANDO_PORT_WEB_API-unset}:${PANDO_DB_NAME-unset}" > resources.txt', + }, + ], + { + commandName: 'add', + cwd: tempDir, + worktreePath: tempDir, + branch: 'feature/no-ports', + commit: 'abc123', + } + ) + } finally { + if (previousPort === undefined) delete process.env.PANDO_PORT_WEB_API + else process.env.PANDO_PORT_WEB_API = previousPort + if (previousDbName === undefined) delete process.env.PANDO_DB_NAME + else process.env.PANDO_DB_NAME = previousDbName + } + + await expect(fs.readFile(path.join(tempDir, 'resources.txt'), 'utf8')).resolves.toBe( + 'unset:unset' + ) }) it('throws with captured result when a script exits non-zero', async () => { diff --git a/test/utils/worktreeMetadata.test.ts b/test/utils/worktreeMetadata.test.ts new file mode 100644 index 0000000..38bc9e3 --- /dev/null +++ b/test/utils/worktreeMetadata.test.ts @@ -0,0 +1,270 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRaw, mockReadFile, mockSimpleGit } = vi.hoisted(() => ({ + mockRaw: vi.fn(), + mockReadFile: vi.fn(), + mockSimpleGit: vi.fn(), +})) + +vi.mock('simple-git', () => ({ + simpleGit: mockSimpleGit, +})) + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, +})) + +import { + assertGitVersion, + ensureWorktreeConfigEnabled, + enumerateAll, + readMetadata, + unsetPort, + writeMetadata, +} from '../../src/utils/worktreeMetadata' + +describe('worktreeMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSimpleGit.mockReturnValue({ raw: mockRaw }) + }) + + describe('readMetadata', () => { + it('parses metadata keys case-insensitively, including named ports', async () => { + mockRaw.mockResolvedValue( + `PANDO.KIND\nephemeral\0pando.CreatedAt\n2026-07-22T10:00:00.000Z\0PANDO.SOURCEBRANCH\nfeature/metadata branch\0pando.OWNER\nJane Doe\0pando.ttl\n12h\0PANDO.PORT.HTTP\n4310\0pando.port.debug\n9229\0pando.DB.NAME\npando_feature\0pando.unknown\nignored\0` + ) + + await expect(readMetadata('/repo/worktree')).resolves.toEqual({ + kind: 'ephemeral', + createdAt: '2026-07-22T10:00:00.000Z', + sourceBranch: 'feature/metadata branch', + owner: 'Jane Doe', + ttl: '12h', + ports: { http: 4310, debug: 9229 }, + dbName: 'pando_feature', + }) + expect(mockSimpleGit).toHaveBeenCalledWith('/repo/worktree') + expect(mockRaw).toHaveBeenCalledWith([ + 'config', + '--worktree', + '--get-regexp', + '--null', + '^pando\\.', + ]) + }) + + it('returns empty metadata when git config cannot be read', async () => { + mockRaw.mockRejectedValue(new Error('no matching keys')) + + await expect(readMetadata('/not/a/repo')).resolves.toEqual({}) + }) + }) + + describe('unsetPort', () => { + it('unsets the named port from worktree config', async () => { + mockRaw.mockResolvedValue('') + + await expect(unsetPort('/repo/worktree', 'api')).resolves.toBeUndefined() + + expect(mockSimpleGit).toHaveBeenCalledWith('/repo/worktree') + expect(mockRaw).toHaveBeenCalledWith(['config', '--worktree', '--unset', 'pando.port.api']) + }) + + it('does not fail when the port key is absent', async () => { + mockRaw.mockRejectedValue(new Error('key not found')) + + await expect(unsetPort('/repo/worktree', 'api')).resolves.toBeUndefined() + }) + }) + + describe('writeMetadata', () => { + it('writes only fields present in the patch to worktree config', async () => { + mockRaw.mockResolvedValue('') + + await writeMetadata('/repo/worktree', { + kind: 'long-lived', + createdAt: '2026-07-22T10:00:00.000Z', + dbName: 'pando_long_lived', + ports: { api: 3001, debug: 9229 }, + }) + + expect(mockRaw.mock.calls).toEqual([ + [['config', '--worktree', 'pando.kind', 'long-lived']], + [['config', '--worktree', 'pando.createdAt', '2026-07-22T10:00:00.000Z']], + [['config', '--worktree', 'pando.db.name', 'pando_long_lived']], + [['config', '--worktree', 'pando.port.api', '3001']], + [['config', '--worktree', 'pando.port.debug', '9229']], + ]) + }) + }) + + describe('enumerateAll', () => { + it('reads main and linked worktree config.worktree files directly', async () => { + const mainPath = path.resolve('/repo/main') + const linkedPath = path.resolve('/repo/linked worktree\nfeature') + const commonDir = path.join(mainPath, '.git') + const mainConfig = path.join(commonDir, 'config.worktree') + const linkedConfig = path.join(commonDir, 'worktrees', 'feature', 'config.worktree') + + mockReadFile.mockImplementation((file: string) => { + if (file === path.join(linkedPath, '.git')) { + return Promise.resolve(`gitdir: ${path.join(commonDir, 'worktrees', 'feature')}\n`) + } + return Promise.reject(new Error(`unexpected file read: ${file}`)) + }) + mockRaw.mockImplementation((args: string[]) => { + if (args[0] === 'worktree') { + return Promise.resolve( + `worktree ${mainPath}\0HEAD abc123\0branch refs/heads/main\0worktree ${linkedPath}\0HEAD def456\0branch refs/heads/feature\0` + ) + } + if (args[0] === 'rev-parse') return Promise.resolve('.git\n') + if (args.includes(mainConfig)) { + return Promise.resolve('pando.kind\nlong-lived\0pando.owner\nmain-owner\0') + } + if (args.includes(linkedConfig)) { + return Promise.resolve('pando.kind\nephemeral\0pando.port.api\n4100\0') + } + return Promise.reject(new Error(`unexpected git call: ${args.join(' ')}`)) + }) + + await expect(enumerateAll(mainPath)).resolves.toEqual([ + { worktreePath: mainPath, metadata: { kind: 'long-lived', owner: 'main-owner' } }, + { + worktreePath: linkedPath, + metadata: { kind: 'ephemeral', ports: { api: 4100 } }, + }, + ]) + expect(mockRaw).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z']) + expect(mockRaw).toHaveBeenCalledWith([ + 'config', + '--file', + mainConfig, + '--get-regexp', + '--null', + '^pando\\.', + ]) + expect(mockRaw).toHaveBeenCalledWith([ + 'config', + '--file', + linkedConfig, + '--get-regexp', + '--null', + '^pando\\.', + ]) + expect(mockRaw).not.toHaveBeenCalledWith(expect.arrayContaining(['config', '--worktree'])) + }) + + it('returns empty metadata when a worktree config file is missing', async () => { + const mainPath = path.resolve('/repo/main') + mockRaw.mockImplementation((args: string[]) => { + if (args[0] === 'worktree') return Promise.resolve(`worktree ${mainPath}\0`) + if (args[0] === 'rev-parse') return Promise.resolve('.git\n') + return Promise.reject(new Error('config.worktree does not exist')) + }) + + await expect(enumerateAll(mainPath)).resolves.toEqual([ + { worktreePath: mainPath, metadata: {} }, + ]) + }) + }) + + describe('ensureWorktreeConfigEnabled', () => { + it('short-circuits when worktree config is already enabled', async () => { + mockRaw.mockResolvedValue('true\n') + + await expect(ensureWorktreeConfigEnabled('/repo')).resolves.toEqual({ + enabled: true, + migrated: [], + }) + expect(mockRaw).toHaveBeenCalledTimes(1) + expect(mockRaw).toHaveBeenCalledWith(['config', '--get', 'extensions.worktreeConfig']) + }) + + it('enables worktree config after copying shared core settings', async () => { + mockRaw.mockImplementation((args: string[]) => { + const key = args.at(-1) + if (key === 'extensions.worktreeConfig' && args.includes('--get')) { + return Promise.reject(new Error('not set')) + } + if (key === 'core.worktree') return Promise.resolve('/repo\n') + if (key === 'core.bare') return Promise.resolve('false\n') + if (key === 'core.sparseCheckout') return Promise.reject(new Error('not set')) + return Promise.resolve('') + }) + + await expect(ensureWorktreeConfigEnabled('/repo')).resolves.toEqual({ + enabled: true, + migrated: ['core.worktree', 'core.bare'], + notice: 'Enabled extensions.worktreeConfig (migrated: core.worktree, core.bare)', + }) + expect(mockRaw.mock.calls).toEqual([ + [['config', '--get', 'extensions.worktreeConfig']], + [['config', '--local', '--get', 'core.worktree']], + [['config', '--local', '--get', 'core.bare']], + [['config', '--local', '--get', 'core.sparseCheckout']], + [['config', 'extensions.worktreeConfig', 'true']], + [['config', '--worktree', 'core.worktree', '/repo']], + [['config', '--local', '--unset', 'core.worktree']], + [['config', '--worktree', 'core.bare', 'false']], + [['config', '--local', '--unset', 'core.bare']], + ]) + }) + + it('never writes an empty core.worktree when keys are unset (simple-git returns "")', async () => { + // Regression: simple-git resolves an absent `config --get` to '' rather + // than throwing. Writing that empty value back via `config --worktree + // core.worktree ''` bricks the repo (`fatal: cannot chdir to ''`) and + // poisons every later git command. Only genuinely-set keys must migrate. + mockRaw.mockImplementation((args: string[]) => { + const key = args.at(-1) + if (key === 'extensions.worktreeConfig' && args.includes('--get')) { + return Promise.reject(new Error('not set')) + } + if (key === 'core.bare') return Promise.resolve('false\n') + // core.worktree and core.sparseCheckout are unset → simple-git yields ''. + if (args.includes('--get')) return Promise.resolve('') + return Promise.resolve('') + }) + + await expect(ensureWorktreeConfigEnabled('/repo')).resolves.toEqual({ + enabled: true, + migrated: ['core.bare'], + notice: 'Enabled extensions.worktreeConfig (migrated: core.bare)', + }) + + const writeCalls = mockRaw.mock.calls.map(([args]) => args as string[]) + expect(writeCalls).not.toContainEqual(['config', '--worktree', 'core.worktree', '']) + expect(writeCalls).not.toContainEqual(['config', '--worktree', 'core.sparseCheckout', '']) + expect(writeCalls).toContainEqual(['config', '--worktree', 'core.bare', 'false']) + }) + }) + + describe('assertGitVersion', () => { + it('throws when git is below the minimum version', async () => { + mockRaw.mockResolvedValue('git version 2.37.5\n') + + await expect(assertGitVersion()).rejects.toThrow( + 'pando worktree metadata requires git >= 2.38 (found 2.37.5)' + ) + }) + + it.each(['git version 2.38.0\n', 'git version 2.53.0\n'])( + 'passes for a supported version: %s', + async (output) => { + mockRaw.mockResolvedValue(output) + + await expect(assertGitVersion()).resolves.toBeUndefined() + expect(mockRaw).toHaveBeenCalledWith(['--version']) + } + ) + + it('does not block when the version output cannot be parsed', async () => { + mockRaw.mockResolvedValue('unknown git build') + + await expect(assertGitVersion()).resolves.toBeUndefined() + }) + }) +})