diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index 226611a..16fda8a 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -205,18 +205,29 @@ git worktree add -b fix/issue-123-example branches/fix-issue-123-example prgs/ma cd branches/fix-issue-123-example ``` -For review work, create a separate review worktree/folder instead of reusing the -author's implementation folder. - -Cleanup is explicit and only after merge or close: +For review work, create a separate **detached** review worktree instead of +reusing the author's implementation folder: ```bash +scripts/worktree-review fix/issue-123-example # → branches/review-fix-issue-123-example +``` + +Cleanup is explicit and only after merge or close. Use the helper (it fetches/ +prunes first, refuses to remove a dirty worktree, and only safe-deletes a merged +branch), or the equivalent manual commands: + +```bash +scripts/worktree-clean --delete-branch fix/issue-123-example +# equivalent manual commands: cd git fetch prgs --prune git worktree remove branches/fix-issue-123-example git branch -d fix/issue-123-example ``` +All three helpers accept `--dry-run` to print the exact commands/paths without +touching anything. + ### Create an issue / child issues - **Profile:** issue-manager or author (any profile allowed to create issues). diff --git a/scripts/worktree-clean b/scripts/worktree-clean new file mode 100755 index 0000000..2bccf1c --- /dev/null +++ b/scripts/worktree-clean @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/worktree-clean [--dry-run] [--delete-branch] + +Remove a branch worktree under branches/ AFTER its PR is merged or closed. This +is the ONLY helper that deletes anything, and it deletes nothing unless you +invoke it explicitly. It refuses to remove a worktree with uncommitted changes +(no --force is offered). With --delete-branch it also deletes the local branch, +but only with a safe `git branch -d` (fails unless the branch is merged). + +Pass the branch name (with slashes) so --delete-branch can resolve it; the +folder is branches/. + +Examples: + scripts/worktree-clean --dry-run fix/issue-123-example + scripts/worktree-clean --delete-branch fix/issue-123-example +EOF +} + +dry_run=0 +del_branch=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --delete-branch) del_branch=1 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 2 +fi + +branch="$1" +worktree_name="${branch//\//-}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +worktree_path="$repo_root/branches/$worktree_name" + +if [[ ! -d "$worktree_path" ]]; then + printf 'No such worktree: %s\n' "$worktree_path" >&2 + exit 1 +fi + +if [[ "$dry_run" -eq 1 ]]; then + printf 'repo: %s\n' "$repo_root" + printf 'worktree: %s\n' "$worktree_path" + printf 'delete-branch: %s\n' "$del_branch" + printf 'commands:\n' + printf ' git -C %q fetch prgs --prune\n' "$repo_root" + printf ' git -C %q worktree remove %q\n' "$repo_root" "$worktree_path" + if [[ "$del_branch" -eq 1 ]]; then + printf ' git -C %q branch -d %q\n' "$repo_root" "$branch" + fi + exit 0 +fi + +git -C "$repo_root" fetch prgs --prune +# No --force: `git worktree remove` fails on uncommitted changes, on purpose. +git -C "$repo_root" worktree remove "$worktree_path" +printf 'removed worktree: %s\n' "$worktree_path" + +if [[ "$del_branch" -eq 1 ]]; then + # Safe delete only: refuses to drop an unmerged branch. + git -C "$repo_root" branch -d "$branch" + printf 'deleted branch: %s\n' "$branch" +fi diff --git a/scripts/worktree-review b/scripts/worktree-review new file mode 100755 index 0000000..68618c6 --- /dev/null +++ b/scripts/worktree-review @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/worktree-review [--dry-run] [start-ref] + +Create an isolated, DETACHED review worktree under branches/review-. +Reviews an existing branch in its own folder without creating a local branch, +so review work never blocks or contaminates the author's implementation folder +and a reviewer cannot accidentally commit. Default start-ref is prgs/. + +Examples: + scripts/worktree-review fix/issue-123-example + scripts/worktree-review --dry-run fix/issue-123-example prgs/fix/issue-123-example +EOF +} + +dry_run=0 +if [[ "${1:-}" == "--dry-run" ]]; then + dry_run=1 + shift +fi + +if [[ $# -lt 1 || $# -gt 2 ]]; then + usage >&2 + exit 2 +fi + +branch="$1" +start_ref="${2:-prgs/$branch}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +worktree_name="review-${branch//\//-}" +worktree_path="$repo_root/branches/$worktree_name" + +if [[ -e "$worktree_path" ]]; then + cat >&2 < + rc, _, err = run("worktree-review", "--dry-run", + f"zz-refuse-rev-{os.getpid()}") + self.assertEqual(rc, 1) + self.assertIn("Refusing to reuse", err) + finally: + target.rmdir() + + +class TestWorktreeClean(unittest.TestCase): + + def test_missing_worktree_errors(self): + rc, _, err = run("worktree-clean", "--dry-run", "does-not-exist-xyz") + self.assertEqual(rc, 1) + self.assertIn("No such worktree", err) + + def test_dry_run_does_not_delete(self): + slug = f"zz-clean-{os.getpid()}" + target = BRANCHES / slug + target.mkdir(parents=True, exist_ok=True) + try: + rc, out, _ = run("worktree-clean", "--dry-run", slug) + self.assertEqual(rc, 0) + self.assertIn("worktree remove", out) + self.assertTrue(target.is_dir()) # nothing removed in dry-run + finally: + target.rmdir() + + def test_dry_run_delete_branch_lists_branch_command(self): + slug = f"zz-clean-b-{os.getpid()}" + target = BRANCHES / slug + target.mkdir(parents=True, exist_ok=True) + try: + rc, out, _ = run("worktree-clean", "--dry-run", "--delete-branch", slug) + self.assertEqual(rc, 0) + self.assertIn("branch -d", out) + finally: + target.rmdir() + + +if __name__ == "__main__": + unittest.main()