diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cf9119b..1b6217a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -193,6 +193,7 @@ jobs: test-pmctl-safe.sh test-pmctl-validate.sh test-pmctl-decision.sh + test-pmctl-worktree.sh test-doctor: runs-on: ubuntu-latest @@ -419,6 +420,13 @@ jobs: - name: pmctl validate brief regression suite run: bash scripts/test-pmctl-validate.sh + test-pmctl-worktree: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: pmctl worktree regression suite + run: bash scripts/test-pmctl-worktree.sh + lint-backlog: runs-on: ubuntu-latest steps: diff --git a/BACKLOG.md b/BACKLOG.md index dda82ab..7a79ed9 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,7 +13,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-004 | 🟢 someday | test-pr-gate.sh docstring 格式統一 | ops | 2026-05-12 | pr:#38 | P3 | — | | CC-011 | 🟢 someday | sync-memory.sh + install 選項:symlink memory 到雲端資料夾實現跨裝置共用 | ux/memory | 2026-05-14 | — | — | — | | CC-012 | 🟢 someday | SessionStart hook:session 啟動時 pull 最新 memory(git/rsync)確保跨裝置同步 | ux/memory | 2026-05-14 | — | — | — | -| CC-014 | 🔵 active | `using-git-worktrees` skill:parallel PR gate 隔離開發環境。v0.8.0 Phase 4 | arch | 2026-05-14 | — | — | — | +| CC-014 | ✅ closed 2026-07-02 | repo 通用 worktree 平行開發工具:建立/清理 worktree + using-git-worktrees skill。v0.8.0 Phase 4 | arch | 2026-05-14 | pr:#358 | — | — | | CC-015 | ⏸ deferred | `systematic-debugging` skill:結構化偵錯工作流 | ux | 2026-05-14 | — | — | — | | CC-018 | 🟢 someday | Codex quota 自動追蹤 + rate-limit 路徑統一(吸收 CC-269):寫到 `~/.local/share/pm-dispatch/state/rate-limits.json`;解析 API response headers;token-usage.sh 加 Codex pool 顯示 | ux/token | 2026-05-14 | — | P3 | — | | CC-023 | ⏸ deferred | `coupling-reviewer`:PR gate 加入語言感知耦合分析(dependency-cruiser/gocyclo/coca) | ops/gate | 2026-05-14 | — | — | — | @@ -216,12 +216,17 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed **Note**: 依賴 CC-011;建議與 CC-011 合入同一 PR(Phase 1 + Phase 2 同步落地,CC-012 無獨立實作意義)。 **Status note (CC-050 audit 2026-05-18)**: Downgraded from ⏸ deferred to 🟢 someday — depends on CC-011; no active plan. Re-evaluate together with CC-011. -## CC-014 — `using-git-worktrees` skill +## CC-014 — repo 通用 worktree 平行開發工具 ✅ 2026-07-02 -**Status note (v0.8.0 planning 2026-07-01)**: Re-activated (was downgraded to ⏸ deferred by the CC-050 audit 2026-05-18 for lacking an open branch) — assigned to v0.8.0 Phase 4. -**Problem**: `--parallel` PR gate 各 reviewer 在同一 working tree 執行,reviewer 寫入可能互相干擾。 -**Why**: git worktree 讓每個 subagent 在獨立環境工作,避免狀態污染,也直接補強 CC-003 的解法方向。 -**Requirement**: `commands/using-git-worktrees.md` skill,指導平行開發中使用 git worktree;評估 `--parallel` gate 是否可為每個 reviewer 建立獨立 worktree。 +**Status note (v0.8.0 planning 2026-07-02)**: Re-activated (was downgraded to ⏸ deferred by the CC-050 audit 2026-05-18 for lacking an open branch) — assigned to v0.8.0 Phase 4. Scope broadened 2026-07-02: 不再侷限於 pr-gate reviewer 隔離,改為 repo 層級通用 worktree 工具,讓任一 ticket/分支都能快速建立、切換、清理獨立 worktree 以支援多票並行開發。 +**Problem**: 目前開發者(與 `--parallel` PR gate 各 reviewer)都在同一 working tree 上工作,跨票並行開發時彼此的未 commit 變更、build 產物會互相干擾;沒有標準化的方式建立/清理獨立 worktree。 +**Why**: git worktree 讓每個工作串流(人或 subagent)在獨立環境工作,避免狀態污染;同時直接補強 CC-003 的解法方向,也可延伸解掉 `--parallel` gate 的 reviewer 隔離問題。 +**Requirement**: +1. `scripts/worktree-*.sh`(或等效 `pmctl` 子指令):為指定 ticket/分支建立、列出、清理 worktree,統一命名慣例與清理時機(避免孤兒 worktree 殘留)。 +2. `commands/using-git-worktrees.md` skill:指導開發者(人或 dispatch executor)如何用這些工具做功能分支平行開發。 +3. 評估 `--parallel` PR gate 是否可改用同一套工具為每個 reviewer 建立獨立 worktree(原票聚焦點,現列為本票子項而非全部範圍)。 +**Outcome**: `pmctl worktree create/list/remove/gc` 落地,manifest 存於 state store(`sw_project_worktree_dir`,跨主 repo/linked worktree 同一 partition);`commands/using-git-worktrees.md` skill 文件;36 個 focused test。`--parallel` gate reviewer 隔離(原需求 3)留待未來 follow-up ticket,未併入本次範圍。 +**See**: pr:#358 ## CC-015 — `systematic-debugging` skill diff --git a/MILESTONES.md b/MILESTONES.md index 29b6ba6..cfb04a8 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -51,13 +51,13 @@ |----|------|------| | CC-381 | install host-PM-aware — 縮小為 read-only host-profile-detection / doctor 擴充切片:讓 `doctor.sh`/`pmctl doctor` 能回答「目前 host 是 claude/codex/opencode?哪些能力有 wiring?哪些只能透過 pmctl 手動使用?」不動 installer write path。前置票 CC-372/374/375/380 已全數 done,本 Phase 目標是把 CC-381 從設計陳述推進為有明確 Requirement 的實作票 | 🔵 active | -### Phase 4 — CC-014 `using-git-worktrees` skill(P3;低風險並行;與 Phase 1-3 檔案面不重疊) +### Phase 4 — CC-014 repo 通用 worktree 平行開發工具(P3;低風險並行;與 Phase 1-3 檔案面不重疊) | 票 | 摘要 | 狀態 | |----|------|------| -| CC-014 | `using-git-worktrees` skill:為平行開發(`--parallel` PR gate 各 reviewer 目前共用同一 working tree,寫入可能互相干擾)補一份指導 skill;評估 `--parallel` gate 是否可為每個 reviewer 建立獨立 git worktree | 🔵 active | +| CC-014 | repo 通用 worktree 建立/列出/清理工具 + `using-git-worktrees` skill,支援多票並行開發;`--parallel` PR gate reviewer 隔離整合留待未來 follow-up ticket,未併入本次範圍 | ✅ done pr:#358 | -> 由 CC-050 稽核降級的 ⏸ deferred(無開放分支)重新啟用;規劃時尚未有實作分支,範圍與時程由後續 `/pre-impl` 或直接 dispatch 時再收斂。 +> 由 CC-050 稽核降級的 ⏸ deferred(無開放分支)重新啟用;2026-07-02 範圍由「pr-gate reviewer 隔離」擴大為「repo 通用 worktree 工具」;規劃時尚未有實作分支,範圍與時程由後續 `/pre-impl` 或直接 dispatch 時再收斂。 ### 待後續 / 明確排除 diff --git a/cli/pmctl b/cli/pmctl index 40899ee..80a9f91 100755 --- a/cli/pmctl +++ b/cli/pmctl @@ -16,7 +16,7 @@ done REPO_ROOT="$(cd "$(dirname "$_pmctl_self")/.." && pwd)" unset _pmctl_self _pmctl_dir -for _lib in detached-launch pmctl-policy pmctl-fs pmctl-adapter pmctl-backlog pmctl-guard executor-router pmctl-dispatch pmctl-trace pmctl-task pmctl-decision gate-result-verify pmctl-gate pmctl-safe pmctl-validate pmctl-context pmctl-memory pmctl-artifacts pmctl-pre-release; do +for _lib in detached-launch pmctl-policy pmctl-fs pmctl-adapter pmctl-backlog pmctl-guard executor-router pmctl-dispatch pmctl-trace pmctl-task pmctl-decision gate-result-verify pmctl-gate pmctl-safe pmctl-validate pmctl-context pmctl-memory pmctl-artifacts pmctl-pre-release pmctl-worktree; do # shellcheck source=/dev/null [[ -r "$REPO_ROOT/scripts/lib/$_lib.sh" ]] && . "$REPO_ROOT/scripts/lib/$_lib.sh" done @@ -99,6 +99,30 @@ case "$cmd/$sub" in fi pmctl_artifacts_migrate "$REPO_ROOT" "" "$@" ;; + worktree/create) + if ! declare -F pmctl_worktree_create >/dev/null; then + pmctl_die "worktree create unavailable" + fi + pmctl_worktree_create "$REPO_ROOT" "" "$@" + ;; + worktree/list) + if ! declare -F pmctl_worktree_list >/dev/null; then + pmctl_die "worktree list unavailable" + fi + pmctl_worktree_list "$REPO_ROOT" "" "$@" + ;; + worktree/remove) + if ! declare -F pmctl_worktree_remove >/dev/null; then + pmctl_die "worktree remove unavailable" + fi + pmctl_worktree_remove "$REPO_ROOT" "" "$@" + ;; + worktree/gc) + if ! declare -F pmctl_worktree_gc >/dev/null; then + pmctl_die "worktree gc unavailable" + fi + pmctl_worktree_gc "$REPO_ROOT" "" "$@" + ;; trace/tail) if ! declare -F pmctl_trace_tail >/dev/null; then pmctl_die "trace tail unavailable" diff --git a/commands/using-git-worktrees.md b/commands/using-git-worktrees.md new file mode 100644 index 0000000..437cd0f --- /dev/null +++ b/commands/using-git-worktrees.md @@ -0,0 +1,60 @@ +--- +description: Use a dedicated git worktree per ticket/branch for parallel development — isolates uncommitted changes and build artifacts so multiple branches can be worked on at once without stepping on each other. +argument-hint: "[branch-name]" +--- + +Set up an isolated `git worktree` for parallel development using `pmctl worktree`, instead of switching branches in place or cloning the repo again. + +## What + +`pmctl worktree` wraps `git worktree` with a small out-of-repo registry (manifest) so linked worktrees are easy to list and clean up later, without hand-tracking which directory belongs to which branch. + +**Prerequisite: this requires git.** `pmctl worktree` is a thin wrapper over `git worktree add/remove/prune` — there is no non-git fallback. The target directory must already be a git repository; `pmctl worktree create` fails immediately (git itself rejects the operation) if it is not. + +Worktrees are stored out-of-repo, under the state store (`~/.local/share/pm-dispatch/state/projects//worktrees/checkouts/`), not inside the repo — so they never show up in `git status`, don't need a `.gitignore` entry, and survive `git clean`. The registry (manifest) resolves to the **same partition** whether `pmctl worktree` is invoked from the primary checkout or from inside one of the linked worktrees it created, so `pmctl worktree list` always shows the full picture regardless of which checkout you run it from. Manifest writes (`create` appending, `remove`/`gc` removing entries) are serialized under a single lock and always commit against the manifest's current on-disk state, not a snapshot taken earlier — running `pmctl worktree create` concurrently with `remove`/`gc` from another shell or process is safe. + +## When to use + +- You're mid-way through one ticket and need to start a second one without stashing/committing WIP just to switch branches. +- You want to run a long build/test in one branch's worktree while continuing to edit another branch. +- You need a disposable checkout of a branch (e.g. to inspect an old PR) without disturbing your primary working tree. + +## Example + +```sh +pmctl worktree create feat/my-feature +pmctl worktree list +pmctl worktree remove feat/my-feature +``` + +## Usage + +``` +pmctl worktree create [--from ] [--name ] [--cd ] +pmctl worktree list [--cd ] [--json] +pmctl worktree remove [--force] [--cd ] +pmctl worktree gc [--dry-run] [--merged] [--max-age-days D] [--force] [--cd ] +``` + +- `create ` — attaches an existing local branch, or creates a new one off the current `HEAD` if it doesn't exist yet. Pass `--from ` to create the new branch off a specific base instead of `HEAD`. Prints the new worktree's absolute path on success — capture it if you need to `cd` into it. +- `create --name ` — override the manifest slug (defaults to the branch name with `/` replaced by `-`). Two worktrees cannot share a slug; `create` fails rather than silently overwriting an existing one. +- `list` — table of registered worktrees (`SLUG`, `BRANCH`, `PATH`). Add `--json` for a machine-readable array (each entry: `slug`, `branch`, `path`, `created_ts`). +- `remove ` — matches by slug or by branch name. Fails if the worktree has uncommitted changes; pass `--force` to discard them and remove anyway. **`--force` is destructive** — it discards uncommitted work in that worktree with no recovery path, so confirm you don't need those changes before passing it. +- `gc` — reconciles the manifest against actual git/filesystem state: drops entries whose directory was removed manually (e.g. `rm -rf`) or that git no longer tracks (these are never destructive — the worktree is already gone or already untracked, so `gc` only cleans up the leftover manifest entry). Add `--merged` to also remove worktrees whose branch is fully merged, or `--max-age-days N` to remove entries older than N days — by default `gc` will *not* remove a merged/aged worktree that still has uncommitted changes (same dirty-worktree protection as plain `remove` without `--force`); it prints a `skipping ... has uncommitted changes` line and keeps the manifest entry instead. Pass `gc --force` to override that protection and force-remove merged/aged worktrees even when dirty — treat it with the same caution as `remove --force`. `--merged` is evaluated against the primary checkout's branch, not whichever worktree you happen to run `gc` from, so running `gc --merged` from inside a linked worktree never mistakes "merged into itself" for "safe to delete". Always run with `--dry-run` first to see what would be removed before running for real. + +All four subcommands accept `--cd ` to target a repo other than the current directory (same convention as `pmctl artifacts`/`pmctl dispatch`). + +## Typical workflow + +1. `pmctl worktree create feat/my-feature` — creates the worktree and prints its path. +2. `cd ` and work there like a normal checkout: `git add`, `git commit`, push, open a PR — all git operations behave exactly as in a regular checkout, because a linked worktree shares the same object store and refs as the primary checkout. +3. When the ticket's PR is merged (or abandoned), `pmctl worktree remove feat/my-feature` from either the primary checkout or from inside the worktree itself. +4. Periodically run `pmctl worktree gc --dry-run --merged` to spot worktrees left behind for already-merged branches, then `pmctl worktree gc --merged` to clean them up. + +## Cleanup and orphan recovery + +If a worktree directory is deleted directly (`rm -rf` instead of `pmctl worktree remove`), git and the manifest both still reference it. Run `pmctl worktree gc` — it detects the missing path, removes the stale manifest entry, and runs `git worktree prune` so `git worktree list` stays in sync too. By default `gc` never discards a directory that still exists and has uncommitted changes: for `--merged`/`--max-age-days` matches it attempts a plain (non-forced) removal and skips + reports any that turn out dirty, exactly like `remove` without `--force`. Only entries that are already gone or already untracked by git are removed unconditionally, since there is nothing live left to lose. Pass `gc --force` if you want merged/aged dirty worktrees discarded anyway. + +## Out of scope + +This tool does not touch the `--parallel` PR gate's reviewer-isolation logic (`scripts/pr-gate.sh`) — that is a separate integration tracked independently. `pmctl worktree` is a general-purpose utility for any parallel branch work, not specific to the gate. diff --git a/scripts/lib/pmctl-worktree.sh b/scripts/lib/pmctl-worktree.sh new file mode 100644 index 0000000..d0d95f0 --- /dev/null +++ b/scripts/lib/pmctl-worktree.sh @@ -0,0 +1,532 @@ +#!/usr/bin/env bash +# pmctl worktree create/list/remove/gc -- repo-wide git worktree registry for +# parallel multi-ticket development. Manifest is stored out-of-repo in the +# state store, keyed by the MAIN repo identity (sw_project_worktree_dir / +# state-paths.sh:_sw_worktree_project_key) so a linked worktree and its +# primary checkout resolve to the SAME partition regardless of which one the +# command is invoked from. +# +# Every subcommand resolves reg_dir ONCE, up front, via +# `sw_project_worktree_dir "$work_dir"` (a pure computation that takes an +# explicit repo_root -- no cd required). All manifest reads/writes take that +# resolved reg_dir as an explicit argument instead of re-deriving it via a +# `cd "$work_dir"` subshell each time. This matters because gc/remove can +# delete the very directory `$work_dir` points at (a linked worktree +# removing itself) -- re-deriving reg_dir afterward via cd would fail once +# that directory is gone, but the already-resolved absolute reg_dir (which +# lives in the state store, not the repo) keeps working. + +pmctl_worktree_usage() { + printf 'usage: pmctl worktree create [--from ] [--name ] [--cd ]\n' >&2 + printf ' pmctl worktree list [--cd ] [--json]\n' >&2 + printf ' pmctl worktree remove [--force] [--cd ]\n' >&2 + printf ' pmctl worktree gc [--dry-run] [--merged] [--max-age-days D] [--force] [--cd ]\n' >&2 +} + +pmctl_worktree_ensure_state_paths() { + local repo_root="${1:-}" + if [[ "$(type -t sw_project_worktree_dir 2>/dev/null)" != function ]]; then + local _sp_lib="${repo_root:-}/scripts/lib/state-paths.sh" + if [[ -r "$_sp_lib" ]]; then + # shellcheck disable=SC1090,SC1091 # dynamic repo-root path. + . "$_sp_lib" 2>/dev/null || true + fi + fi + if [[ "$(type -t sw_project_worktree_dir 2>/dev/null)" != function ]]; then + printf 'pmctl worktree: state-paths.sh unavailable; cannot resolve worktree registry dir\n' >&2 + return 2 + fi +} + +pmctl_worktree_ensure_writer() { + local repo_root="${1:-}" + if [[ "$(type -t _sw_compact_json_line 2>/dev/null)" != function || "$(type -t serialize_with_lock 2>/dev/null)" != function \ + || "$(type -t _sw_ensure_store_root_safe 2>/dev/null)" != function ]]; then + local _sw_lib="${repo_root:-}/scripts/lib/state-writer.sh" + if [[ -r "$_sw_lib" ]]; then + # shellcheck disable=SC1090,SC1091 # dynamic repo-root path. + . "$_sw_lib" 2>/dev/null || true + fi + fi + if [[ "$(type -t _sw_compact_json_line 2>/dev/null)" != function || "$(type -t serialize_with_lock 2>/dev/null)" != function \ + || "$(type -t _sw_ensure_store_root_safe 2>/dev/null)" != function ]]; then + printf 'pmctl worktree: state-writer.sh unavailable; cannot write worktree manifest\n' >&2 + return 2 + fi +} + +# pmctl_worktree_ensure_root_safe +# Route every worktree-registry write (manifest mkdir/mktemp, and the git +# checkout create/remove/gc perform under it) through the SAME unsafe-root +# check ("state_store_init" / "_sw_ensure_store_root_safe") that guards every +# other state-store writer -- a symlinked or non-owned PM_DISPATCH_STATE_ROOT +# leaf must be rejected here too, not just for runs/events/decisions. Must be +# called (after pmctl_worktree_ensure_writer) before ANY mkdir/mktemp/git-add +# under a resolved reg_dir. +pmctl_worktree_ensure_root_safe() { + _sw_ensure_store_root_safe "$(_sw_store_root)" +} + +# _pmctl_worktree_slugify +# Normalize a branch name into a filesystem-safe, single-path-segment slug: +# `/` -> `-`, then strip everything outside [A-Za-z0-9._-]. Rejects a branch +# that slugifies to empty or that still contains a path-escape sequence. +_pmctl_worktree_slugify() { + local branch="${1:-}" slug + slug="${branch//\//-}" + slug="$(printf '%s' "$slug" | tr -c 'A-Za-z0-9._-' '-')" + if [[ -z "$slug" || "$slug" == *..* || "$slug" == .* ]]; then + printf 'pmctl worktree: branch name does not produce a safe slug: %s\n' "$branch" >&2 + return 1 + fi + printf '%s\n' "$slug" +} + +# _pmctl_worktree_main_root +# Resolve the PRIMARY (non-worktree) checkout root for work_dir, mirroring +# state-paths.sh:_sw_main_repo_root. Used to evaluate branch --merged status +# against a STABLE reference instead of the caller's own possibly-linked- +# worktree HEAD (see pmctl_worktree_gc for why that distinction matters). +_pmctl_worktree_main_root() { + local work_dir="$1" common_dir + common_dir="$(git -C "$work_dir" rev-parse --git-common-dir 2>/dev/null)" || return 1 + if [[ "$common_dir" == /* ]]; then + dirname "$common_dir" + else + git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null + fi +} + +# _pmctl_worktree_branch_is_merged +# Exact-match membership test against `git branch --merged` output. `git +# branch [--merged]` always prints a fixed 2-character marker column +# ("* ", "+ ", " ") followed by the ref name, so stripping exactly 2 +# characters and comparing for equality is both correct and immune to +# branch names containing regex metacharacters (a prior version matched +# via `grep -E`, so a branch named e.g. "a.b" could false-positive match +# an unrelated merged branch "axb"). +_pmctl_worktree_branch_is_merged() { + local main_root="$1" branch="$2" line + while IFS= read -r line; do + [[ "${line:2}" == "$branch" ]] && return 0 + done < <(git -C "$main_root" branch --merged 2>/dev/null) + return 1 +} + +_pmctl_worktree_manifest_append_inner() { + local json_line="$1" compact manifest="$2" + compact="$(_sw_compact_json_line "$json_line")" || return $? + printf '%s\n' "$compact" >> "$manifest" +} + +# pmctl_worktree_manifest_append +pmctl_worktree_manifest_append() { + local reg_dir="$1" json_line="$2" manifest rc=0 + mkdir -p "$reg_dir" 2>/dev/null || { printf 'pmctl worktree: mkdir failed: %s\n' "$reg_dir" >&2; return 1; } + chmod 0700 "$reg_dir" 2>/dev/null || true + manifest="$reg_dir/manifest.jsonl" + serialize_with_lock "$reg_dir/manifest" _pmctl_worktree_manifest_append_inner "$json_line" "$manifest" || rc=$? + return "$rc" +} + +# _pmctl_worktree_manifest_remove_slugs_inner +# Runs under the SAME lock as append: re-reads the CURRENT manifest content +# (not a snapshot taken before acquiring the lock) and writes back only the +# entries whose slug is NOT in the removal set. Read + filter + write all +# happen inside one locked critical section, so a `create` that appends +# between an earlier (unlocked) decision-making read and this commit is +# never silently dropped -- its new slug simply isn't in the removal set, +# so it survives regardless of when it was appended relative to that +# earlier read. +_pmctl_worktree_manifest_remove_slugs_inner() { + local reg_dir="$1" manifest="$1/manifest.jsonl" tmp current new_content slugs_json + shift + current="" + [[ -f "$manifest" ]] && current="$(cat "$manifest")" + [[ -n "$current" ]] || return 0 + slugs_json="$(printf '%s\n' "$@" | jq -R . | jq -s -c .)" + new_content="$(printf '%s\n' "$current" | jq -c --argjson slugs "$slugs_json" \ + 'select(.slug as $s | ($slugs | index($s)) == null)')" + tmp="$(mktemp "$reg_dir/.manifest.XXXXXX")" || return 1 + printf '%s' "$new_content" > "$tmp" + mv -f "$tmp" "$manifest" +} + +# pmctl_worktree_manifest_remove_slugs +# Atomic (locked read-modify-write) removal of one or more slugs from the +# manifest. This is the ONLY way remove/gc should mutate the manifest -- +# never pass a pre-filtered full-content blob to be written blind, since +# that would silently discard any entry a concurrent `create` appended +# after the caller's own (unlocked) read. +pmctl_worktree_manifest_remove_slugs() { + local reg_dir="$1" + shift + [[ $# -gt 0 ]] || return 0 + mkdir -p "$reg_dir" 2>/dev/null || { printf 'pmctl worktree: mkdir failed: %s\n' "$reg_dir" >&2; return 1; } + serialize_with_lock "$reg_dir/manifest" _pmctl_worktree_manifest_remove_slugs_inner "$reg_dir" "$@" +} + +# pmctl_worktree_manifest_read +pmctl_worktree_manifest_read() { + local reg_dir="$1" manifest="$1/manifest.jsonl" + [[ -f "$manifest" ]] && cat "$manifest" + return 0 +} + +pmctl_worktree_create() { + local repo_root="${1:-}" work_dir="${2:-}" branch="" base="" name="" args=() + shift 2 || true + [[ -n "$work_dir" ]] || work_dir="$repo_root" + args=("$@") + local i=0 rest=() + while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + --from) + base="${args[$((i+1))]:-}" + [[ -n "$base" ]] || { printf 'pmctl worktree create: --from requires a branch\n' >&2; return 2; } + i=$((i+2)) + ;; + --name) + name="${args[$((i+1))]:-}" + [[ -n "$name" ]] || { printf 'pmctl worktree create: --name requires a slug\n' >&2; return 2; } + i=$((i+2)) + ;; + --cd) + work_dir="${args[$((i+1))]:-}" + [[ -n "$work_dir" ]] || { printf 'pmctl worktree create: --cd requires a directory\n' >&2; return 2; } + i=$((i+2)) + ;; + -h|--help) + pmctl_worktree_usage + return 0 + ;; + *) + rest+=("${args[$i]}") + i=$((i+1)) + ;; + esac + done + branch="${rest[0]:-}" + if [[ -z "$branch" ]]; then + printf 'pmctl worktree create: is required\n' >&2 + pmctl_worktree_usage + return 2 + fi + [[ -n "$work_dir" ]] || work_dir="$repo_root" + pmctl_worktree_ensure_state_paths "$repo_root" || return $? + pmctl_worktree_ensure_writer "$repo_root" || return $? + pmctl_worktree_ensure_root_safe || return 1 + + local slug + slug="$(_pmctl_worktree_slugify "${name:-$branch}")" || return 1 + + local reg_dir wt_path + reg_dir="$(sw_project_worktree_dir "$work_dir")" || { + printf 'pmctl worktree create: cannot resolve worktree registry dir from %s\n' "$work_dir" >&2 + return 1 + } + wt_path="$reg_dir/checkouts/$slug" + + if [[ -e "$wt_path" ]]; then + printf 'pmctl worktree create: a worktree already exists at %s (slug %s in use)\n' "$wt_path" "$slug" >&2 + return 1 + fi + # `-Fxq` (fixed-string, whole-line match) -- NOT `grep -q "^worktree $path\$"`. + # A path is arbitrary data (PM_DISPATCH_STATE_ROOT can contain regex + # metacharacters like `[`), so treating it as a regex pattern can silently + # misclassify a tracked path as untracked or vice versa. + if git -C "$work_dir" worktree list --porcelain 2>/dev/null | grep -Fxq "worktree $wt_path"; then + printf 'pmctl worktree create: git already tracks a worktree at %s\n' "$wt_path" >&2 + return 1 + fi + # A manifest entry can outlive its checkout (manual `rm -rf` + `git + # worktree prune` without going through `pmctl worktree remove`/`gc` + # first) -- the two checks above only see live filesystem/git state, so + # they would miss that stale registration and let this same slug get + # appended a second time. Check the manifest itself too. + if [[ -n "$(pmctl_worktree_manifest_read "$reg_dir" | jq -c --arg s "$slug" 'select(.slug == $s)' | head -1)" ]]; then + printf 'pmctl worktree create: slug %s is already registered in the manifest (stale entry? run '\''pmctl worktree gc'\'' first)\n' "$slug" >&2 + return 1 + fi + + mkdir -p "$reg_dir/checkouts" 2>/dev/null || true + + local git_args=(worktree add) + if [[ -n "$base" ]]; then + git_args+=(-b "$branch" "$wt_path" "$base") + elif git -C "$work_dir" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then + git_args+=("$wt_path" "$branch") + else + git_args+=(-b "$branch" "$wt_path") + fi + + if ! git -C "$work_dir" "${git_args[@]}"; then + printf 'pmctl worktree create: git worktree add failed\n' >&2 + return 1 + fi + + local created_ts json_line + created_ts="$(date -Is 2>/dev/null || date)" + json_line="$(printf '{"slug":%s,"branch":%s,"path":%s,"created_ts":%s}' \ + "$(jq -Rn --arg v "$slug" '$v')" \ + "$(jq -Rn --arg v "$branch" '$v')" \ + "$(jq -Rn --arg v "$wt_path" '$v')" \ + "$(jq -Rn --arg v "$created_ts" '$v')")" + if ! pmctl_worktree_manifest_append "$reg_dir" "$json_line"; then + # gc reconciles EXISTING manifest entries against git/filesystem state -- + # it cannot discover a worktree that never got a manifest entry in the + # first place, so this is not gc-recoverable. Surface it as a failure + # (not a warning) and point at the manual recovery path instead. + printf 'pmctl worktree create: worktree created at %s but manifest registration failed -- it will not appear in '\''pmctl worktree list/gc'\''. Run '\''git worktree remove %s'\'' to discard it, or retry '\''pmctl worktree create'\'' after fixing the manifest write error above.\n' "$wt_path" "$wt_path" >&2 + printf '%s\n' "$wt_path" + return 1 + fi + printf '%s\n' "$wt_path" +} + +pmctl_worktree_list() { + local repo_root="${1:-}" work_dir json_out=0 args=() + shift || true + work_dir="${1:-$repo_root}" + shift || true + args=("$@") + local i=0 + while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + --cd) + work_dir="${args[$((i+1))]:-}" + [[ -n "$work_dir" ]] || { printf 'pmctl worktree list: --cd requires a directory\n' >&2; return 2; } + i=$((i+2)) + ;; + --json) json_out=1; i=$((i+1)) ;; + -h|--help) pmctl_worktree_usage; return 0 ;; + *) i=$((i+1)) ;; + esac + done + [[ -n "$work_dir" ]] || work_dir="$repo_root" + pmctl_worktree_ensure_state_paths "$repo_root" || return $? + + local reg_dir manifest_content + reg_dir="$(sw_project_worktree_dir "$work_dir")" || true + manifest_content="$(pmctl_worktree_manifest_read "$reg_dir")" + + if [[ "$json_out" -eq 1 ]]; then + if [[ -z "$manifest_content" ]]; then + printf '[]\n' + else + printf '%s\n' "$manifest_content" | jq -s -c . + fi + return 0 + fi + + if [[ -z "$manifest_content" ]]; then + printf 'No registered worktrees.\n' + return 0 + fi + printf '%-30s %-30s %s\n' SLUG BRANCH PATH + printf '%s\n' "$manifest_content" | while IFS= read -r line; do + [[ -n "$line" ]] || continue + jq -r '[.slug, .branch, .path] | @tsv' <<<"$line" | while IFS=$'\t' read -r slug branch path; do + printf '%-30s %-30s %s\n' "$slug" "$branch" "$path" + done + done +} + +pmctl_worktree_remove() { + local repo_root="${1:-}" work_dir target force=0 args=() + shift || true + work_dir="${1:-$repo_root}" + shift || true + args=("$@") + local i=0 rest=() + while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + --force) force=1; i=$((i+1)) ;; + --cd) + work_dir="${args[$((i+1))]:-}" + [[ -n "$work_dir" ]] || { printf 'pmctl worktree remove: --cd requires a directory\n' >&2; return 2; } + i=$((i+2)) + ;; + -h|--help) pmctl_worktree_usage; return 0 ;; + *) rest+=("${args[$i]}"); i=$((i+1)) ;; + esac + done + target="${rest[0]:-}" + if [[ -z "$target" ]]; then + printf 'pmctl worktree remove: is required\n' >&2 + pmctl_worktree_usage + return 2 + fi + [[ -n "$work_dir" ]] || work_dir="$repo_root" + pmctl_worktree_ensure_state_paths "$repo_root" || return $? + pmctl_worktree_ensure_writer "$repo_root" || return $? + pmctl_worktree_ensure_root_safe || return 1 + + local reg_dir manifest_content match_line match_path match_slug + reg_dir="$(sw_project_worktree_dir "$work_dir")" || { + printf 'pmctl worktree remove: cannot resolve worktree registry dir from %s\n' "$work_dir" >&2 + return 1 + } + manifest_content="$(pmctl_worktree_manifest_read "$reg_dir")" + match_line="$(printf '%s\n' "$manifest_content" | jq -c --arg t "$target" 'select(.slug == $t or .branch == $t)' | head -1)" + if [[ -z "$match_line" ]]; then + printf 'pmctl worktree remove: no registered worktree matches %s\n' "$target" >&2 + return 1 + fi + match_path="$(jq -r '.path' <<<"$match_line")" + match_slug="$(jq -r '.slug' <<<"$match_line")" + + local git_rm_args=(worktree remove) + [[ "$force" -eq 1 ]] && git_rm_args+=(--force) + git_rm_args+=("$match_path") + if [[ -d "$match_path" ]]; then + if ! git -C "$work_dir" "${git_rm_args[@]}"; then + printf 'pmctl worktree remove: git worktree remove failed for %s (dirty? pass --force to override)\n' "$match_path" >&2 + return 1 + fi + fi + git -C "$work_dir" worktree prune 2>/dev/null || true + + if ! pmctl_worktree_manifest_remove_slugs "$reg_dir" "$match_slug"; then + printf 'pmctl worktree remove: worktree removed but manifest cleanup failed for slug %s -- run '\''pmctl worktree gc'\'' to reconcile\n' "$match_slug" >&2 + return 1 + fi + printf 'removed %s (%s)\n' "$target" "$match_path" +} + +pmctl_worktree_gc() { + local repo_root="${1:-}" work_dir dry_run=0 merged_only=0 max_age_days=0 force=0 args=() + shift || true + work_dir="${1:-$repo_root}" + shift || true + args=("$@") + local i=0 + while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + --dry-run) dry_run=1; i=$((i+1)) ;; + --merged) merged_only=1; i=$((i+1)) ;; + --force) force=1; i=$((i+1)) ;; + --max-age-days) + max_age_days="${args[$((i+1))]:-0}" + if ! [[ "$max_age_days" =~ ^[0-9]+$ ]]; then + printf 'pmctl worktree gc: --max-age-days requires an integer >= 0\n' >&2 + return 2 + fi + i=$((i+2)) + ;; + --cd) + work_dir="${args[$((i+1))]:-}" + [[ -n "$work_dir" ]] || { printf 'pmctl worktree gc: --cd requires a directory\n' >&2; return 2; } + i=$((i+2)) + ;; + -h|--help) pmctl_worktree_usage; return 0 ;; + *) i=$((i+1)) ;; + esac + done + [[ -n "$work_dir" ]] || work_dir="$repo_root" + pmctl_worktree_ensure_state_paths "$repo_root" || return $? + pmctl_worktree_ensure_writer "$repo_root" || return $? + pmctl_worktree_ensure_root_safe || return 1 + + local reg_dir manifest_content now_epoch max_age_seconds main_root + reg_dir="$(sw_project_worktree_dir "$work_dir")" || { + printf 'pmctl worktree gc: cannot resolve worktree registry dir from %s\n' "$work_dir" >&2 + return 1 + } + manifest_content="$(pmctl_worktree_manifest_read "$reg_dir")" + [[ -n "$manifest_content" ]] || { printf 'gc: no registered worktrees\n'; return 0; } + now_epoch="$(date +%s 2>/dev/null || echo 0)" + max_age_seconds=$(( max_age_days * 86400 )) + # --merged must be evaluated against the PRIMARY checkout's HEAD, not + # work_dir's own HEAD: a branch is trivially "merged into itself", so if + # work_dir is itself a linked worktree, `git branch --merged` run there + # would flag the worktree's own checked-out branch as removable. + main_root="$(_pmctl_worktree_main_root "$work_dir")" || main_root="$work_dir" + + local removed_slugs=() removed_count=0 skipped_dirty=0 + while IFS= read -r line; do + [[ -n "$line" ]] || continue + local slug branch path created_ts age_seconds should_remove=0 reason="" destructive=0 + slug="$(jq -r '.slug' <<<"$line")" + branch="$(jq -r '.branch' <<<"$line")" + path="$(jq -r '.path' <<<"$line")" + created_ts="$(jq -r '.created_ts' <<<"$line")" + + if [[ ! -d "$path" ]]; then + should_remove=1; reason="path missing (orphaned manifest entry)" + elif ! git -C "$work_dir" worktree list --porcelain 2>/dev/null | grep -Fxq "worktree $path"; then + # `-Fxq`, not a regex `grep -q "^worktree $path\$"`: $path is data (a + # symlink-free but otherwise arbitrary filesystem path derived from + # PM_DISPATCH_STATE_ROOT), and this decision gates a `git worktree + # remove --force` a few lines below -- a false "no longer tracked" + # here force-deletes a still-live, possibly dirty worktree. + should_remove=1; reason="git no longer tracks this worktree" + elif [[ "$merged_only" -eq 1 ]] && _pmctl_worktree_branch_is_merged "$main_root" "$branch"; then + should_remove=1; destructive=1; reason="branch merged" + elif [[ "$max_age_days" -gt 0 ]]; then + local created_epoch + # Strip only the trailing timezone offset (the LAST +/- in the string, + # via the shortest-suffix-match `%` form) before handing to BSD/macOS + # `date -jf`, which has no offset syntax. Using the greedy `%%` form + # here was a bug: it strips from the FIRST `-` in the string, which + # for an ISO timestamp is inside the date portion itself + # ("2026-07-02T..." -> greedy strip left only "2026"). + created_epoch="$(date -d "$created_ts" +%s 2>/dev/null || date -jf '%Y-%m-%dT%H:%M:%S' "${created_ts%[+-]*}" +%s 2>/dev/null || echo "$now_epoch")" + age_seconds=$(( now_epoch - created_epoch )) + if [[ "$age_seconds" -ge "$max_age_seconds" ]]; then + should_remove=1; destructive=1; reason="older than $max_age_days day(s)" + fi + fi + + if [[ "$should_remove" -eq 1 ]]; then + if [[ "$dry_run" -eq 1 ]]; then + removed_count=$((removed_count+1)) + printf 'would remove %s (%s): %s\n' "$slug" "$path" "$reason" + continue + fi + if [[ "$destructive" -eq 1 && -d "$path" ]]; then + # merged/age-based reasons touch a LIVE worktree that may have + # uncommitted changes -- default to git's own dirty-worktree + # refusal (same as `remove` without --force) unless the caller + # explicitly opted into `gc --force`. + local rm_args=(worktree remove) + [[ "$force" -eq 1 ]] && rm_args+=(--force) + rm_args+=("$path") + if ! git -C "$work_dir" "${rm_args[@]}" 2>/dev/null; then + printf 'skipping %s (%s): has uncommitted changes -- pass gc --force to remove anyway\n' "$slug" "$path" + skipped_dirty=$((skipped_dirty+1)) + continue + fi + elif [[ -d "$path" ]]; then + # orphaned / no-longer-tracked reasons: nothing live to lose, safe + # to force since git already doesn't consider this a real worktree. + git -C "$work_dir" worktree remove --force "$path" 2>/dev/null || true + fi + removed_count=$((removed_count+1)) + removed_slugs+=("$slug") + printf 'removed %s (%s): %s\n' "$slug" "$path" "$reason" + fi + done <<<"$manifest_content" + + git -C "$work_dir" worktree prune 2>/dev/null || true + + if [[ "$dry_run" -eq 0 && "${#removed_slugs[@]}" -gt 0 ]]; then + # Commits the removal atomically against the CURRENT manifest (see + # pmctl_worktree_manifest_remove_slugs) -- any worktree a concurrent + # `create` registered after the read at the top of this function is + # NOT in removed_slugs, so it survives this write even though our + # should_remove decisions above were made from a stale snapshot. + pmctl_worktree_manifest_remove_slugs "$reg_dir" "${removed_slugs[@]}" || { + printf 'pmctl worktree gc: manifest cleanup failed after removal -- some removed worktrees may still be listed; re-run '\''pmctl worktree gc'\'' to reconcile\n' >&2 + return 1 + } + fi + + if [[ "$dry_run" -eq 1 ]]; then + printf 'gc: dry-run, would remove %d worktree(s)\n' "$removed_count" + else + printf 'gc: removed %d worktree(s)' "$removed_count" + [[ "$skipped_dirty" -gt 0 ]] && printf ', skipped %d dirty worktree(s)' "$skipped_dirty" + printf '\n' + fi +} diff --git a/scripts/lib/state-paths.sh b/scripts/lib/state-paths.sh index dc25b36..f976597 100644 --- a/scripts/lib/state-paths.sh +++ b/scripts/lib/state-paths.sh @@ -95,6 +95,89 @@ _sw_project_dir() { return 0 } +# _sw_main_repo_root [repo_root] +# Resolve the PRIMARY (non-worktree) checkout root, so callers get the same +# identity whether invoked from the main checkout or from inside a linked +# `git worktree`. `git rev-parse --show-toplevel` (used by _sw_project_key) +# returns the linked worktree's OWN path when run inside one, which would +# split one project's state across partitions depending on caller cwd. +# `--git-common-dir` instead always resolves to the primary worktree's `.git`: +# an absolute path when run from inside a linked worktree, or the relative +# string ".git" when run from the primary worktree itself (git only prints +# it relative to cwd there, since git-common-dir == git-dir in that case). +_sw_main_repo_root() { + { + local repo_root="${1:-}" common_dir + if [[ -n "$repo_root" ]]; then + common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null)" + else + common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" + fi + [[ -n "$common_dir" ]] || return 1 + if [[ "$common_dir" == /* ]]; then + dirname "$common_dir" + elif [[ -n "$repo_root" ]]; then + git -C "$repo_root" rev-parse --show-toplevel 2>/dev/null + else + git rev-parse --show-toplevel 2>/dev/null + fi + } 2>/dev/null || true + return 0 +} + +# _sw_worktree_project_key +# Same hashing scheme as _sw_project_key, but keyed off _sw_main_repo_root +# instead of --show-toplevel, so a linked worktree and its primary checkout +# resolve to the SAME partition. Used only by sw_project_worktree_dir -- +# existing _sw_project_key/_sw_project_dir/sw_project_run_dir callers +# (dispatch, gate, artifacts) are intentionally left untouched by this +# helper; see docs/architecture/v0.4.0-state-first-foundation.md A5 for the +# broader identity-reconciliation gap this does NOT attempt to fix. +_sw_worktree_project_key() { + { + local main_root project_key + if [[ -n "${_SW_REPO_ROOT:-}" ]]; then + main_root="$(_sw_main_repo_root "${_SW_REPO_ROOT}")" + else + main_root="$(_sw_main_repo_root)" + fi + if [[ -z "$main_root" ]]; then + printf 'global\n' + return 0 + fi + main_root="$(_portable_canonical_path "$main_root")" + if ! project_key="$(printf '%s\n' "$main_root" | _portable_sha1 2>/dev/null)"; then + _sw_log_error "_sw_worktree_project_key: failed to hash main repo root; falling back to global: $main_root" + project_key="" + fi + if [[ -n "$project_key" ]]; then + printf '%s\n' "$project_key" + else + printf 'global\n' + fi + } 2>/dev/null || true + return 0 +} + +# sw_project_worktree_dir [repo_root] +# Print the absolute worktree-registry directory for the given (or ambient +# cwd) repo's MAIN partition (stable whether invoked from the main checkout +# or from inside a linked worktree pmctl created): +# /projects//worktrees +# Pure computation -- does NOT create the directory; pmctl-worktree.sh owns +# mkdir + manifest writes. Accepting an explicit repo_root lets callers +# resolve the registry dir WITHOUT cd'ing into it first, so the resolution +# doesn't depend on that directory continuing to exist for the rest of the +# call (e.g. gc deleting the very worktree it was invoked from via --cd). +sw_project_worktree_dir() { + local repo_root="${1:-}" + if [[ -n "$repo_root" ]]; then + printf '%s/projects/%s/worktrees\n' "$(_sw_store_root)" "$(_SW_REPO_ROOT="$repo_root" _sw_worktree_project_key)" + else + printf '%s/projects/%s/worktrees\n' "$(_sw_store_root)" "$(_sw_worktree_project_key)" + fi +} + # sw_project_run_dir # Print the absolute run-artifact directory for the current project partition: # /projects//runs/ diff --git a/scripts/test-commands.sh b/scripts/test-commands.sh index d30570d..d84dd8d 100755 --- a/scripts/test-commands.sh +++ b/scripts/test-commands.sh @@ -352,6 +352,23 @@ should_run "pre-release: Layer 2 section is informational only" && assert_file_c should_run "pre-release: documents Layer 3 blind spots" && assert_file_contains "pre-release: documents Layer 3 blind spots" "$PRE_RELEASE" "Layer 3" && pass "pre-release: documents Layer 3 blind spots" should_run "pre-release: documents exit codes" && assert_file_contains "pre-release: documents exit codes" "$PRE_RELEASE" "Exit codes" && pass "pre-release: documents exit codes" +# ── using-git-worktrees.md contract ───────────────────────────────────────── + +USING_GIT_WORKTREES="$COMMANDS_DIR/using-git-worktrees.md" + +assert_frontmatter "using-git-worktrees: frontmatter valid" "$USING_GIT_WORKTREES" +should_run "using-git-worktrees: states git is a hard prerequisite" && assert_file_contains "using-git-worktrees: states git is a hard prerequisite" "$USING_GIT_WORKTREES" "this requires git" && pass "using-git-worktrees: states git is a hard prerequisite" +should_run "using-git-worktrees: documents create subcommand" && assert_file_contains "using-git-worktrees: documents create subcommand" "$USING_GIT_WORKTREES" "pmctl worktree create" && pass "using-git-worktrees: documents create subcommand" +should_run "using-git-worktrees: documents list subcommand" && assert_file_contains "using-git-worktrees: documents list subcommand" "$USING_GIT_WORKTREES" "pmctl worktree list" && pass "using-git-worktrees: documents list subcommand" +should_run "using-git-worktrees: documents remove subcommand" && assert_file_contains "using-git-worktrees: documents remove subcommand" "$USING_GIT_WORKTREES" "pmctl worktree remove" && pass "using-git-worktrees: documents remove subcommand" +should_run "using-git-worktrees: documents gc subcommand" && assert_file_contains "using-git-worktrees: documents gc subcommand" "$USING_GIT_WORKTREES" "pmctl worktree gc" && pass "using-git-worktrees: documents gc subcommand" +should_run "using-git-worktrees: documents --force is destructive" && assert_file_contains "using-git-worktrees: documents --force is destructive" "$USING_GIT_WORKTREES" "destructive" && pass "using-git-worktrees: documents --force is destructive" +should_run "using-git-worktrees: documents cross-worktree identity guarantee" && assert_file_contains "using-git-worktrees: documents cross-worktree identity guarantee" "$USING_GIT_WORKTREES" "same partition" && pass "using-git-worktrees: documents cross-worktree identity guarantee" +should_run "using-git-worktrees: documents concurrent manifest write safety" && assert_file_contains "using-git-worktrees: documents concurrent manifest write safety" "$USING_GIT_WORKTREES" "serialized under a single lock" && pass "using-git-worktrees: documents concurrent manifest write safety" +should_run "using-git-worktrees: documents orphan recovery via gc" && assert_file_contains "using-git-worktrees: documents orphan recovery via gc" "$USING_GIT_WORKTREES" "git worktree prune" && pass "using-git-worktrees: documents orphan recovery via gc" +should_run "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" && assert_file_contains "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" "$USING_GIT_WORKTREES" "does not touch the \`--parallel\` PR gate" && pass "using-git-worktrees: excludes --parallel gate reviewer isolation from scope" +should_run "using-git-worktrees: no CC ticket references" && assert_not_contains "using-git-worktrees: no CC ticket references" "$USING_GIT_WORKTREES" "CC-" + # ── summary ────────────────────────────────────────────────────────────────── th_summary diff --git a/scripts/test-pmctl-worktree.sh b/scripts/test-pmctl-worktree.sh new file mode 100755 index 0000000..10c114b --- /dev/null +++ b/scripts/test-pmctl-worktree.sh @@ -0,0 +1,898 @@ +#!/usr/bin/env bash +# Regression tests for `pmctl worktree create/list/remove/gc`. +# shellcheck disable=SC2154 # tmp_root supplied by sourced test-harness +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PMCTL="$REPO_ROOT/cli/pmctl" + +# shellcheck source=scripts/lib/test-harness.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/test-harness.sh" +th_init "$@" + +make_work_repo() { + local path="$1" + mkdir -p "$path" + git init -q "$path" + git -C "$path" config user.email test@example.com + git -C "$path" config user.name test + printf 'seed\n' > "$path/seed.txt" + git -C "$path" add seed.txt + git -C "$path" commit -q -m seed +} + +wt_list_json() { + local store="$1" work="$2" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree list --json --cd "$work" +} + +# reg_dir_for +# Resolve the worktree-registry directory for a test partition, so a case +# can directly rewrite manifest.jsonl's created_ts to simulate an aged +# entry without waiting. +reg_dir_for() { + local store="$1" work="$2" + PM_DISPATCH_STATE_ROOT="$store" bash -c \ + '. "$1/scripts/lib/state-paths.sh" && sw_project_worktree_dir "$2"' \ + _ "$REPO_ROOT" "$work" +} + +# manifest_append_raw +# Calls pmctl_worktree_manifest_append directly (bypassing `pmctl worktree +# create`'s git-checkout step) so a test can simulate a concurrent create's +# manifest write landing at a precise point in another operation's sequence. +manifest_append_raw() { + local reg_dir="$1" json_line="$2" + bash -c ' + repo_root="$1"; reg_dir="$2"; json_line="$3" + . "$repo_root/scripts/lib/portable.sh" + . "$repo_root/scripts/lib/state-writer.sh" + . "$repo_root/scripts/lib/pmctl-worktree.sh" + pmctl_worktree_manifest_append "$reg_dir" "$json_line" + ' _ "$REPO_ROOT" "$reg_dir" "$json_line" +} + +# manifest_remove_slugs_raw +# Calls pmctl_worktree_manifest_remove_slugs directly -- the same locked +# read-modify-write primitive `remove`/`gc` commit through. +manifest_remove_slugs_raw() { + local reg_dir="$1" + shift + bash -c ' + repo_root="$1"; reg_dir="$2"; shift 2 + . "$repo_root/scripts/lib/portable.sh" + . "$repo_root/scripts/lib/state-writer.sh" + . "$repo_root/scripts/lib/pmctl-worktree.sh" + pmctl_worktree_manifest_remove_slugs "$reg_dir" "$@" + ' _ "$REPO_ROOT" "$reg_dir" "$@" +} + +case_create_requires_branch() { + # behavior: pmctl worktree create with no arg exits 2 and prints usage + # Steps: run create with only --cd; assert exit 2, stderr has " is required" and "usage:" + local name="worktree create: missing exits 2 with usage" + should_run "$name" || return 0 + local store work out err status=0 + store="$tmp_root/state-create-noarg" + work="$tmp_root/work-create-noarg" + make_work_repo "$work" + out="$tmp_root/c1.out"; err="$tmp_root/c1.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create --cd "$work" > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *" is required"* && "$(<"$err")" == *"usage:"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_create_new_branch() { + # behavior: create with a branch name that doesn't exist yet creates the branch + worktree + manifest entry + # Steps: run create feat/x; assert exit 0, printed path is a directory, manifest has branch feat/x + local name="worktree create: new branch creates worktree + manifest entry" + should_run "$name" || return 0 + local store work out err status=0 wt_path + store="$tmp_root/state-create-new" + work="$tmp_root/work-create-new" + make_work_repo "$work" + out="$tmp_root/c2.out"; err="$tmp_root/c2.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/x --cd "$work" > "$out" 2> "$err" || status=$? + wt_path="$(tail -1 "$out")" + if [[ "$status" -eq 0 && -d "$wt_path" ]] \ + && [[ "$(wt_list_json "$store" "$work" | jq -r '.[0].branch')" == "feat/x" ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out") err=$(<"$err")" + fi +} + +case_create_from_base() { + # behavior: create --from creates the new branch off the given base commit, not HEAD + # Steps: create a base-branch pointer; create feat/from-base --from base-branch; assert its HEAD sha == base sha + local name="worktree create: --from creates a new branch off the given base" + should_run "$name" || return 0 + local store work out err status=0 wt_path base_sha branch_sha + store="$tmp_root/state-create-from" + work="$tmp_root/work-create-from" + make_work_repo "$work" + git -C "$work" branch base-branch + out="$tmp_root/c3.out"; err="$tmp_root/c3.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/from-base --from base-branch --cd "$work" > "$out" 2> "$err" || status=$? + wt_path="$(tail -1 "$out")" + base_sha="$(git -C "$work" rev-parse base-branch)" + branch_sha="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null || true)" + if [[ "$status" -eq 0 && "$branch_sha" == "$base_sha" ]]; then + pass "$name" + else + fail "$name" "status=$status base_sha=$base_sha branch_sha=$branch_sha err=$(<"$err")" + fi +} + +case_create_existing_branch_no_new_ref() { + # behavior: create on a branch that already exists attaches to it instead of erroring or creating a duplicate ref + # Steps: create a local branch; run create ; assert exit 0 and no "already exists" error + local name="worktree create: existing branch attaches without creating a duplicate ref" + should_run "$name" || return 0 + local store work out err status=0 + store="$tmp_root/state-create-existing" + work="$tmp_root/work-create-existing" + make_work_repo "$work" + git -C "$work" branch existing-branch + out="$tmp_root/c4.out"; err="$tmp_root/c4.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create existing-branch --cd "$work" > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 0 && "$(<"$err")" != *"already exists"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_create_name_override_slug() { + # behavior: create --name overrides the default branch-derived manifest slug + # Steps: run create feat/named --name custom-slug; assert the manifest's slug field is custom-slug + local name="worktree create: --name overrides the manifest slug" + should_run "$name" || return 0 + local store work out err status=0 + store="$tmp_root/state-create-name" + work="$tmp_root/work-create-name" + make_work_repo "$work" + out="$tmp_root/c5.out"; err="$tmp_root/c5.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/named --name custom-slug --cd "$work" > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 0 && "$(wt_list_json "$store" "$work" | jq -r '.[0].slug')" == "custom-slug" ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_create_duplicate_slug_rejected() { + # behavior: create with a slug that is already registered fails and does not add a second manifest entry + # Steps: create feat/dup twice; assert first succeeds, second fails with "already exists", manifest has exactly 1 entry + local name="worktree create: duplicate slug is rejected, no duplicate manifest entry" + should_run "$name" || return 0 + local store work err1 err2 status1=0 status2=0 + store="$tmp_root/state-create-dup" + work="$tmp_root/work-create-dup" + make_work_repo "$work" + err1="$tmp_root/c6a.err"; err2="$tmp_root/c6b.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/dup --cd "$work" > /dev/null 2> "$err1" || status1=$? + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/dup --cd "$work" > /dev/null 2> "$err2" || status2=$? + if [[ "$status1" -eq 0 && "$status2" -ne 0 && "$(<"$err2")" == *"already exists"* \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status1=$status1 status2=$status2 err2=$(<"$err2")" + fi +} + +case_create_stale_manifest_duplicate_slug_rejected() { + # behavior: create rejects a slug that is still registered in the manifest even when the checkout was + # deleted and pruned OUTSIDE pmctl (rm -rf + git worktree prune, bypassing `remove`/`gc`) -- + # the live-path and git-worktree-list checks alone would miss this and append a duplicate row + # Steps: create feat/stale, then rm -rf its directory and `git worktree prune` directly (not via pmctl); + # run create feat/stale again; assert it is rejected and the manifest still has exactly 1 entry + local name="worktree create: stale manifest entry blocks recreating the same slug" + should_run "$name" || return 0 + local store work wt_path err status=0 + store="$tmp_root/state-create-stale" + work="$tmp_root/work-create-stale" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/stale --cd "$work" 2>/dev/null | tail -1)" + rm -rf "$wt_path" + git -C "$work" worktree prune + err="$tmp_root/c9.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/stale --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -ne 0 && "$(<"$err")" == *"already registered"* \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err") count=$(wt_list_json "$store" "$work" | jq 'length')" + fi +} + +case_create_rejects_unsafe_symlinked_state_root() { + # behavior: worktree writes (manifest mkdir, checkout creation) are rejected when PM_DISPATCH_STATE_ROOT + # itself is a symlink, mirroring the same unsafe-root policy other state-store writers enforce + # Steps: point PM_DISPATCH_STATE_ROOT at a symlink to a real directory; run create; assert nonzero exit + # and stderr mentions the unsafe-root rejection, and nothing was created under the symlink target + local name="worktree create: rejects a symlinked PM_DISPATCH_STATE_ROOT" + should_run "$name" || return 0 + local work real_target link err status=0 + work="$tmp_root/work-create-unsafe-root" + make_work_repo "$work" + real_target="$tmp_root/unsafe-root-real" + mkdir -p "$real_target" + link="$tmp_root/unsafe-root-link" + ln -s "$real_target" "$link" + err="$tmp_root/c10.err" + PM_DISPATCH_STATE_ROOT="$link" "$PMCTL" worktree create feat/unsafe-root --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -ne 0 && "$(<"$err")" == *"unsafe state root rejected"* \ + && -z "$(find "$real_target" -mindepth 1 2>/dev/null)" ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_create_unsafe_slug_rejected() { + # behavior: a branch name that slugifies to an unsafe segment (e.g. "..") is rejected before touching git + # Steps: run create '..'; assert non-zero exit and stderr mentions "safe slug" + local name="worktree create: a branch slug of '..' is rejected before touching git" + should_run "$name" || return 0 + local store work err status=0 + store="$tmp_root/state-create-unsafe" + work="$tmp_root/work-create-unsafe" + make_work_repo "$work" + err="$tmp_root/c7.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create '..' --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -ne 0 && "$(<"$err")" == *"safe slug"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_create_help() { + # behavior: create -h prints usage and exits 0 without creating anything + # Steps: run create -h; assert exit 0 and stderr contains "usage:" + local name="worktree create: -h prints usage and exits 0" + should_run "$name" || return 0 + local store work out status=0 + store="$tmp_root/state-create-help" + work="$tmp_root/work-create-help" + make_work_repo "$work" + out="$tmp_root/c8.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create -h --cd "$work" > /dev/null 2> "$out" || status=$? + if [[ "$status" -eq 0 && "$(<"$out")" == *"usage:"* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out")" + fi +} + +case_create_missing_cd_value() { + # behavior: create --cd with no following operand exits 2 instead of silently falling back to the + # invoking pmctl's own repo (a malformed --cd must never resolve to a default target -- + # that default could be the wrong repo for a command that creates a checkout) + # Steps: run create feat/x --cd (no value after --cd, nothing else follows); assert exit 2 and + # stderr says --cd requires a directory + local name="worktree create: missing --cd operand exits 2 instead of defaulting to another repo" + should_run "$name" || return 0 + local err status=0 + err="$tmp_root/cdmiss1.err" + PM_DISPATCH_STATE_ROOT="$tmp_root/state-cd-missing-guard" "$PMCTL" worktree create feat/x --cd > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *"--cd requires a directory"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_list_missing_cd_value() { + # behavior: list --cd with no following operand exits 2 instead of silently defaulting + # Steps: run list --cd (no value); assert exit 2 and stderr says --cd requires a directory + local name="worktree list: missing --cd operand exits 2 instead of defaulting to another repo" + should_run "$name" || return 0 + local err status=0 + err="$tmp_root/cdmiss2.err" + PM_DISPATCH_STATE_ROOT="$tmp_root/state-cd-missing-guard" "$PMCTL" worktree list --cd > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *"--cd requires a directory"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_remove_missing_cd_value() { + # behavior: remove --cd with no following operand exits 2 BEFORE any destructive git/manifest + # operation, instead of silently defaulting to another repo's worktree registry + # Steps: run remove sometarget --cd (no value); assert exit 2 and stderr says --cd requires a directory + local name="worktree remove: missing --cd operand exits 2 instead of defaulting to another repo" + should_run "$name" || return 0 + local err status=0 + err="$tmp_root/cdmiss3.err" + PM_DISPATCH_STATE_ROOT="$tmp_root/state-cd-missing-guard" "$PMCTL" worktree remove sometarget --cd > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *"--cd requires a directory"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_gc_missing_cd_value() { + # behavior: gc --cd with no following operand exits 2 BEFORE any destructive git/manifest + # operation, instead of silently defaulting to another repo's worktree registry + # Steps: run gc --cd (no value); assert exit 2 and stderr says --cd requires a directory + local name="worktree gc: missing --cd operand exits 2 instead of defaulting to another repo" + should_run "$name" || return 0 + local err status=0 + err="$tmp_root/cdmiss4.err" + PM_DISPATCH_STATE_ROOT="$tmp_root/state-cd-missing-guard" "$PMCTL" worktree gc --cd > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *"--cd requires a directory"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_list_empty() { + # behavior: list on an empty registry prints a human-readable "no worktrees" message + # Steps: run list on a fresh repo with no registered worktrees; assert output contains "No registered worktrees." + local name="worktree list: empty registry prints no-worktrees message" + should_run "$name" || return 0 + local store work out status=0 + store="$tmp_root/state-list-empty" + work="$tmp_root/work-list-empty" + make_work_repo "$work" + out="$tmp_root/l1.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree list --cd "$work" > "$out" 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(<"$out")" == *"No registered worktrees."* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out")" + fi +} + +case_list_json_valid() { + # behavior: list --json prints a valid JSON array with one element per registered worktree + # Steps: create one worktree; run list --json; assert output type is array with length 1 + local name="worktree list: --json prints a valid JSON array" + should_run "$name" || return 0 + local store work status=0 + store="$tmp_root/state-list-json" + work="$tmp_root/work-list-json" + make_work_repo "$work" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/j --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(wt_list_json "$store" "$work" | jq 'type')" == '"array"' \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status" + fi +} + +case_list_text_table() { + # behavior: list (no --json) prints a human-readable SLUG/BRANCH/PATH table + # Steps: create one worktree; run list; assert header row has SLUG/BRANCH/PATH and a data row has the branch + local name="worktree list: text mode prints a SLUG/BRANCH/PATH table" + should_run "$name" || return 0 + local store work out status=0 + store="$tmp_root/state-list-text" + work="$tmp_root/work-list-text" + make_work_repo "$work" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/t --cd "$work" > /dev/null 2>&1 + out="$tmp_root/l3.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree list --cd "$work" > "$out" 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(sed -n '1p' "$out")" == *SLUG*BRANCH*PATH* && "$(sed -n '2p' "$out")" == *"feat/t"* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out")" + fi +} + +case_list_cross_worktree_identity() { + # behavior: list invoked with --cd pointing INSIDE a linked worktree resolves the same manifest partition + # as the primary checkout (the identity seam this whole feature depends on) + # Steps: create a worktree from the primary checkout; run list --json --cd ; + # assert it sees the same entry it was just registered under + local name="worktree list: invoked from inside a linked worktree sees the same manifest" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-list-identity" + work="$tmp_root/work-list-identity" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/identity --cd "$work" 2>/dev/null | tail -1)" + local inside_json + inside_json="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree list --json --cd "$wt_path" 2>/dev/null)" || status=$? + if [[ "$status" -eq 0 && "$(jq -r '.[0].branch' <<<"$inside_json")" == "feat/identity" ]]; then + pass "$name" + else + fail "$name" "status=$status inside_json=$inside_json" + fi +} + +case_remove_requires_target() { + # behavior: remove with no arg exits 2 and prints usage + # Steps: run remove with only --cd; assert exit 2 and stderr has " is required" + local name="worktree remove: missing exits 2 with usage" + should_run "$name" || return 0 + local store work err status=0 + store="$tmp_root/state-remove-noarg" + work="$tmp_root/work-remove-noarg" + make_work_repo "$work" + err="$tmp_root/r1.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree remove --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *" is required"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_remove_unknown_target() { + # behavior: remove with a target that matches no manifest entry exits non-zero with a clear error + # Steps: run remove nope on an empty registry; assert non-zero exit and stderr mentions no match found + local name="worktree remove: unknown target exits 1" + should_run "$name" || return 0 + local store work err status=0 + store="$tmp_root/state-remove-unknown" + work="$tmp_root/work-remove-unknown" + make_work_repo "$work" + err="$tmp_root/r2.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree remove nope --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -ne 0 && "$(<"$err")" == *"no registered worktree matches"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_remove_success() { + # behavior: remove on a clean worktree deletes the git worktree directory and its manifest entry + # Steps: create feat/rm; remove feat/rm; assert exit 0, directory gone, manifest empty + local name="worktree remove: removes git worktree and manifest entry" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-remove-ok" + work="$tmp_root/work-remove-ok" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/rm --cd "$work" 2>/dev/null | tail -1)" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree remove feat/rm --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && ! -d "$wt_path" && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status=$status wt_path=$wt_path exists=$([[ -d "$wt_path" ]] && echo yes || echo no)" + fi +} + +case_remove_dirty_requires_force() { + # behavior: remove on a worktree with uncommitted changes fails without --force and succeeds with it + # Steps: create feat/dirty, add an untracked file; remove without --force (assert fails, dir survives); + # remove --force (assert succeeds, dir gone) + local name="worktree remove: dirty worktree fails without --force, succeeds with it" + should_run "$name" || return 0 + local store work wt_path status1=0 status2=0 existed_after_first=0 existed_after_second=0 + store="$tmp_root/state-remove-dirty" + work="$tmp_root/work-remove-dirty" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/dirty --cd "$work" 2>/dev/null | tail -1)" + printf 'dirty\n' > "$wt_path/dirty.txt" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree remove feat/dirty --cd "$work" > /dev/null 2>&1 || status1=$? + [[ -d "$wt_path" ]] && existed_after_first=1 + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree remove feat/dirty --force --cd "$work" > /dev/null 2>&1 || status2=$? + [[ -d "$wt_path" ]] && existed_after_second=1 + if [[ "$status1" -ne 0 && "$existed_after_first" -eq 1 && "$status2" -eq 0 && "$existed_after_second" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status1=$status1 existed_after_first=$existed_after_first status2=$status2 existed_after_second=$existed_after_second" + fi +} + +case_manifest_remove_slugs_survives_concurrent_append() { + # behavior: pmctl_worktree_manifest_remove_slugs (the primitive remove/gc commit their removal + # through) reads the manifest FRESH inside its lock, so an entry appended AFTER an + # earlier decision-time read but BEFORE the removal commits is never silently dropped -- + # this is the exact race pattern remove/gc go through: read manifest (unlocked, to decide + # what to remove) -> [a concurrent create can land here] -> commit removal (locked) + # Steps: register A and B; take a manifest snapshot (simulating remove/gc's decision-time read); + # THEN append C directly (simulating a concurrent `create` landing in the race window between + # that read and the commit below); THEN commit removal of A via the same primitive remove/gc + # use; assert the final manifest has B and C but NOT A -- proving C survived even though it + # was appended after the snapshot the removal decision was based on + local name="worktree manifest: concurrent create appended during a remove/gc race window survives" + should_run "$name" || return 0 + local store work reg_dir status=0 + store="$tmp_root/state-manifest-race" + work="$tmp_root/work-manifest-race" + make_work_repo "$work" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/race-a --cd "$work" > /dev/null 2>&1 + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/race-b --cd "$work" > /dev/null 2>&1 + reg_dir="$(reg_dir_for "$store" "$work")" + + # Simulate remove/gc's unlocked decision-time read (its content is irrelevant here -- what matters + # is that this read happens BEFORE the concurrent append below, mirroring the real race window). + cat "$reg_dir/manifest.jsonl" > /dev/null + + # Concurrent create landing in the race window: appends C AFTER the decision read above but + # BEFORE the removal of A commits below. + local race_json + race_json="$(jq -cn --arg v race-c '{"slug":$v,"branch":"feat/race-c","path":"/tmp/race-c","created_ts":"2026-01-01T00:00:00+00:00"}')" + manifest_append_raw "$reg_dir" "$race_json" + + # Commit removal of A via the exact same primitive remove/gc use. + manifest_remove_slugs_raw "$reg_dir" feat-race-a > /dev/null 2>&1 || status=$? + + local final_json has_a has_b has_c + final_json="$(wt_list_json "$store" "$work")" + has_a="$(jq '[.[] | select(.slug=="feat-race-a")] | length' <<<"$final_json")" + has_b="$(jq '[.[] | select(.slug=="feat-race-b")] | length' <<<"$final_json")" + has_c="$(jq '[.[] | select(.slug=="race-c")] | length' <<<"$final_json")" + if [[ "$status" -eq 0 && "$has_a" -eq 0 && "$has_b" -eq 1 && "$has_c" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status has_a=$has_a has_b=$has_b has_c=$has_c final=$final_json" + fi +} + +case_gc_no_worktrees() { + # behavior: gc on an empty registry is a no-op that reports so and exits 0 + # Steps: run gc on a repo with no registered worktrees; assert exit 0 and output mentions no registered worktrees + local name="worktree gc: empty registry is a no-op" + should_run "$name" || return 0 + local store work out status=0 + store="$tmp_root/state-gc-empty" + work="$tmp_root/work-gc-empty" + make_work_repo "$work" + out="$tmp_root/g1.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --cd "$work" > "$out" 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(<"$out")" == *"no registered worktrees"* ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out")" + fi +} + +case_gc_dry_run_no_mutation() { + # behavior: gc --dry-run reports what it would remove but leaves the manifest untouched + # Steps: create a worktree, delete its directory manually (orphan it); run gc --dry-run; + # assert output says "would remove" and the manifest still has the entry + local name="worktree gc: --dry-run reports but does not mutate the manifest" + should_run "$name" || return 0 + local store work wt_path out status=0 + store="$tmp_root/state-gc-dry" + work="$tmp_root/work-gc-dry" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/gcdry --cd "$work" 2>/dev/null | tail -1)" + rm -rf "$wt_path" + out="$tmp_root/g2.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --dry-run --cd "$work" > "$out" 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(<"$out")" == *"would remove"* \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status out=$(<"$out")" + fi +} + +case_gc_removes_orphaned_manifest_entry() { + # behavior: gc removes a manifest entry whose directory was deleted outside of pmctl (e.g. rm -rf) + # Steps: create a worktree, delete its directory manually; run gc; assert manifest is empty afterward + local name="worktree gc: removes a manifest entry whose path was manually deleted" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-orphan" + work="$tmp_root/work-gc-orphan" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/orphan --cd "$work" 2>/dev/null | tail -1)" + rm -rf "$wt_path" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status=$status" + fi +} + +case_gc_merged_flag() { + # behavior: gc --merged removes a clean worktree whose branch is fully merged into the primary checkout's HEAD + # Steps: create feat/merged with no new commits (trivially merged); run gc --merged from the primary checkout; + # assert manifest is empty afterward + local name="worktree gc: --merged removes worktrees whose branch is fully merged" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-merged" + work="$tmp_root/work-gc-merged" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/merged --cd "$work" 2>/dev/null | tail -1)" + # feat/merged has no new commits, so it is already fully merged into the base. + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --merged --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status=$status" + fi +} + +case_gc_merged_does_not_self_remove_when_invoked_from_inside() { + # behavior: gc --merged evaluates "merged" against the PRIMARY checkout's HEAD, not the invoking worktree's + # own HEAD -- so it must not treat an unmerged branch as removable just because gc was run + # from inside that very worktree (a branch is trivially "merged into itself") + # Steps: create feat/self, commit something on it that master does NOT have (genuinely unmerged); + # run gc --merged --cd ; assert the worktree and its manifest entry survive + local name="worktree gc: --merged run from inside the linked worktree does not remove itself" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-self" + work="$tmp_root/work-gc-self" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/self --cd "$work" 2>/dev/null | tail -1)" + git -C "$wt_path" config user.email t@e.com + git -C "$wt_path" config user.name t + printf 'unmerged\n' > "$wt_path/unmerged.txt" + git -C "$wt_path" add unmerged.txt + git -C "$wt_path" commit -q -m unmerged + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --merged --cd "$wt_path" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && -d "$wt_path" && "$(wt_list_json "$store" "$wt_path" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status wt_path_exists=$([[ -d "$wt_path" ]] && echo yes || echo no)" + fi +} + +case_gc_merged_skips_dirty_without_force() { + # behavior: gc --merged must not silently discard uncommitted changes -- a merged-but-dirty worktree is + # skipped (kept in the manifest, reported) unless the caller explicitly passes gc --force + # Steps: create feat/dirty-merged (trivially merged), add an untracked file; run gc --merged (no --force): + # assert it is skipped with an "uncommitted changes" message and the directory/manifest entry survive; + # run gc --merged --force: assert it is now removed + local name="worktree gc: --merged skips a dirty worktree without --force, removes it with --force" + should_run "$name" || return 0 + local store work wt_path out status1=0 status2=0 existed_after_skip=0 existed_after_force=0 + store="$tmp_root/state-gc-dirty-merged" + work="$tmp_root/work-gc-dirty-merged" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/dirty-merged --cd "$work" 2>/dev/null | tail -1)" + printf 'dirty\n' > "$wt_path/dirty.txt" + out="$tmp_root/gcd1.out" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --merged --cd "$work" > "$out" 2>&1 || status1=$? + [[ -d "$wt_path" ]] && existed_after_skip=1 + local kept_after_skip + kept_after_skip="$(wt_list_json "$store" "$work" | jq 'length')" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --merged --force --cd "$work" > /dev/null 2>&1 || status2=$? + [[ -d "$wt_path" ]] && existed_after_force=1 + if [[ "$status1" -eq 0 && "$existed_after_skip" -eq 1 && "$kept_after_skip" -eq 1 && "$(<"$out")" == *"uncommitted changes"* \ + && "$status2" -eq 0 && "$existed_after_force" -eq 0 && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status1=$status1 existed_after_skip=$existed_after_skip kept_after_skip=$kept_after_skip status2=$status2 existed_after_force=$existed_after_force out=$(<"$out")" + fi +} + +case_gc_merged_exact_match_ignores_regex_metachar_collision() { + # behavior: gc --merged uses EXACT branch-name matching, so a branch containing regex metacharacters + # (e.g. a literal dot) must not false-positive match an unrelated already-merged branch whose + # name happens to satisfy the metachar as a wildcard (a prior version used `grep -E` and a + # branch "a.b" would incorrectly match a merged branch literally named "axb") + # Steps: create an unrelated already-merged plain branch "axb"; create worktree branch "a.b" and give it + # a commit master does not have (genuinely unmerged); run gc --merged; assert "a.b" survives + local name="worktree gc: --merged exact-matches branch names, ignoring regex-metachar collisions" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-regex" + work="$tmp_root/work-gc-regex" + make_work_repo "$work" + git -C "$work" branch axb + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create 'a.b' --cd "$work" 2>/dev/null | tail -1)" + git -C "$wt_path" config user.email t@e.com + git -C "$wt_path" config user.name t + printf 'unmerged\n' > "$wt_path/unmerged.txt" + git -C "$wt_path" add unmerged.txt + git -C "$wt_path" commit -q -m unmerged + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --merged --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && -d "$wt_path" && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status wt_path_exists=$([[ -d "$wt_path" ]] && echo yes || echo no)" + fi +} + +case_gc_max_age_days_filters() { + # behavior: gc --max-age-days N only removes entries older than N days; a freshly created entry is kept + # Steps: create feat/fresh; run gc --max-age-days 30 immediately afterward; assert directory and manifest entry survive + local name="worktree gc: --max-age-days only removes entries older than the threshold" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-age" + work="$tmp_root/work-gc-age" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/fresh --cd "$work" 2>/dev/null | tail -1)" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --max-age-days 30 --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && -d "$wt_path" && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status" + fi +} + +case_gc_max_age_days_removes_aged_entry() { + # behavior: gc --max-age-days N actually removes a worktree whose manifest created_ts is older than N days + # (case_gc_max_age_days_filters above only proves a FRESH entry survives -- this proves the + # destructive removal side of the same flag actually fires) + # Steps: create feat/aged, then directly rewrite its manifest created_ts to 60 days ago; run + # gc --max-age-days 30; assert the directory is gone and the manifest entry is removed + local name="worktree gc: --max-age-days removes an entry older than the threshold" + should_run "$name" || return 0 + local store work wt_path reg_dir manifest old_ts status=0 + store="$tmp_root/state-gc-age-aged" + work="$tmp_root/work-gc-age-aged" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/aged --cd "$work" 2>/dev/null | tail -1)" + reg_dir="$(reg_dir_for "$store" "$work")" + manifest="$reg_dir/manifest.jsonl" + old_ts="$(date -d '60 days ago' -Is 2>/dev/null || date -v-60d -Is 2>/dev/null)" + jq -c --arg ts "$old_ts" '.created_ts = $ts' "$manifest" > "$manifest.new" && mv "$manifest.new" "$manifest" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --max-age-days 30 --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && ! -d "$wt_path" && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 0 ]]; then + pass "$name" + else + fail "$name" "status=$status wt_path_exists=$([[ -d "$wt_path" ]] && echo yes || echo no)" + fi +} + +case_gc_max_age_days_bsd_fallback_parses_correctly() { + # behavior: when `date -d` is unavailable (the BSD/macOS case), gc --max-age-days falls back to + # `date -jf '%Y-%m-%dT%H:%M:%S' "${created_ts%[+-]*}"` -- this must strip ONLY the trailing + # timezone offset, not truncate the whole ISO timestamp down to just the year (a prior `%%` + # greedy-strip bug did exactly that, since the date portion itself contains "-") + # Steps: install a fake `date` on PATH that rejects `-d` (forcing the fallback branch) and re-derives + # the epoch for `-jf` calls via the real system date, so this test proves BOTH that the fallback + # branch actually executes AND that it computes the correct (not year-only) epoch; assert an + # aged entry is removed and a fresh entry survives under the SAME fallback-only `date` + local name="worktree gc: --max-age-days BSD-fallback path strips only the timezone, not the whole date" + should_run "$name" || return 0 + local store work wt_path_aged wt_path_fresh reg_dir manifest old_ts fake_bin status=0 + + fake_bin="$tmp_root/fake-bsd-date-bin" + mkdir -p "$fake_bin" + cat > "$fake_bin/date" <<'EOF' +#!/usr/bin/env bash +# Fake BSD-style `date`: rejects -d (forcing callers onto the -jf fallback +# path), and implements -jf by re-deriving the epoch through the real +# system date binary -- so this stub proves the -jf branch actually ran +# with a correctly-stripped timestamp, not just that SOME epoch came out. +if [[ "$1" == "-d" ]]; then + exit 1 +fi +if [[ "$1" == "-jf" ]]; then + shift 2 + exec /usr/bin/date -d "$1" "${@:2}" +fi +exec /usr/bin/date "$@" +EOF + chmod +x "$fake_bin/date" + + store="$tmp_root/state-gc-age-bsd" + work="$tmp_root/work-gc-age-bsd" + make_work_repo "$work" + wt_path_aged="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/bsd-aged --cd "$work" 2>/dev/null | tail -1)" + wt_path_fresh="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/bsd-fresh --cd "$work" 2>/dev/null | tail -1)" + reg_dir="$(reg_dir_for "$store" "$work")" + manifest="$reg_dir/manifest.jsonl" + old_ts="$(date -d '60 days ago' -Is)" + jq -c --arg ts "$old_ts" --arg slug feat-bsd-aged 'if .slug == $slug then .created_ts = $ts else . end' "$manifest" \ + > "$manifest.new" && mv "$manifest.new" "$manifest" + + PATH="$fake_bin:$PATH" PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --max-age-days 30 --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && ! -d "$wt_path_aged" && -d "$wt_path_fresh" \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status aged_exists=$([[ -d "$wt_path_aged" ]] && echo yes || echo no) fresh_exists=$([[ -d "$wt_path_fresh" ]] && echo yes || echo no)" + fi +} + +case_gc_max_age_days_rejects_non_integer() { + # behavior: gc --max-age-days with a non-integer value is rejected with exit 2 and a documented error + # Steps: run gc --max-age-days nope; assert exit 2 and stderr mentions the requirement + local name="worktree gc: --max-age-days rejects a non-integer value" + should_run "$name" || return 0 + local store work err status=0 + store="$tmp_root/state-gc-age-badarg" + work="$tmp_root/work-gc-age-badarg" + make_work_repo "$work" + err="$tmp_root/gcbad.err" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --max-age-days nope --cd "$work" > /dev/null 2> "$err" || status=$? + if [[ "$status" -eq 2 && "$(<"$err")" == *"--max-age-days requires an integer"* ]]; then + pass "$name" + else + fail "$name" "status=$status err=$(<"$err")" + fi +} + +case_gc_prunes_git_state() { + # behavior: gc also runs `git worktree prune` so git's own bookkeeping stays in sync with the manifest + # Steps: create a worktree, delete its directory manually, run gc; assert `git worktree list` shows only + # the primary checkout afterward (no stray registered-but-gone entries) + local name="worktree gc: leaves git's own worktree list in sync (no stray entries)" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state-gc-prune" + work="$tmp_root/work-gc-prune" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/prune --cd "$work" 2>/dev/null | tail -1)" + rm -rf "$wt_path" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && "$(git -C "$work" worktree list --porcelain | grep -c '^worktree ')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status" + fi +} + +case_gc_path_with_regex_metachar_not_misclassified() { + # behavior: gc's "is this path still tracked by git" check must use fixed-string/exact matching, not + # regex -- the checkout path is built from PM_DISPATCH_STATE_ROOT, which is arbitrary data + # (not something the tool controls the syntax of), so a state root containing a regex + # metacharacter like `[` must not make `grep` misparse the pattern and false-negative a + # still-tracked, still-dirty LIVE worktree as "git no longer tracks this" -- which would + # then force-remove it and discard uncommitted changes with no --force confirmation + # Steps: use a PM_DISPATCH_STATE_ROOT containing `[`; create a worktree (its path inherits the `[`); + # make it dirty; run plain `gc` (no flags); assert the worktree and its uncommitted file + # survive and the manifest still has the entry -- proving it was correctly recognized as + # still tracked, not force-removed as a false orphan + local name="worktree gc: a checkout path containing a regex metacharacter is not misclassified as untracked" + should_run "$name" || return 0 + local store work wt_path status=0 + store="$tmp_root/state[meta" + work="$tmp_root/work-gc-metachar" + make_work_repo "$work" + wt_path="$(PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree create feat/metachar --cd "$work" 2>/dev/null | tail -1)" + printf 'dirty\n' > "$wt_path/dirty.txt" + PM_DISPATCH_STATE_ROOT="$store" "$PMCTL" worktree gc --cd "$work" > /dev/null 2>&1 || status=$? + if [[ "$status" -eq 0 && -d "$wt_path" && -f "$wt_path/dirty.txt" \ + && "$(wt_list_json "$store" "$work" | jq 'length')" -eq 1 ]]; then + pass "$name" + else + fail "$name" "status=$status wt_path_exists=$([[ -d "$wt_path" ]] && echo yes || echo no)" + fi +} + +case_create_requires_branch +case_create_new_branch +case_create_from_base +case_create_existing_branch_no_new_ref +case_create_name_override_slug +case_create_duplicate_slug_rejected +case_create_stale_manifest_duplicate_slug_rejected +case_create_rejects_unsafe_symlinked_state_root +case_create_unsafe_slug_rejected +case_create_help +case_create_missing_cd_value +case_list_empty +case_list_missing_cd_value +case_list_json_valid +case_list_text_table +case_list_cross_worktree_identity +case_remove_requires_target +case_remove_missing_cd_value +case_remove_unknown_target +case_remove_success +case_remove_dirty_requires_force +case_manifest_remove_slugs_survives_concurrent_append +case_gc_no_worktrees +case_gc_missing_cd_value +case_gc_dry_run_no_mutation +case_gc_removes_orphaned_manifest_entry +case_gc_merged_flag +case_gc_merged_does_not_self_remove_when_invoked_from_inside +case_gc_merged_skips_dirty_without_force +case_gc_merged_exact_match_ignores_regex_metachar_collision +case_gc_max_age_days_filters +case_gc_max_age_days_removes_aged_entry +case_gc_max_age_days_bsd_fallback_parses_correctly +case_gc_max_age_days_rejects_non_integer +case_gc_prunes_git_state +case_gc_path_with_regex_metachar_not_misclassified + +th_summary