From 6ff21937dfb24b50e32564fe018f2891e898b051 Mon Sep 17 00:00:00 2001 From: Duncan McGreggor Date: Tue, 27 Oct 2026 23:57:54 -0500 Subject: [PATCH] build: add planning worktree setup targets Adapt the Vlajmaji helper for fresh clones and planning worktree setup, with preservation checks and disposable-repository tests run in CI. Document the workflow and its branch-publication behavior for contributors. Co-authored-by: Codex Co-authored-by: Billo AI --- .github/workflows/ci.yml | 1 + CONTRIBUTING.md | 38 +++ Makefile | 22 +- scripts/planning-worktree.sh | 255 +++++++++++++++++++ tests/test-planning-worktree.sh | 419 ++++++++++++++++++++++++++++++++ 5 files changed, 734 insertions(+), 1 deletion(-) create mode 100755 scripts/planning-worktree.sh create mode 100755 tests/test-planning-worktree.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5861e803..50fa4a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo check --workspace - run: cargo fmt --all -- --check + - run: make test-tools - run: cargo clippy --workspace --all-targets -- -D warnings test: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 092cb8dc..94f384f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,44 @@ Thanks for helping improve NMS Copilot. The project is a Rust 2024 workspace with a minimum supported Rust version of 1.91. See [AGENTS.md](AGENTS.md) for repository conventions and the crate layout in the [README](README.md). +## Planning worktree + +A fresh clone contains the implementation checkout. To attach the separate +planning documents, run: + +```bash +make planning +``` + +`make planning-worktree` is an alias. Setup needs Git and Bash (including the +Bash 3.2 shipped with macOS), plus access to `origin`; it does not need Rust. +The helper fetches `origin/planning` and attaches it at `.worktrees/planning`. +A clean local planning branch can fast-forward, and a local branch ahead of the +remote is preserved without pushing its newer commits. + +If the remote planning branch is absent, the helper publishes an existing local +planning branch, or creates and publishes an empty orphan root when neither +exists. That initialization requires a configured Git name/email and permission +to push to `origin`. New planning history starts without implementation files. +The implementation branch, working files, and staging index are preserved. + +Repeated setup reuses the registered worktree. Dirty, locked, misplaced, or +divergent planning worktrees are left for manual reconciliation; the helper does +not reset or clean them. A remote access failure is reported as an error rather +than treated as an absent branch. Existing directories and tracked source paths +are protected against accidental reuse as a planning checkout. + +Keep plans and project records in `.worktrees/planning`; user-facing guides +remain in `docs/`. Run the helper's tests without contacting any remote service: + +```bash +make test-tools +``` + +The tests use disposable repositories and local bare remotes. The underlying +`scripts/planning-worktree.sh --help` also describes direct helper options for a +different remote, branch, or worktree path. + ## Build and validate From the repository root, the normal local sequence is: diff --git a/Makefile b/Makefile index a8f32fe2..28c9cf5b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Makefile for the Fermata Project +# Makefile for NMS Copilot # ANSI color codes BLUE := \033[1;34m @@ -12,6 +12,7 @@ RESET := \033[0m PROJECT_NAME := NMS Copilot CODE_NAME := nms-copilot BIN_DIR := ./bin +BASH ?= /bin/bash MODE := debug TARGET := ./target/$(MODE) GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") @@ -53,6 +54,11 @@ help: @echo " $(YELLOW)make clean$(RESET) - Clean bin directory" @echo " $(YELLOW)make clean-all$(RESET) - Full clean (cargo clean)" @echo "" + @echo "$(GREEN)Contributor Setup:$(RESET)" + @echo " $(YELLOW)make planning$(RESET) - Fetch or create .worktrees/planning (publishes a missing remote branch)" + @echo " $(YELLOW)make planning-worktree$(RESET) - Alias for make planning" + @echo " $(YELLOW)make test-tools$(RESET) - Test planning setup with disposable local repositories" + @echo "" @echo "$(GREEN)Utilities:$(RESET)" @echo " $(YELLOW)make push$(RESET) - Pushes to Codeberg and Github" @echo " $(YELLOW)make publish$(RESET) - WIP: Publishes all crates to crates.io" @@ -277,6 +283,20 @@ docs: @echo " * file://$(shell pwd)/$(DOCS_PATH)/index.html" @echo +# Contributor setup. The helper preserves the implementation checkout and index. +.PHONY: planning planning-worktree test-tools +planning: + @printf '%bSetting up the planning worktree...%b\n' '$(BLUE)' '$(RESET)' + @"$(BASH)" scripts/planning-worktree.sh --remote origin --branch planning --path .worktrees/planning + @printf '%b✓ Planning worktree ready%b\n' '$(GREEN)' '$(RESET)' + +planning-worktree: planning + +test-tools: + @"$(BASH)" -n scripts/planning-worktree.sh + @"$(BASH)" -n tests/test-planning-worktree.sh + @"$(BASH)" tests/test-planning-worktree.sh + # Utility targets .PHONY: tracked-files tracked-files: diff --git a/scripts/planning-worktree.sh b/scripts/planning-worktree.sh new file mode 100755 index 00000000..2108dfb5 --- /dev/null +++ b/scripts/planning-worktree.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Create or attach a planning worktree while preserving implementation state. +# Adapted from oxur/vlajmaji scripts/planning-worktree.sh. +# Compatible with the Bash 3.2 shipped with macOS. +set -euo pipefail + +die() { printf 'planning-worktree: %s\n' "$*" >&2; exit 1; } +usage() { + printf 'Usage: %s [--remote origin] [--branch planning] [--path .worktrees/planning]\n' "${0##*/}" +} + +# Resolve every component, including symlinks reached after a nonexistent/.. +# prefix. Resolving only the longest existing ancestor can miss those links. +# This also handles missing suffixes without requiring GNU realpath. +canonical_path() ( + local path=$1 pending part resolved=/ candidate link links=0 + [[ $path == /* ]] || path=$PWD/$path + pending=${path#/} + while [[ -n $pending ]]; do + case $pending in + */*) part=${pending%%/*}; pending=${pending#*/} ;; + *) part=$pending; pending='' ;; + esac + case $part in + ''|.) ;; + ..) resolved=${resolved%/*}; [[ -n $resolved ]] || resolved=/ ;; + *) + candidate=${resolved%/}/$part + if [[ -L $candidate ]]; then + links=$((links + 1)) + [[ $links -le 40 ]] || die 'too many symbolic links in path' + link=$(readlink "$candidate") || die "cannot read symbolic link: $candidate" + case $link in /*) resolved=/; link=${link#/} ;; esac + pending=$link${pending:+/$pending} + else + resolved=$candidate + fi ;; + esac + done + printf '%s\n' "$resolved" +) + +overlaps() { + local cursor + if [[ $1 == / || $2 == / || $1 == "$2" || $1 == "$2/"* || $2 == "$1/"* ]]; then + return 0 + fi + # On case-insensitive filesystems, different spellings can name the same + # existing object. Check identity along ancestors of a missing suffix too. + if [[ -e $2 ]]; then + cursor=$1 + while :; do + if [[ $cursor -ef $2 ]]; then return 0; fi + [[ $cursor != / ]] || break + cursor=${cursor%/*} + [[ -n $cursor ]] || cursor=/ + done + fi + if [[ -e $1 ]]; then + cursor=$2 + while :; do + if [[ $cursor -ef $1 ]]; then return 0; fi + [[ $cursor != / ]] || break + cursor=${cursor%/*} + [[ -n $cursor ]] || cursor=/ + done + fi + return 1 +} + +remote=origin +branch=planning +path=.worktrees/planning +while [[ $# -gt 0 ]]; do + case $1 in + --remote|--branch|--path) + [[ $# -ge 2 ]] || die "missing value for $1" + case $1 in --remote) remote=$2 ;; --branch) branch=$2 ;; --path) path=$2 ;; esac + shift 2 ;; + --remote=*) remote=${1#*=}; shift ;; + --branch=*) branch=${1#*=}; shift ;; + --path=*) path=${1#*=}; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n $branch && $branch != -* ]] || die 'branch must be a non-empty Git branch name' +[[ -n $remote && $remote != -* ]] || die 'remote must be a non-empty name' +[[ -n $path ]] || die 'worktree path must not be empty' +root=$(git rev-parse --show-toplevel) +root=$(canonical_path "$root") +g() { git -C "$root" "$@"; } +g check-ref-format --branch "$branch" >/dev/null +g remote get-url "$remote" >/dev/null || die "remote does not exist: $remote" +case $path in \~) path=$HOME ;; \~/*) path=$HOME/${path#\~/} ;; esac +[[ $path == /* ]] || path=$root/$path +# Ignore trailing separators and dot components when checking the leaf. +# Bash -L otherwise follows alias/ whereas Path-style inputs normalize it. +while [[ $path != / ]]; do + case $path in + */) path=${path%/} ;; + */.) path=${path%/.} ;; + *) break ;; + esac +done +[[ -n $path ]] || path=/ +[[ ! -L $path ]] || die "target path is a symlink: $path" +target=$(canonical_path "$path") +[[ $target != "$root" ]] || die 'planning worktree path may not be the implementation checkout' + +common_dir() { + local directory + directory=$(git -C "$1" rev-parse --git-common-dir) || die "cannot inspect Git directory: $1" + [[ $directory == /* ]] || directory=$1/$directory + canonical_path "$directory" +} +common=$(common_dir "$root") +for administration in "$root/.git" "$common"; do + administration=$(canonical_path "$administration") + if overlaps "$target" "$administration"; then + die 'planning worktree path overlaps Git administration data' + fi +done + +records="" +tracked="" +trap 'rm -f -- "$records" "$tracked"' EXIT +records=$(mktemp "${TMPDIR:-/tmp}/planning-worktrees.XXXXXX") +tracked=$(mktemp "${TMPDIR:-/tmp}/planning-tracked.XXXXXX") +g ls-files -z > "$tracked" +while IFS= read -r -d '' name; do + resolved=$(canonical_path "$root/$name") + if overlaps "$target" "$resolved"; then + die 'planning worktree path overlaps tracked implementation content' + fi +done < "$tracked" + +# Git's NUL-delimited porcelain keeps spaces and embedded newlines intact. +load_worktrees() { + local field + wt_paths=(); wt_branches=(); wt_locked=(); wt_prunable=(); wt_count=0 + g worktree list --porcelain -z > "$records" + while IFS= read -r -d '' field; do + case $field in + 'worktree '*) + wt_paths[wt_count]=$(canonical_path "${field#worktree }") + wt_branches[wt_count]="" + wt_locked[wt_count]=0 + wt_prunable[wt_count]=0 + wt_count=$((wt_count + 1)) ;; + 'branch '*) wt_branches[wt_count - 1]=${field#branch } ;; + locked*) wt_locked[wt_count - 1]=1 ;; + prunable*) wt_prunable[wt_count - 1]=1 ;; + esac + done < "$records" + [[ $wt_count -gt 0 ]] || die 'Git returned no registered worktrees' +} +load_worktrees +existing=0 +target_index=-1 +for ((i=0; i\nCo-authored-by: Billo AI ' + commit=$(git -C "$target" rev-parse HEAD) + parents=$(git -C "$target" rev-list --parents -n 1 "$commit") + tree=$(git -C "$target" rev-parse "$commit^{tree}") + empty_tree=$(printf '' | g hash-object -t tree --stdin) + ensure_registered + [[ $parents == "$commit" && $tree == "$empty_tree" ]] || die 'new planning root is not a zero-parent, empty-tree commit; preserving worktree for inspection' + g push -u "$remote" "refs/heads/$branch:refs/heads/$branch" + printf 'created empty planning root and pushed %s/%s at %s\n' "$remote" "$branch" "$target" +fi diff --git a/tests/test-planning-worktree.sh b/tests/test-planning-worktree.sh new file mode 100755 index 00000000..35c1c01c --- /dev/null +++ b/tests/test-planning-worktree.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Adapted from oxur/vlajmaji tests/test-build-tools.sh. +# Real-Git planning fixtures use disposable local bare remotes. +# No network, Python, Bats, or Rust compiler is needed. Bash 3.2 compatible. +set -euo pipefail +repo=$(cd -- "$(dirname -- "$0")/.." && pwd -P) +make_command=${MAKE:-make} +run_root=$(mktemp -d "${TMPDIR:-/tmp}/build-tools.XXXXXX") +# Isolate fixtures from user Git configuration, hooks, signing and environment. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES +unset GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS GIT_CONFIG GIT_NAMESPACE +unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL +unset MAKEFLAGS MFLAGS MAKEOVERRIDES +export LC_ALL=C + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +equal() { [[ $1 == "$2" ]] || fail "expected [$2], got [$1]"; } +contains() { grep -F -- "$2" "$1" >/dev/null || fail "$1 does not contain: $2"; } +excludes() { if grep -F -- "$2" "$1" >/dev/null; then fail "$1 unexpectedly contains: $2"; fi; } +# A failed command is expected only here; assertions themselves always exit. +reject() { + local code + if "$@" > "$case_root/rejected.log" 2>&1; then fail "unexpected success: $*"; else code=$?; fi + [[ $code -ne 0 ]] || fail 'failure was not propagated' +} +g() { git -C "$source" "$@"; } +w() { git -C "$work" "$@"; } +plan() { "$make_command" -C "$source" --no-print-directory "BASH=$BASH" planning; } +identity() { + git -C "$1" config user.name 'Fixture User' + git -C "$1" config user.email fixture@example.invalid + git -C "$1" config commit.gpgsign false +} +fixture() { + local base=${1:-$case_root} + mkdir -p "$base" + source=$base/'source checkout with spaces' + origin=$base/origin.git + work=$source/.worktrees/planning + mkdir -p "$source" + git init -b main "$source" + identity "$source" + printf 'source base\n' > "$source/tracked.txt" + g add tracked.txt + g commit -m 'source base' + git init --bare "$origin" + git --git-dir="$origin" symbolic-ref HEAD refs/heads/main + g remote add origin "$origin" + g push -u origin main + cp "$repo/Makefile" "$repo/.gitignore" "$source/" + mkdir "$source/scripts" + cp "$repo/scripts/planning-worktree.sh" "$source/scripts/" + g add Makefile .gitignore scripts/planning-worktree.sh + g commit -m 'fixture build tooling' + cp "$source/.git/index" "$base/index-before" + head_before=$(g rev-parse HEAD) + index_before=$base/index-before +} +unchanged() { + equal "$(g rev-parse HEAD)" "$head_before" + # Compare before status, which may refresh the index's stat cache. + cmp "$source/.git/index" "$index_before" || fail 'implementation index changed' + equal "$(g status --porcelain)" '' +} +remote_head() { git --git-dir="$origin" rev-parse refs/heads/planning; } +empty_root() { + local commit + commit=$(w rev-parse HEAD) + equal "$(w rev-list --parents -n 1 "$commit")" "$commit" + equal "$(w ls-tree -r --name-only HEAD)" '' +} +clone_planning() { + clone=$case_root/'remote update' + git clone "$origin" "$clone" + identity "$clone" + git -C "$clone" switch planning +} +remote_update() { + clone_planning + printf 'remote advanced\n' > "$clone/later.txt" + git -C "$clone" add later.txt + git -C "$clone" commit -m 'remote update' + git -C "$clone" push origin planning +} +local_update() { + printf 'local\n' > "$work/local.txt" + w add local.txt + w commit -m 'local ahead' +} + +help_and_planning_alias_do_not_require_rust() { + local fake=$case_root/no-rust + mkdir "$fake" + printf '#!/bin/sh\nexit 127\n' > "$fake/rustc" + cp "$fake/rustc" "$fake/cargo" + chmod +x "$fake/rustc" "$fake/cargo" + export PATH=$fake:$PATH + "$make_command" -C "$repo" --no-print-directory help > "$case_root/help.log" + contains "$case_root/help.log" 'make planning' + contains "$case_root/help.log" 'make planning-worktree' + contains "$case_root/help.log" 'make test-tools' + fixture + "$make_command" -C "$source" --no-print-directory "BASH=$BASH" planning-worktree + empty_root + unchanged +} + +create_empty_root_push_idempotence_and_preserve_source() { + fixture + plan > "$case_root/first.log" + local commit + commit=$(w rev-parse HEAD) + empty_root + equal "$(remote_head)" "$commit" + contains "$case_root/first.log" 'created empty planning root' + plan + equal "$(w rev-parse HEAD)" "$commit" + unchanged +} +remote_branch_without_local_branch_attaches() { + fixture + clone=$case_root/seed + git clone "$origin" "$clone" + identity "$clone" + git -C "$clone" switch --orphan planning + git -C "$clone" commit --allow-empty -m 'remote plan' + git -C "$clone" push origin planning + plan + equal "$(w branch --show-current)" planning + equal "$(w rev-parse HEAD)" "$(remote_head)" + unchanged +} +remote_update_fast_forwards_clean_planning_worktree() { + fixture; plan; remote_update; plan + equal "$(cat "$work/later.txt")" 'remote advanced' + equal "$(w rev-parse HEAD)" "$(remote_head)" + unchanged +} +local_ahead_preserved_without_push() { + fixture; plan + local remote before + remote=$(remote_head) + local_update + before=$(w rev-parse HEAD) + plan + equal "$(w rev-parse HEAD)" "$before" + equal "$(remote_head)" "$remote" + unchanged +} +existing_local_branch_without_remote_is_attached_and_pushed() { + fixture + g branch planning HEAD + local expected + expected=$(g rev-parse refs/heads/planning) + plan + equal "$(w rev-parse HEAD)" "$expected" + equal "$(remote_head)" "$expected" + unchanged +} +dirty_planning_refused_without_data_loss() { + fixture; plan + printf 'preserve me' > "$work/untracked" + reject plan + contains "$case_root/rejected.log" 'is dirty' + equal "$(cat "$work/untracked")" 'preserve me' + unchanged +} +true_divergence_refused() { + fixture; plan; remote_update; local_update + local before remote + before=$(w rev-parse HEAD) + remote=$(remote_head) + reject plan + contains "$case_root/rejected.log" diverged + equal "$(w rev-parse HEAD)" "$before" + equal "$(remote_head)" "$remote" + unchanged +} +path_collision_and_wrong_branch_refused() { + fixture "$case_root/collision" + mkdir -p "$work" + printf keep > "$work/keep" + reject plan + equal "$(cat "$work/keep")" keep + unchanged + fixture "$case_root/wrong-branch" + g worktree add -b other "$work" HEAD + reject plan + equal "$(w branch --show-current)" other + [[ -f $work/tracked.txt ]] || fail 'wrong-branch worktree damaged' + unchanged +} +unreachable_origin_is_not_treated_as_missing_branch() { + fixture + g remote set-url origin "$case_root/absent.git" + reject plan + contains "$case_root/rejected.log" 'could not query remote branch' + [[ ! -e $work ]] || fail 'created worktree after remote error' + reject g show-ref --verify --quiet refs/heads/planning + unchanged +} +implementation_dirty_state_is_preserved() { + fixture + printf 'staged change\n' > "$source/tracked.txt" + g add tracked.txt + cp "$source/.git/index" "$case_root/staged-index" + printf 'staged plus unstaged\n' > "$source/tracked.txt" + printf 'preserve\n' > "$source/untracked.txt" + plan + equal "$(g rev-parse HEAD)" "$head_before" + cmp "$source/.git/index" "$case_root/staged-index" + equal "$(cat "$source/tracked.txt")" 'staged plus unstaged' + equal "$(cat "$source/untracked.txt")" preserve +} +branch_checked_out_elsewhere_is_refused() { + fixture + g branch planning HEAD + g worktree add "$case_root/already attached" planning + reject plan + contains "$case_root/rejected.log" 'already checked out elsewhere' + [[ -f $case_root/'already attached'/tracked.txt && ! -e $work ]] || fail 'unexpected worktree change' + unchanged +} +push_rejection_preserves_empty_local_root_and_worktree() { + fixture + printf '#!/bin/sh\necho fixture push rejection >&2\nexit 1\n' > "$origin/hooks/pre-receive" + chmod +x "$origin/hooks/pre-receive" + reject plan + contains "$case_root/rejected.log" 'fixture push rejection' + empty_root + reject git --git-dir="$origin" show-ref --verify --quiet refs/heads/planning + unchanged +} +symlink_path_is_refused() { + fixture + mkdir -p "$case_root/outside" "$source/.worktrees" + ln -s "$case_root/outside" "$work" + reject plan + contains "$case_root/rejected.log" symlink + [[ ! -e $case_root/outside/.git ]] || fail 'symlink destination was changed' + rm "$work" + plan + local commit suffix + commit=$(w rev-parse HEAD) + ln -s .worktrees/planning "$source/planning-alias" + cd "$source" + for suffix in / /.; do + reject "$BASH" scripts/planning-worktree.sh --path "planning-alias$suffix" + contains "$case_root/rejected.log" symlink + equal "$(w rev-parse HEAD)" "$commit" + done + rm "$source/planning-alias" + unchanged +} +local_branch_behind_remote_without_worktree_fast_forwards_once() { + fixture; plan + local old + old=$(w rev-parse HEAD) + g worktree remove "$work" + remote_update + equal "$(g rev-parse refs/heads/planning)" "$old" + plan + equal "$(cat "$work/later.txt")" 'remote advanced' + equal "$(w rev-parse HEAD)" "$(remote_head)" + unchanged +} +helper_can_be_invoked_from_repository_subdirectory() { + fixture + mkdir "$source/nested" + (cd "$source/nested" && "$BASH" ../scripts/planning-worktree.sh --path .worktrees/planning) > "$case_root/direct.log" + contains "$case_root/direct.log" 'created empty planning root' + equal "$(w branch --show-current)" planning + unchanged +} +git_administration_path_is_refused() { + fixture + cd "$source" + reject "$BASH" scripts/planning-worktree.sh --path .git/planning + contains "$case_root/rejected.log" 'Git administration' + [[ ! -e $source/.git/planning ]] || fail 'Git administration data was changed' + ln -s .git "$source/admin-alias" + g worktree list --porcelain -z > "$case_root/worktrees-before" + reject "$BASH" scripts/planning-worktree.sh --path missing/../admin-alias/review-planning + contains "$case_root/rejected.log" 'Git administration' + if [[ -d $source/.GIT ]]; then + reject "$BASH" scripts/planning-worktree.sh --path .GIT/casefold-planning + contains "$case_root/rejected.log" 'Git administration' + [[ ! -e $source/.git/casefold-planning ]] || fail 'case alias bypassed Git directory guard' + fi + [[ ! -e $source/.git/review-planning ]] || fail 'created a worktree in Git administration data' + g worktree list --porcelain -z > "$case_root/worktrees-after" + cmp "$case_root/worktrees-before" "$case_root/worktrees-after" + reject g show-ref --verify --quiet refs/heads/planning + reject git --git-dir="$origin" show-ref --verify --quiet refs/heads/planning + rm "$source/admin-alias" + unchanged +} + +fresh_clone_attaches_remote_planning_without_identity() { + fixture; plan + g push origin main + local fresh=$case_root/'fresh clone with spaces' + git clone "$origin" "$fresh" + "$make_command" -C "$fresh" --no-print-directory "BASH=$BASH" planning + equal "$(git -C "$fresh/.worktrees/planning" rev-parse HEAD)" "$(remote_head)" + equal "$(git -C "$fresh" status --porcelain)" '' + unchanged +} +locked_planning_is_preserved() { + fixture; plan; remote_update + local before + before=$(w rev-parse HEAD) + g worktree lock "$work" --reason 'fixture protected worktree' + reject plan + contains "$case_root/rejected.log" 'locked or prunable' + equal "$(w rev-parse HEAD)" "$before" + g worktree unlock "$work" + unchanged +} +missing_registered_worktree_is_not_pruned_or_replaced() { + fixture; plan + mv "$work" "$case_root/retained planning" + g worktree list --porcelain -z > "$case_root/registrations-before" + reject plan + contains "$case_root/rejected.log" 'locked or prunable' + g worktree list --porcelain -z > "$case_root/registrations-after" + cmp "$case_root/registrations-before" "$case_root/registrations-after" + [[ ! -e $work ]] || fail 'missing worktree unexpectedly recreated' + mv "$case_root/retained planning" "$work" + unchanged +} +tracked_paths_and_implementation_root_are_refused() { + fixture + cd "$source" + reject "$BASH" scripts/planning-worktree.sh --path . + contains "$case_root/rejected.log" 'implementation checkout' + reject "$BASH" scripts/planning-worktree.sh --path tracked.txt/child + contains "$case_root/rejected.log" 'tracked implementation content' + equal "$(cat "$source/tracked.txt")" 'source base' + [[ ! -e $work ]] || fail 'created planning despite invalid target' + unchanged +} +failed_make_target_never_reports_ready() { + fixture + mkdir -p "$work" + printf keep > "$work/keep" + reject plan + excludes "$case_root/rejected.log" 'Planning worktree ready' + equal "$(cat "$work/keep")" keep + unchanged +} +retry_after_rejected_initial_push_reuses_root() { + fixture + printf '#!/bin/sh\nexit 1\n' > "$origin/hooks/pre-receive" + chmod +x "$origin/hooks/pre-receive" + reject plan + local before + before=$(w rev-parse HEAD) + empty_root + rm "$origin/hooks/pre-receive" + plan + equal "$(w rev-parse HEAD)" "$before" + equal "$(remote_head)" "$before" + contains "$case_root/rejected.log" 'rejected' + unchanged +} + +cases=( + fresh_clone_attaches_remote_planning_without_identity + locked_planning_is_preserved + missing_registered_worktree_is_not_pruned_or_replaced + tracked_paths_and_implementation_root_are_refused + failed_make_target_never_reports_ready + retry_after_rejected_initial_push_reuses_root + help_and_planning_alias_do_not_require_rust + create_empty_root_push_idempotence_and_preserve_source + remote_branch_without_local_branch_attaches + remote_update_fast_forwards_clean_planning_worktree + local_ahead_preserved_without_push + existing_local_branch_without_remote_is_attached_and_pushed + dirty_planning_refused_without_data_loss + true_divergence_refused + path_collision_and_wrong_branch_refused + unreachable_origin_is_not_treated_as_missing_branch + implementation_dirty_state_is_preserved + branch_checked_out_elsewhere_is_refused + push_rejection_preserves_empty_local_root_and_worktree + symlink_path_is_refused + local_branch_behind_remote_without_worktree_fast_forwards_once + helper_can_be_invoked_from_repository_subdirectory + git_administration_path_is_refused +) +failed=0 +passed=0 +for test_case in "${cases[@]}"; do + case_root=$run_root/$test_case + mkdir "$case_root" + # Do not put the function in an if/|| condition: that disables errexit in + # the whole function. Only the parent disables it to collect the exit code. + set +e + (set -euo pipefail; cd "$case_root"; "$test_case") > "$case_root/output.log" 2>&1 + status=$? + set -e + if [[ $status -eq 0 ]]; then + printf 'ok - %s\n' "$test_case" + passed=$((passed + 1)) + else + printf 'not ok - %s (exit %s)\n' "$test_case" "$status" >&2 + cat "$case_root/output.log" >&2 + failed=$((failed + 1)) + fi +done +printf '%s passed; %s failed (%s total)\n' "$passed" "$failed" "${#cases[@]}" +if [[ $failed -gt 0 ]]; then + printf 'Fixtures and logs retained at %s\n' "$run_root" >&2 + exit 1 +fi +rm -rf -- "$run_root"