diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4fd2bf..9eacb5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ numeric next development line is tracked in `DEVELOPMENT_VERSION`. automation can distinguish trust-blocked and not-run projects from tests that actually executed. +### Added + +- Stability compatibility: enroll the new `basectl gh review fetch` and + `basectl gh review prune` command contracts, including the migration path + from ordinary local `pr-*` branches to namespaced detached review refs. + ## [1.10.5] - 2026-10-14 ### Added diff --git a/cli/bash/commands/basectl/README.md b/cli/bash/commands/basectl/README.md index 81e532ae..87cdc97a 100644 --- a/cli/bash/commands/basectl/README.md +++ b/cli/bash/commands/basectl/README.md @@ -131,6 +131,10 @@ such command directories exist. Optional utility CLIs such as `caff` and is set, it takes precedence and must be unset or rotated at its source. Prefer this command for Base repository GitHub workflows when it supports the task. + `basectl gh review fetch --name ` creates a detached, + namespaced review checkout without a local `pr-*` branch, and + `basectl gh review prune` previews or removes only verified safe review + checkouts. Legacy local `pr-*` branches remain a separate one-time cleanup. - `basectl onboard` guides first-run setup around existing setup, check, doctor, profile, and project-discovery primitives. See `docs/basectl-onboard.md`. diff --git a/cli/bash/commands/basectl/subcommands/gh.sh b/cli/bash/commands/basectl/subcommands/gh.sh index 2b0c8a78..acca303c 100644 --- a/cli/bash/commands/basectl/subcommands/gh.sh +++ b/cli/bash/commands/basectl/subcommands/gh.sh @@ -13,6 +13,8 @@ source "$BASE_HOME/cli/bash/commands/basectl/subcommands/github_policy.sh" # shellcheck source=cli/bash/commands/basectl/subcommands/inspection_json.sh source "$BASE_HOME/cli/bash/commands/basectl/subcommands/inspection_json.sh" source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh_branch_worktree.sh" +# shellcheck source=cli/bash/commands/basectl/subcommands/gh_review.sh +source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh_review.sh" base_gh_load_module() { local function_name="$1" @@ -66,6 +68,8 @@ Usage: basectl gh branch stale [--days ] [--format ] basectl gh branch prune [--dry-run] [--yes] [--remote] [--closed-unmerged] basectl gh worktree prune [--dry-run] [--yes] [--closed-unmerged] + basectl gh review fetch --name [--repo ] [--root ] [--refresh] + basectl gh review prune [--dry-run] [--yes] [--closed-unmerged] [--pr ] [--root ] Purpose: Manage GitHub issues, pull requests, Project metadata, branch naming, and @@ -105,7 +109,8 @@ Notes: body also follows that project policy. Pass --no-fixes to suppress body injection. - Pull request implementation work should happen in a dedicated worktree. - - Branch and worktree pruning are dry-run by default and apply only when --yes is passed. + - Review worktrees are detached and use refs/base-review/pr//. + - Branch, worktree, and review pruning are dry-run by default and apply only when --yes is passed. EOF } @@ -1033,6 +1038,7 @@ base_gh_subcommand_main() { project) base_gh_do_project "$@" ;; branch) base_gh_do_branch "$@" ;; worktree) base_gh_do_worktree "$@" ;; + review) base_gh_review_main "$@" ;; -h|--help|help|"") base_gh_usage ;; *) base_gh_usage_error base_gh_usage "Unknown gh area '$area'." diff --git a/cli/bash/commands/basectl/subcommands/gh_review.sh b/cli/bash/commands/basectl/subcommands/gh_review.sh new file mode 100644 index 00000000..1876faac --- /dev/null +++ b/cli/bash/commands/basectl/subcommands/gh_review.sh @@ -0,0 +1,706 @@ +#!/usr/bin/env bash + +[[ -n "${_base_gh_review_sourced:-}" ]] && return 0 +_base_gh_review_sourced=1 +readonly _base_gh_review_sourced + +BASE_GH_REVIEW_REF_PREFIX="refs/base-review/pr" +BASE_GH_REVIEW_ID_PATTERN='^pr-[1-9][0-9]*-[a-z0-9]+(-[a-z0-9]+)*$' +BASE_GH_REVIEW_SLUG_PATTERN='^[a-z0-9]+(-[a-z0-9]+)*$' +readonly BASE_GH_REVIEW_REF_PREFIX BASE_GH_REVIEW_ID_PATTERN BASE_GH_REVIEW_SLUG_PATTERN + +base_gh_review_usage() { + cat <<'EOF' +Usage: + basectl gh review fetch --name [--repo ] [--root ] [--refresh] + basectl gh review prune [--dry-run] [--yes] [--closed-unmerged] [--pr ] [--root ] + +Purpose: + Create detached PR-review worktrees under a namespaced local ref and prune + only Base-owned review worktrees that are safe to remove. + +Notes: + - Review worktrees never create ordinary refs/heads/pr-* branches. + - Fetch creates refs/base-review/pr// and a worktree named + pr-- under -review-worktrees by default. + - Prune is dry-run by default and never removes dirty or open-PR worktrees. + - Legacy local pr-* branches are a separate one-time cleanup and are not + managed by this command. +EOF +} + +base_gh_review_leaf_usage() { + local command="$1" + + case "$command" in + fetch) + cat <<'EOF' +Usage: + basectl gh review fetch --name [options] + +Purpose: + Fetch a pull-request head into a namespaced local review ref and create a + detached review worktree. + +Options: + --name Required lower-case kebab-case review name. + --repo Pull request repository. Defaults to origin and must + match the checkout's origin repository. + --root Review worktree root. Defaults to ../-review-worktrees. + --refresh Replace an existing clean review worktree with the current PR head. + -h, --help Show this help text. +EOF + ;; + prune) + cat <<'EOF' +Usage: + basectl gh review prune [options] + +Purpose: + Prune Base-owned detached review worktrees and their namespaced local refs. + +Options: + --dry-run Preview removals (default). + --yes Apply the preview. + --closed-unmerged Include clean worktrees for closed, unmerged PRs. + --pr Restrict the scan to one pull request. + --root Review worktree root. Defaults to ../-review-worktrees. + -h, --help Show this help text. +EOF + ;; + *) + return 1 + ;; + esac +} + +base_gh_review_usage_error() { + local command="$1" + local message="$2" + + base_gh_error "$message" + base_gh_review_leaf_usage "$command" >&2 + return 2 +} + +base_gh_review_validate_number() { + local number="$1" + + [[ "$number" =~ ^[1-9][0-9]*$ ]] +} + +base_gh_review_validate_slug() { + local slug="$1" + + [[ "$slug" =~ $BASE_GH_REVIEW_SLUG_PATTERN ]] +} + +base_gh_review_repo_root() { + git rev-parse --show-toplevel 2>/dev/null +} + +base_gh_review_common_dir() { + local repo_root common_dir + + repo_root="$(base_gh_review_repo_root)" || return 1 + common_dir="$(git rev-parse --git-common-dir 2>/dev/null)" || return 1 + if [[ "$common_dir" != /* ]]; then + common_dir="$repo_root/$common_dir" + fi + (cd "$common_dir" && pwd -P) +} + +base_gh_review_metadata_dir() { + local common_dir + + common_dir="$(base_gh_review_common_dir)" || return 1 + printf '%s/base-review/worktrees\n' "$common_dir" +} + +base_gh_review_default_root() { + local common_dir repo_root repo_name repo_parent + + common_dir="$(base_gh_review_common_dir)" || return 1 + repo_root="$(dirname "$common_dir")" + repo_name="$(basename "$repo_root")" + repo_parent="$(dirname "$repo_root")" + printf '%s/%s-review-worktrees\n' "$repo_parent" "$repo_name" +} + +base_gh_review_resolve_root() { + local requested="${1:-}" + local root + + if [[ -n "$requested" ]]; then + root="$requested" + else + root="$(base_gh_review_default_root)" || return 1 + fi + mkdir -p "$root" || return 1 + (cd "$root" && pwd -P) +} + +base_gh_review_metadata_path() { + local id="$1" + local metadata_dir + + metadata_dir="$(base_gh_review_metadata_dir)" || return 1 + printf '%s/%s.json\n' "$metadata_dir" "$id" +} + +base_gh_review_now() { + printf '%(%Y-%m-%dT%H:%M:%SZ)T\n' -1 +} + +base_gh_review_ref() { + local number="$1" + local slug="$2" + + printf '%s/%s/%s\n' "$BASE_GH_REVIEW_REF_PREFIX" "$number" "$slug" +} + +base_gh_review_id() { + local number="$1" + local slug="$2" + + printf 'pr-%s-%s\n' "$number" "$slug" +} + +base_gh_review_path() { + local root="$1" + local number="$2" + local slug="$3" + + printf '%s/%s\n' "$root" "$(base_gh_review_id "$number" "$slug")" +} + +base_gh_review_ref_exists() { + local ref="$1" + + git show-ref --verify --quiet "$ref" +} + +base_gh_review_write_metadata() { + local metadata_path="$1" + local repo="$2" + local number="$3" + local slug="$4" + local review_ref="$5" + local worktree_path="$6" + local head_sha="$7" + local created_at="$8" + local metadata_dir temporary_path + + metadata_dir="$(dirname "$metadata_path")" + mkdir -p "$metadata_dir" || return 1 + temporary_path="$metadata_dir/.${number}-${slug}.json.tmp.$$" + jq -n \ + --arg kind base-review-worktree \ + --arg repo "$repo" \ + --arg number "$number" \ + --arg slug "$slug" \ + --arg review_ref "$review_ref" \ + --arg worktree_path "$worktree_path" \ + --arg head_sha "$head_sha" \ + --arg created_at "$created_at" \ + '{kind:$kind,schema:1,repository:$repo,pull_request:($number|tonumber),slug:$slug,review_ref:$review_ref,worktree_path:$worktree_path,head_sha:$head_sha,created_at:$created_at}' \ + > "$temporary_path" || { + rm -f "$temporary_path" + return 1 + } + mv -f "$temporary_path" "$metadata_path" +} + +base_gh_review_remove_existing() { + local worktree_path="$1" + local review_ref="$2" + local metadata_path="$3" + + if [[ -e "$worktree_path" ]]; then + base_gh_worktree_is_valid "$worktree_path" || { + base_gh_error "Cannot refresh review worktree '$worktree_path': path is not a valid Git worktree." + return 1 + } + base_gh_worktree_dirty "$worktree_path" && { + base_gh_error "Cannot refresh review worktree '$worktree_path': worktree is dirty." + return 1 + } + git worktree remove "$worktree_path" || { + base_gh_error "Unable to remove existing review worktree '$worktree_path'." + return 1 + } + fi + if base_gh_review_ref_exists "$review_ref"; then + git update-ref -d "$review_ref" || { + base_gh_error "Unable to remove existing review ref '$review_ref'." + return 1 + } + fi + rm -f "$metadata_path" +} + +base_gh_review_fetch() { + local number="${1:-}" + local repo="" slug="" root="" review_root="" review_id review_ref worktree_path metadata_path + local pr_json head_sha base_repo state created_at actual_sha origin_repo + local refresh=0 review_ref_exists=0 + local -a parser_args=() positionals=() + # shellcheck disable=SC2034 # base_arg_parse receives caller-owned arrays by name. + local -a option_specs=( + "name|value|--name" + "repo|value|--repo" + "root|value|--root" + "refresh|flag|--refresh" + ) + local -A parsed_options=() + + if [[ "$number" == -h || "$number" == --help ]]; then + base_gh_review_leaf_usage fetch + return 0 + fi + [[ -n "$number" ]] || { + base_gh_review_usage_error fetch "Missing pull request number." + return $? + } + shift + while (($#)); do + case "$1" in + --name|--repo|--root) + (($# > 1)) || { + base_gh_review_usage_error fetch "Option '$1' requires an argument." + return $? + } + parser_args+=("$1" "$2") + shift 2 + ;; + --refresh) + parser_args+=("$1") + shift + ;; + -h|--help) + base_gh_review_leaf_usage fetch + return 0 + ;; + *) + positionals+=("$1") + shift + ;; + esac + done + if ! base_arg_parse parsed_options positionals option_specs -- "${parser_args[@]}"; then + base_gh_review_usage_error fetch "Could not parse review fetch arguments." + return $? + fi + if ((${#positionals[@]} > 0)); then + base_gh_review_usage_error fetch "Unknown option '${positionals[0]}'." + return $? + fi + base_gh_review_validate_number "$number" || { + base_gh_review_usage_error fetch "Pull request number must be a positive integer." + return $? + } + slug="${parsed_options[name]:-}" + [[ -n "$slug" ]] || { + base_gh_review_usage_error fetch "Missing required --name." + return $? + } + base_gh_review_validate_slug "$slug" || { + base_gh_review_usage_error fetch "Review name must be lower-case kebab-case." + return $? + } + repo="${parsed_options[repo]:-}" + if [[ -n "$repo" ]]; then + repo="$(base_gh_normalize_github_repo "$repo")" || { + base_gh_error "Invalid GitHub repository '$repo'." + return 2 + } + else + repo="$(base_gh_infer_github_repo)" || { + base_gh_error "Unable to infer the GitHub repository from origin." + return 1 + } + fi + root="${parsed_options[root]:-}" + review_root="$(base_gh_review_resolve_root "$root")" || { + base_gh_error "Unable to prepare the review worktree root." + return 1 + } + refresh="${parsed_options[refresh]:-0}" + review_id="$(base_gh_review_id "$number" "$slug")" + review_ref="$(base_gh_review_ref "$number" "$slug")" + worktree_path="$(base_gh_review_path "$review_root" "$number" "$slug")" + metadata_path="$(base_gh_review_metadata_path "$review_id")" + + base_gh_require_command jq || return 1 + pr_json="$(base_cli_gh_run api "repos/$repo/pulls/$number" 2>&1)" || { + base_gh_error "Unable to read pull request #$number from '$repo'." + return 1 + } + base_repo="$(jq -r '.base.repo.full_name // empty' <<< "$pr_json")" + [[ "$base_repo" == "$repo" ]] || { + base_gh_error "Pull request #$number is not based on '$repo'." + return 1 + } + head_sha="$(jq -r '.head.sha // empty' <<< "$pr_json")" + [[ "$head_sha" =~ ^[0-9a-fA-F]{40}$ ]] || { + base_gh_error "Pull request #$number did not provide a valid head SHA." + return 1 + } + state="$(jq -r '.state // empty' <<< "$pr_json")" + [[ "$state" == open || "$state" == closed ]] || { + base_gh_error "Pull request #$number returned an invalid state." + return 1 + } + + origin_repo="$(base_gh_infer_github_repo)" || { + base_gh_error "Unable to infer the GitHub repository from origin." + return 1 + } + if [[ "$origin_repo" != "$repo" ]]; then + base_gh_error "Review fetch requires '$repo' to match the checkout's origin '$origin_repo'." + return 1 + fi + + if base_gh_review_ref_exists "$review_ref"; then + review_ref_exists=1 + fi + if [[ -e "$metadata_path" || -e "$worktree_path" ]] || ((review_ref_exists)); then + if ((refresh == 0)); then + base_gh_error "Review checkout '$review_id' already exists; pass --refresh only for a clean checkout." + return 1 + fi + base_gh_review_remove_existing "$worktree_path" "$review_ref" "$metadata_path" || return $? + fi + + git fetch origin "pull/$number/head:$review_ref" || { + base_gh_error "Unable to fetch pull request #$number into '$review_ref'." + return 1 + } + git worktree add --detach "$worktree_path" "$review_ref" || { + git update-ref -d "$review_ref" "$head_sha" >/dev/null 2>&1 || true + base_gh_error "Unable to create detached review worktree '$worktree_path'." + return 1 + } + actual_sha="$(git -C "$worktree_path" rev-parse HEAD 2>/dev/null)" || actual_sha="" + if [[ "$actual_sha" != "$head_sha" ]]; then + git worktree remove "$worktree_path" >/dev/null 2>&1 || true + git update-ref -d "$review_ref" "$actual_sha" >/dev/null 2>&1 || true + base_gh_error "Fetched review head for #$number did not match the GitHub head SHA." + return 1 + fi + created_at="$(base_gh_review_now)" + base_gh_review_write_metadata "$metadata_path" "$repo" "$number" "$slug" "$review_ref" "$worktree_path" "$head_sha" "$created_at" || { + git worktree remove "$worktree_path" >/dev/null 2>&1 || true + git update-ref -d "$review_ref" "$head_sha" >/dev/null 2>&1 || true + base_gh_error "Unable to write review metadata '$metadata_path'." + return 1 + } + + printf 'CREATED %s\n' "$worktree_path" + printf ' PR: #%s (%s)\n' "$number" "$state" + printf ' Ref: %s\n' "$review_ref" + printf ' Head: %s\n' "$head_sha" + printf ' State: detached\n' +} + +base_gh_review_metadata_valid() { + local metadata_path="$1" + + jq -e ' + .kind == "base-review-worktree" and + .schema == 1 and + (.repository | type == "string" and length > 0) and + (.pull_request | type == "number" and . >= 1 and floor == .) and + (.slug | type == "string" and test("^[a-z0-9]+(-[a-z0-9]+)*$")) and + (.review_ref | type == "string" and test("^refs/base-review/pr/[1-9][0-9]*/[a-z0-9]+(-[a-z0-9]+)*$")) and + (.worktree_path | type == "string" and startswith("/")) and + (.head_sha | type == "string" and test("^[0-9a-fA-F]{40}$")) and + (.created_at | type == "string" and length > 0) + ' "$metadata_path" >/dev/null 2>&1 +} + +base_gh_review_pr_state() { + local repo="$1" + local number="$2" + local output_var="$3" + local sha_var="$4" + local pr_json pr_state merged_at head_sha + + pr_json="$(base_cli_gh_run api "repos/$repo/pulls/$number" 2>&1)" || { + if [[ "$pr_json" == *"404"* || "$pr_json" == *"Not Found"* ]]; then + printf -v "$output_var" '%s' missing + printf -v "$sha_var" '%s' '' + return 0 + fi + return 2 + } + pr_state="$(jq -r '.state // empty' <<< "$pr_json")" + merged_at="$(jq -r '.merged_at // empty' <<< "$pr_json")" + head_sha="$(jq -r '.head.sha // empty' <<< "$pr_json")" + if [[ -n "$merged_at" ]]; then + pr_state=merged + elif [[ "$pr_state" == closed ]]; then + pr_state=closed_unmerged + elif [[ "$pr_state" == open ]]; then + pr_state=open + else + return 2 + fi + printf -v "$output_var" '%s' "$pr_state" + printf -v "$sha_var" '%s' "$head_sha" +} + +base_gh_review_prune() { + local root="" review_root metadata_dir metadata_path + local dry_run=1 dry_run_requested=0 yes=0 closed_unmerged=0 pr_filter="" + local number slug repo current_repo review_ref worktree_path head_sha created_at id + local state current_sha physical_root physical_path actual_sha + local removed=0 skipped_open=0 skipped_closed=0 skipped_dirty=0 skipped_stale=0 skipped_orphan=0 failed=0 + local metadata_id + local -a parser_args=() positionals=() + # shellcheck disable=SC2034 # base_arg_parse receives caller-owned arrays by name. + local -a option_specs=( + "dry_run|flag|--dry-run" + "yes|flag|--yes" + "closed_unmerged|flag|--closed-unmerged" + "pr|value|--pr" + "root|value|--root" + ) + local -A parsed_options=() seen_paths=() seen_refs=() + + while (($#)); do + case "$1" in + --dry-run|--yes|--closed-unmerged) + parser_args+=("$1") + shift + ;; + --pr|--root) + (($# > 1)) || { + base_gh_review_usage_error prune "Option '$1' requires an argument." + return $? + } + parser_args+=("$1" "$2") + shift 2 + ;; + -h|--help) + base_gh_review_leaf_usage prune + return 0 + ;; + *) + positionals+=("$1") + shift + ;; + esac + done + if ! base_arg_parse parsed_options positionals option_specs -- "${parser_args[@]}"; then + base_gh_review_usage_error prune "Could not parse review prune arguments." + return $? + fi + if ((${#positionals[@]} > 0)); then + base_gh_review_usage_error prune "Unknown option '${positionals[0]}'." + return $? + fi + dry_run_requested="${parsed_options[dry_run]:-0}" + yes="${parsed_options[yes]:-0}" + closed_unmerged="${parsed_options[closed_unmerged]:-0}" + pr_filter="${parsed_options[pr]:-}" + if [[ -n "$pr_filter" ]]; then + base_gh_review_validate_number "$pr_filter" || { + base_gh_review_usage_error prune "--pr must be a positive integer." + return $? + } + fi + if ((dry_run_requested && yes)); then + base_gh_review_usage_error prune "Options '--dry-run' and '--yes' cannot be used together." + return $? + fi + ((yes)) && dry_run=0 + + base_gh_require_git_repo || return 1 + base_gh_require_command jq || return 1 + current_repo="$(base_gh_infer_github_repo)" || { + base_gh_error "Unable to infer the GitHub repository from origin." + return 1 + } + root="${parsed_options[root]:-}" + review_root="$(base_gh_review_resolve_root "$root")" || { + base_gh_error "Unable to prepare the review worktree root." + return 1 + } + physical_root="$(base_gh_resolve_physical_path "$review_root")" || return 1 + metadata_dir="$(base_gh_review_metadata_dir)" || return 1 + [[ "$dry_run" -eq 1 ]] && printf '[DRY-RUN] Review prune preview.\n' + printf 'Review worktrees\n' + + if [[ -d "$metadata_dir" ]]; then + while IFS= read -r metadata_path; do + [[ -n "$metadata_path" ]] || continue + metadata_id="$(basename "$metadata_path" .json)" + if ! base_gh_review_metadata_valid "$metadata_path"; then + printf 'SKIP %s invalid metadata; retained\n' "$metadata_path" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + number="$(jq -r '.pull_request | tostring' "$metadata_path")" + slug="$(jq -r '.slug' "$metadata_path")" + repo="$(jq -r '.repository' "$metadata_path")" + review_ref="$(jq -r '.review_ref' "$metadata_path")" + worktree_path="$(jq -r '.worktree_path' "$metadata_path")" + head_sha="$(jq -r '.head_sha' "$metadata_path")" + created_at="$(jq -r '.created_at' "$metadata_path")" + id="$(base_gh_review_id "$number" "$slug")" + seen_paths["$worktree_path"]=1 + seen_refs["$review_ref"]=1 + + if [[ "$metadata_id" != "$id" || "$repo" != "$current_repo" || "$review_ref" != "$(base_gh_review_ref "$number" "$slug")" || "$worktree_path" != "$(base_gh_review_path "$physical_root" "$number" "$slug")" ]]; then + printf 'SKIP %s metadata ownership/path mismatch; retained\n' "$metadata_path" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + [[ -z "$pr_filter" || "$number" == "$pr_filter" ]] || continue + if [[ ! -e "$worktree_path" ]]; then + printf 'SKIP %s (%s) worktree missing; metadata and ref retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + if ! base_gh_worktree_is_valid "$worktree_path"; then + printf 'SKIP %s (%s) invalid worktree; retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + physical_path="$(base_gh_resolve_physical_path "$worktree_path")" || { + printf 'SKIP %s (%s) path could not be resolved; retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + } + if [[ "$physical_path" != "$physical_root"/* ]]; then + printf 'SKIP %s (%s) worktree is outside the review root; retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + if git -C "$worktree_path" symbolic-ref -q HEAD >/dev/null 2>&1; then + printf 'SKIP %s (%s) worktree is branch-attached; retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + if base_gh_worktree_dirty "$worktree_path"; then + printf 'SKIP %s (%s) dirty worktree; retained\n' "$worktree_path" "$review_ref" + skipped_dirty=$((skipped_dirty + 1)) + continue + fi + actual_sha="$(git -C "$worktree_path" rev-parse HEAD 2>/dev/null || true)" + if [[ "$actual_sha" != "$head_sha" ]] || ! base_gh_review_ref_exists "$review_ref" || [[ "$(git rev-parse "$review_ref^{commit}" 2>/dev/null || true)" != "$head_sha" ]]; then + printf 'SKIP %s (%s) review head/ref is stale or mismatched; retained\n' "$worktree_path" "$review_ref" + skipped_stale=$((skipped_stale + 1)) + continue + fi + if ! base_gh_review_pr_state "$repo" "$number" state current_sha; then + printf 'FAIL %s (%s) GitHub PR verification failed; retained\n' "$worktree_path" "$review_ref" + failed=$((failed + 1)) + continue + fi + if [[ "$state" == missing ]]; then + printf 'SKIP %s (%s) GitHub PR missing; retained\n' "$worktree_path" "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + continue + fi + if [[ "$current_sha" != "$head_sha" ]]; then + printf 'SKIP %s (%s) PR head changed since review checkout; retained\n' "$worktree_path" "$review_ref" + skipped_stale=$((skipped_stale + 1)) + continue + fi + case "$state" in + open) + printf 'SKIP %s (%s) open PR; retained\n' "$worktree_path" "$review_ref" + skipped_open=$((skipped_open + 1)) + continue + ;; + closed_unmerged) + if ((closed_unmerged == 0)); then + printf 'SKIP %s (%s) closed unmerged PR; retained\n' "$worktree_path" "$review_ref" + skipped_closed=$((skipped_closed + 1)) + continue + fi + ;; + merged) + ;; + *) + printf 'FAIL %s (%s) unknown PR state; retained\n' "$worktree_path" "$review_ref" + failed=$((failed + 1)) + continue + ;; + esac + + if ((dry_run)); then + printf '[DRY-RUN] REMOVE %s (%s); delete review ref and metadata\n' "$worktree_path" "$review_ref" + removed=$((removed + 1)) + continue + fi + if ! git worktree remove "$worktree_path"; then + printf 'FAIL %s (%s) worktree removal failed; retained\n' "$worktree_path" "$review_ref" + failed=$((failed + 1)) + continue + fi + if ! git update-ref -d "$review_ref" "$head_sha"; then + printf 'FAIL %s (%s) review ref removal failed; metadata retained\n' "$worktree_path" "$review_ref" + failed=$((failed + 1)) + continue + fi + if ! rm -f "$metadata_path"; then + printf 'FAIL %s (%s) metadata removal failed\n' "$worktree_path" "$review_ref" + failed=$((failed + 1)) + continue + fi + printf 'REMOVE %s (%s)\n' "$worktree_path" "$review_ref" + removed=$((removed + 1)) + done < <(find "$metadata_dir" -maxdepth 1 -type f -name '*.json' -print | sort) + fi + + if [[ -d "$review_root" ]]; then + for worktree_path in "$review_root"/pr-*; do + [[ -d "$worktree_path" ]] || continue + id="$(basename "$worktree_path")" + [[ "$id" =~ $BASE_GH_REVIEW_ID_PATTERN ]] || continue + [[ -n "$pr_filter" && "$id" != pr-${pr_filter}-* ]] && continue + [[ ${seen_paths["$worktree_path"]+seen} ]] && continue + printf 'SKIP %s no review metadata; retained\n' "$worktree_path" + skipped_orphan=$((skipped_orphan + 1)) + done + fi + while IFS= read -r review_ref; do + [[ -n "$review_ref" ]] || continue + [[ ${seen_refs["$review_ref"]+seen} ]] && continue + [[ -n "$pr_filter" && "$review_ref" != "$BASE_GH_REVIEW_REF_PREFIX/$pr_filter/"* ]] && continue + printf 'SKIP %s no review metadata; retained\n' "$review_ref" + skipped_orphan=$((skipped_orphan + 1)) + done < <(git for-each-ref --format='%(refname)' "$BASE_GH_REVIEW_REF_PREFIX") + + printf 'Summary: %s %s, %s skipped open, %s skipped closed-unmerged, %s skipped dirty, %s skipped stale, %s skipped orphaned/unowned, %s failed.\n' \ + "$removed" "$([[ "$dry_run" -eq 1 ]] && printf 'would remove' || printf 'removed')" \ + "$skipped_open" "$skipped_closed" "$skipped_dirty" "$skipped_stale" "$skipped_orphan" "$failed" + if ((dry_run)); then + printf 'Run with --yes to apply these changes.\n' + fi + ((failed == 0)) +} + +base_gh_review_main() { + local command="${1:-}" + shift || true + + case "$command" in + fetch) + base_gh_review_fetch "$@" + ;; + prune) + base_gh_review_prune "$@" + ;; + -h|--help|help|"") + base_gh_review_usage + ;; + *) + base_gh_usage_error base_gh_review_usage "Unknown gh review command '$command'." + return $? + ;; + esac +} diff --git a/cli/bash/commands/basectl/tests/completions.bats b/cli/bash/commands/basectl/tests/completions.bats index 022590b7..c976f9e8 100644 --- a/cli/bash/commands/basectl/tests/completions.bats +++ b/cli/bash/commands/basectl/tests/completions.bats @@ -282,7 +282,19 @@ EOF COMP_WORDS=(basectl gh worktree prune --); \ COMP_CWORD=4; \ _base_basectl_completion; \ - printf "gh_worktree_prune_options=%s\n" "${COMPREPLY[*]}"' + printf "gh_worktree_prune_options=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl gh review ""); \ + COMP_CWORD=3; \ + _base_basectl_completion; \ + printf "gh_review_commands=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl gh review fetch --); \ + COMP_CWORD=4; \ + _base_basectl_completion; \ + printf "gh_review_fetch_options=%s\n" "${COMPREPLY[*]}"; \ + COMP_WORDS=(basectl gh review prune --); \ + COMP_CWORD=4; \ + _base_basectl_completion; \ + printf "gh_review_prune_options=%s\n" "${COMPREPLY[*]}"' [ "$status" -eq 0 ] [[ "$output" == *"complete -F _base_basectl_completion basectl"* ]] @@ -341,7 +353,7 @@ EOF [[ "$output" == *"repo_configure_options=--repo --no-protect-default-branch --project --project-owner --project-schema --initiative-option --copy-project-fields-from --replace-project --no-project --release --dry-run"* ]] [[ "$output" == *"repo_agent_guidance_options=--repo --issue --category --repo-name --default-branch --validation-command --pr --dry-run"* ]] [[ "$output" == *"repo_installer_template_options=--print --stdout --repo --issue --category --pr --dry-run"* ]] - [[ "$output" == *"gh_areas=auth issue pr branch worktree project"* ]] + [[ "$output" == *"gh_areas=auth issue pr branch worktree project review"* ]] [[ "$output" == *"gh_auth_commands=status refresh"* ]] [[ "$output" == *"gh_auth_refresh_options=--hostname --scope --scopes --clipboard --help"* ]] [[ "$output" == *"gh_project_commands=doctor configure issue"* ]] @@ -349,6 +361,9 @@ EOF [[ "$output" == *"gh_project_issue_set_fields_options=--repo --project --owner --config --allow-cross-repo --status --priority --area --initiative --size --dry-run"* ]] [[ "$output" == *"gh_worktree_commands=prune"* ]] [[ "$output" == *"gh_worktree_prune_options=--dry-run --yes --closed-unmerged"* ]] + [[ "$output" == *"gh_review_commands=fetch prune"* ]] + [[ "$output" == *"gh_review_fetch_options=--name --repo --root --refresh --help"* ]] + [[ "$output" == *"gh_review_prune_options=--dry-run --yes --closed-unmerged --pr --root --help"* ]] } @test "Zsh repo pull request helper completions include issue and category options" { diff --git a/cli/bash/commands/basectl/tests/gh-review.bats b/cli/bash/commands/basectl/tests/gh-review.bats new file mode 100644 index 00000000..f4e77aa9 --- /dev/null +++ b/cli/bash/commands/basectl/tests/gh-review.bats @@ -0,0 +1,263 @@ +#!/usr/bin/env bats + +load ./basectl_helpers.bash + +create_review_repo() { + local repo="$TEST_TMPDIR/review-repo" + local remote="$TEST_TMPDIR/review-remote.git" + + init_git_repo "$repo" + printf 'base\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + git init --bare "$remote" >/dev/null 2>&1 + git -C "$repo" remote add origin "$remote" + git -C "$repo" push -u origin master >/dev/null 2>&1 + + git -C "$repo" switch -c review-source >/dev/null 2>&1 + printf 'review\n' >> "$repo/README.md" + commit_all "$repo" "Review commit" + BASE_REVIEW_TEST_HEAD_SHA="$(git -C "$repo" rev-parse HEAD)" + export BASE_REVIEW_TEST_HEAD_SHA + git -C "$repo" push origin HEAD:refs/pull/2621/head >/dev/null 2>&1 + git -C "$repo" switch master >/dev/null 2>&1 + BASE_REVIEW_TEST_REPO="$repo" + export BASE_REVIEW_TEST_REPO +} + +write_review_gh_mock() { + cat > "$TEST_MOCKBIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == api && "$2" == repos/basefoundry/base/pulls/2621 ]]; then + state="${BASE_REVIEW_TEST_STATE:-open}" + case "$state" in + fail) + printf 'gh: Not Found (HTTP 404)\n' >&2 + exit 1 + ;; + merged) + printf '{"base":{"repo":{"full_name":"basefoundry/base"}},"head":{"sha":"%s"},"state":"closed","merged_at":"2026-10-20T00:00:00Z"}\n' "$BASE_REVIEW_TEST_HEAD_SHA" + ;; + closed_unmerged) + printf '{"base":{"repo":{"full_name":"basefoundry/base"}},"head":{"sha":"%s"},"state":"closed","merged_at":null}\n' "$BASE_REVIEW_TEST_HEAD_SHA" + ;; + mismatch) + printf '{"base":{"repo":{"full_name":"basefoundry/base"}},"head":{"sha":"0000000000000000000000000000000000000001"},"state":"open","merged_at":null}\n' + ;; + *) + printf '{"base":{"repo":{"full_name":"basefoundry/base"}},"head":{"sha":"%s"},"state":"open","merged_at":null}\n' "$BASE_REVIEW_TEST_HEAD_SHA" + ;; + esac + exit 0 +fi +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 99 +EOF + chmod +x "$TEST_MOCKBIN/gh" +} + +run_review() { + local repo="$1" + shift + local bash_libs_dir + + bash_libs_dir="$(base_bash_libs_fixture_dir)" + run env \ + HOME="$TEST_HOME" \ + BASE_HOME="$BASE_REPO_ROOT" \ + BASE_BASH_LIBS_DIR="$bash_libs_dir" \ + BASE_REVIEW_TEST_HEAD_SHA="$BASE_REVIEW_TEST_HEAD_SHA" \ + BASE_REVIEW_TEST_STATE="${BASE_REVIEW_TEST_STATE:-open}" \ + BASE_GH_TEST_CWD="$repo" \ + BASE_GH_TEST_STATE_DIR="$TEST_STATE_DIR" \ + PATH="$TEST_MOCKBIN:$PATH" \ + bash -c ' + cd "$BASE_GH_TEST_CWD" + source "$BASE_HOME/base_init.sh" + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh.sh" + base_gh_infer_github_repo() { printf "basefoundry/base\\n"; } + base_gh_subcommand_main review "$@" + ' bash "$@" +} + +review_metadata_path() { + local repo="$1" + local common_dir + + common_dir="$(git -C "$repo" rev-parse --git-common-dir)" + if [[ "$common_dir" != /* ]]; then + common_dir="$repo/$common_dir" + fi + printf '%s/base-review/worktrees/pr-2621-stale-worktree.json\n' "$common_dir" +} + +@test "review fetch creates a detached namespaced worktree without a pr branch" { + local repo review_root worktree metadata + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + + run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + + [ "$status" -eq 0 ] + [[ "$output" == *"CREATED $review_root/pr-2621-stale-worktree"* ]] + [[ "$output" == *"Ref: refs/base-review/pr/2621/stale-worktree"* ]] + worktree="$review_root/pr-2621-stale-worktree" + metadata="$(review_metadata_path "$repo")" + [ -d "$worktree" ] + ! git -C "$worktree" symbolic-ref --quiet HEAD + git -C "$repo" show-ref --verify --quiet refs/base-review/pr/2621/stale-worktree + ! git -C "$repo" show-ref --verify --quiet refs/heads/pr-2621-stale-worktree + jq -e '.pull_request == 2621 and .slug == "stale-worktree" and .head_sha == env.BASE_REVIEW_TEST_HEAD_SHA' "$metadata" >/dev/null +} + +@test "review prune removes only a verified merged review checkout" { + local repo review_root worktree metadata + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + [ "$status" -eq 0 ] + worktree="$review_root/pr-2621-stale-worktree" + metadata="$(review_metadata_path "$repo")" + + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"[DRY-RUN] REMOVE $worktree"* ]] + [ -d "$worktree" ] + + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"REMOVE $worktree"* ]] + [ ! -e "$worktree" ] + ! git -C "$repo" show-ref --verify --quiet refs/base-review/pr/2621/stale-worktree + [ ! -e "$metadata" ] + + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"Summary: 0 removed"* ]] +} + +@test "review prune retains dirty, open, and closed-unmerged checkouts unless explicitly selected" { + local repo review_root worktree + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + [ "$status" -eq 0 ] + worktree="$review_root/pr-2621-stale-worktree" + + printf 'local changes\n' > "$worktree/local.txt" + BASE_REVIEW_TEST_STATE=open run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"dirty worktree; retained"* ]] + [ -d "$worktree" ] + + rm "$worktree/local.txt" + BASE_REVIEW_TEST_STATE=closed_unmerged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"closed unmerged PR; retained"* ]] + [ -d "$worktree" ] + + BASE_REVIEW_TEST_STATE=closed_unmerged run_review "$repo" prune --closed-unmerged --yes --root "$review_root" + [ "$status" -eq 0 ] + [ ! -e "$worktree" ] +} + +@test "review prune retains stale heads and orphaned metadata or refs" { + local repo review_root worktree metadata + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + [ "$status" -eq 0 ] + worktree="$review_root/pr-2621-stale-worktree" + metadata="$(review_metadata_path "$repo")" + + git -C "$worktree" reset --hard HEAD^ >/dev/null 2>&1 + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"review head/ref is stale or mismatched; retained"* ]] + [ -d "$worktree" ] + [ -e "$metadata" ] + + rm -rf "$worktree" + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"worktree missing; metadata and ref retained"* ]] + [ -e "$metadata" ] + + git -C "$repo" update-ref refs/base-review/pr/2622/orphan "$BASE_REVIEW_TEST_HEAD_SHA" + BASE_REVIEW_TEST_STATE=merged run_review "$repo" prune --yes --root "$review_root" + [ "$status" -eq 0 ] + [[ "$output" == *"refs/base-review/pr/2622/orphan no review metadata; retained"* ]] + git -C "$repo" show-ref --verify --quiet refs/base-review/pr/2622/orphan +} + +@test "review refresh does not discard an existing checkout when GitHub lookup fails" { + local repo review_root worktree + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + [ "$status" -eq 0 ] + worktree="$review_root/pr-2621-stale-worktree" + + BASE_REVIEW_TEST_STATE=fail run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" --refresh + [ "$status" -eq 1 ] + [[ "$output" == *"Unable to read pull request #2621"* ]] + [ -d "$worktree" ] + ! git -C "$worktree" symbolic-ref --quiet HEAD + git -C "$repo" show-ref --verify --quiet refs/base-review/pr/2621/stale-worktree +} + +@test "review fetch removes the fetched ref when the API head SHA is stale" { + local repo review_root + + create_review_repo + repo="$BASE_REVIEW_TEST_REPO" + mkdir -p "$TEST_TMPDIR/review-worktrees" + review_root="$(cd "$TEST_TMPDIR/review-worktrees" && pwd -P)" + write_review_gh_mock + + BASE_REVIEW_TEST_STATE=mismatch run_review "$repo" fetch 2621 --name stale-worktree --repo basefoundry/base --root "$review_root" + [ "$status" -eq 1 ] + [[ "$output" == *"did not match the GitHub head SHA"* ]] + [ ! -e "$review_root/pr-2621-stale-worktree" ] + ! git -C "$repo" show-ref --verify --quiet refs/base-review/pr/2621/stale-worktree +} + +@test "review fetch validates names and exposes command-scoped help" { + run_basectl gh review fetch --help + [ "$status" -eq 0 ] + [[ "$output" == *"basectl gh review fetch --name [options]"* ]] + + run_basectl gh review fetch 2621 --name Bad_Name + [ "$status" -eq 2 ] + [[ "$output" == *"Review name must be lower-case kebab-case."* ]] + + run_basectl gh review fetch 2621 --name ../escape + [ "$status" -eq 2 ] + [[ "$output" == *"Review name must be lower-case kebab-case."* ]] +} + +@test "review prune rejects conflicting execution flags" { + run_basectl gh review prune --dry-run --yes + + [ "$status" -eq 2 ] + [[ "$output" == *"Options '--dry-run' and '--yes' cannot be used together"* ]] +} diff --git a/docs/command-reference.md b/docs/command-reference.md index bcba3eb3..d683cea6 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -240,7 +240,9 @@ daily project loop commands from the local checkout. | `basectl gh pr create/status/checks/ready/merge` | Create and manage pull requests through Base's workflow wrapper. `pr create` rejects a noncanonical current branch before invoking GitHub, auto-injects `Fixes #` unless `--no-fixes` is passed, and uses `github.pr` from `base_manifest.yaml` when present. | passes through `gh` options; `pr create` also accepts `--no-fixes` | | `basectl gh branch stale` | Report stale local branches. Stable inspection JSON uses the shared v1 envelope. | `--days `, `--format ` | | `basectl gh branch prune` | Prune safe merged branches. Pass `--closed-unmerged` to include branches whose pull requests were closed without merging. | `--dry-run`, `--yes`, `--remote`, `--closed-unmerged` | -| `basectl gh worktree prune` | Prune stale merged branch-attached worktrees. Pass `--closed-unmerged` to include clean worktrees tied to closed, unmerged pull requests; detached worktree metadata remains an explicit `git worktree prune` operation. | `--dry-run`, `--yes`, `--closed-unmerged` | +| `basectl gh worktree prune` | Prune stale merged branch-attached worktrees. Pass `--closed-unmerged` to include clean worktrees tied to closed, unmerged pull requests; unmanaged detached worktree metadata remains an explicit `git worktree prune` operation. Use `gh review prune` for Base-owned review worktrees. | `--dry-run`, `--yes`, `--closed-unmerged` | +| `basectl gh review fetch ` | Fetch a pull-request head into a Base-owned namespaced ref and create a detached review worktree. It never creates an ordinary local `pr-*` branch. | `--name `, `--repo `, `--root `, `--refresh` | +| `basectl gh review prune` | Preview or remove safe Base-owned detached review worktrees and their namespaced refs. The default is dry-run; open, dirty, changed, invalid, missing, and unowned checkouts are retained. | `--dry-run`, `--yes`, `--closed-unmerged`, `--pr `, `--root ` | | `basectl gh project doctor` | Inspect GitHub Project metadata against the Base Project schema. | `--project `, `--owner <login>`, `--schema base-project` | | `basectl gh project configure` | Create or repair Base-managed Project metadata. | `--project <title>`, `--owner <login>`, `--repo <owner/name>`, `--schema base-project`, `--config <path>`, `--copy-fields-from <title>`, `--replace-project`, `--initiative-option <name>`, `--dry-run` | | `basectl gh project issue set-fields <number>` | Add an issue to the Project if needed and update metadata fields. The target Project must be linked to the issue repository unless the intentional cross-repository exception is enabled. | `--project <title>`, `--repo <owner/name>`, `--config <path>`, `--allow-cross-repo`, field options | diff --git a/docs/github-workflow.md b/docs/github-workflow.md index e98fd31f..b172ee13 100644 --- a/docs/github-workflow.md +++ b/docs/github-workflow.md @@ -502,8 +502,55 @@ and does not delete the directory contents. Inspect or preserve anything needed from that path, then use `git worktree prune --dry-run -v` followed by `git worktree prune -v` to remove stale Git administrative records. Git's prune only removes stale worktree metadata; it does not delete an orphaned directory. -Detached worktrees are intentionally outside Base's branch/PR cleanup boundary -and should be handled through the explicit Git command after review. +Unmanaged detached worktrees are intentionally outside Base's branch/PR cleanup +boundary and should be handled through the explicit Git command after review. + +## Review Worktrees + +Review-only checkouts use a separate, detached namespace so a reviewer never +has to create a local `pr-*` branch merely to inspect a pull request. Create one +with: + +```bash +basectl gh review fetch 2621 --name stale-worktree-review +``` + +The command records the checkout outside the worktree itself and uses these +stable names: + +```text +refs/base-review/pr/<number>/<slug> +<repo>-review-worktrees/pr-<number>-<slug> +``` + +The worktree is detached, the review ref is not an ordinary +`refs/heads/pr-*` branch, and the default root is next to the repository root +represented by the common Git directory. Use `--root <path>` when a different +review workspace is needed. Use `--refresh` only to replace an existing clean +review checkout with the current pull-request head. +The optional `--repo <owner/name>` selector must match the checkout's `origin` +repository; use a checkout of that repository when reviewing an upstream PR. + +Review cleanup is conservative and dry-run by default: + +```bash +basectl gh review prune +basectl gh review prune --yes +basectl gh review prune --closed-unmerged --yes +``` + +The pruner removes only Base-owned metadata records whose worktrees are inside +the configured review root, still detached and clean, whose local ref and +checkout still match the recorded pull-request head, and whose current GitHub +pull request is merged. Open, changed, missing, dirty, branch-attached, +closed-unmerged, invalid, or unowned review checkouts remain in place and are +reported. `--closed-unmerged` is an explicit opt-in for clean closed-but-not- +merged reviews; it never removes an open review. `--pr <number>` narrows a +preview or cleanup to one pull request. + +The old local `pr-*` branches created by ad-hoc review scripts are not part of +this feature. They are a one-time migration/cleanup task and must be audited +before deletion; the review pruner deliberately does not guess their ownership. If a worktree is removed but its local branch cannot be deleted, the command retains the branch, reports the incomplete cleanup, and exits nonzero. Resolve diff --git a/docs/stability-baseline/current.json b/docs/stability-baseline/current.json index 507f1e1e..798dab7f 100644 --- a/docs/stability-baseline/current.json +++ b/docs/stability-baseline/current.json @@ -676,6 +676,53 @@ } } }, + "basectl gh review fetch <number>": { + "flags": { + "--help": { + "takes_value": false + }, + "--name": { + "takes_value": true + }, + "--refresh": { + "takes_value": false + }, + "--repo": { + "takes_value": true + }, + "--root": { + "takes_value": true + }, + "-h": { + "takes_value": false + } + } + }, + "basectl gh review prune": { + "flags": { + "--closed-unmerged": { + "takes_value": false + }, + "--dry-run": { + "takes_value": false + }, + "--help": { + "takes_value": false + }, + "--pr": { + "takes_value": true + }, + "--root": { + "takes_value": true + }, + "--yes": { + "takes_value": false + }, + "-h": { + "takes_value": false + } + } + }, "basectl gh worktree prune": { "flags": { "--closed-unmerged": { diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index f880450b..eb6487bc 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -846,7 +846,7 @@ _base_basectl_completion() { ;; gh) if ((COMP_CWORD == 2)); then - _base_basectl_completion_compgen "auth issue pr branch worktree project" "$cur" + _base_basectl_completion_compgen "auth issue pr branch worktree project review" "$cur" else case "${COMP_WORDS[2]:-}" in auth) @@ -908,6 +908,15 @@ _base_basectl_completion() { _base_basectl_completion_compgen "--dry-run --yes --closed-unmerged -h --help" "$cur" fi ;; + review) + if ((COMP_CWORD == 3)); then + _base_basectl_completion_compgen "fetch prune" "$cur" + elif [[ "${COMP_WORDS[3]:-}" == fetch ]]; then + _base_basectl_completion_compgen "--name --repo --root --refresh -h --help" "$cur" + elif [[ "${COMP_WORDS[3]:-}" == prune ]]; then + _base_basectl_completion_compgen "--dry-run --yes --closed-unmerged --pr --root -h --help" "$cur" + fi + ;; project) if ((COMP_CWORD == 3)); then _base_basectl_completion_compgen "doctor configure issue" "$cur" diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 81fee817..606585e3 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -1024,13 +1024,13 @@ _base_basectl_completion() { auth) case "${words[4]:-}" in status) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:auth command:(status refresh)' \ '--hostname[GitHub host]:host:' \ '(-h --help)'{-h,--help}'[Show help text]' ;; refresh) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:auth command:(status refresh)' \ '--hostname[GitHub host]:host:' \ '--scope[Additional OAuth scope]:scope:' \ @@ -1039,7 +1039,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; *) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:auth command:(status refresh)' \ '(-h --help)'{-h,--help}'[Show help text]' ;; @@ -1048,12 +1048,12 @@ _base_basectl_completion() { issue) case "${words[4]:-}" in list) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:issue command:(list create start readiness)' \ '(-h --help)'{-h,--help}'[Show help text]' ;; create) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:issue command:(list create start readiness)' \ '--category[Issue category]:category:(bug enhancement documentation ci security)' \ '--title[Issue title]:title:' \ @@ -1069,7 +1069,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; start) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:issue command:(list create start readiness)' \ '4:issue number:' \ '--category[Issue category]:category:(bug enhancement documentation ci security)' \ @@ -1082,7 +1082,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; readiness) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:issue command:(list create start readiness)' \ '4:issue number:' \ '--repo[GitHub repository]:repo:' \ @@ -1092,7 +1092,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; *) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:issue command:(list create start readiness)' \ '(-h --help)'{-h,--help}'[Show help text]' ;; @@ -1100,25 +1100,25 @@ _base_basectl_completion() { ;; pr) if [[ "${words[4]:-}" == create ]]; then - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:pr command:(create status checks ready merge)' \ '--no-fixes[Do not add an issue-closing line derived from the branch]' \ '(-h --help)'{-h,--help}'[Show help text]' else - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:pr command:(create status checks ready merge)' \ '(-h --help)'{-h,--help}'[Show help text]' fi ;; branch) if [[ "${words[4]:-}" == stale ]]; then - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:branch command:(stale prune)' \ '--days[Stale threshold in days]:days:' \ '--format[Output format]:format:(text json)' \ '(-h --help)'{-h,--help}'[Show help text]' elif [[ "${words[4]:-}" == prune ]]; then - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:branch command:(stale prune)' \ '--dry-run[Show planned deletions]' \ '--yes[Apply branch pruning]' \ @@ -1126,22 +1126,50 @@ _base_basectl_completion() { '--closed-unmerged[Include branches tied to closed, unmerged PRs]' \ '(-h --help)'{-h,--help}'[Show help text]' else - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:branch command:(stale prune)' fi ;; worktree) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:worktree command:(prune)' \ '--dry-run[Show planned removals]' \ '--yes[Apply worktree pruning]' \ '--closed-unmerged[Include worktrees tied to closed, unmerged PRs]' \ '(-h --help)'{-h,--help}'[Show help text]' ;; + review) + case "${words[4]:-}" in + fetch) + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ + '3:review command:(fetch prune)' \ + '4:pull request number:' \ + '--name[Review name]:slug:' \ + '--repo[GitHub repository]:repo:' \ + '--root[Review worktree root]:path:_files' \ + '--refresh[Replace an existing clean review checkout]' \ + '(-h --help)'{-h,--help}'[Show help text]' + ;; + prune) + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ + '3:review command:(fetch prune)' \ + '--dry-run[Show planned removals]' \ + '--yes[Apply review pruning]' \ + '--closed-unmerged[Include clean worktrees tied to closed, unmerged PRs]' \ + '--pr[Restrict pruning to one pull request]:number:' \ + '--root[Review worktree root]:path:_files' \ + '(-h --help)'{-h,--help}'[Show help text]' + ;; + *) + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ + '3:review command:(fetch prune)' + ;; + esac + ;; project) case "${words[4]:-}" in doctor) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:project command:(doctor configure issue)' \ '--project[GitHub Project title]:title:' \ '--owner[GitHub Project owner]:owner:' \ @@ -1149,7 +1177,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; configure) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:project command:(doctor configure issue)' \ '--project[GitHub Project title]:title:' \ '--owner[GitHub Project owner]:owner:' \ @@ -1163,7 +1191,7 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; issue) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:project command:(doctor configure issue)' \ '4:issue command:(set-fields)' \ '5:issue number:' \ @@ -1181,13 +1209,13 @@ _base_basectl_completion() { '(-h --help)'{-h,--help}'[Show help text]' ;; *) - _arguments '2:gh area:(auth issue pr branch worktree project)' \ + _arguments '2:gh area:(auth issue pr branch worktree project review)' \ '3:project command:(doctor configure issue)' ;; esac ;; *) - _arguments '2:gh area:(auth issue pr branch worktree project)' + _arguments '2:gh area:(auth issue pr branch worktree project review)' ;; esac ;; diff --git a/lib/shell/completions/tests/completions.bats b/lib/shell/completions/tests/completions.bats index 93fc80e4..25ac7223 100644 --- a/lib/shell/completions/tests/completions.bats +++ b/lib/shell/completions/tests/completions.bats @@ -381,6 +381,8 @@ run_zsh_positional_completion() { assert_bash_completion_options_match_help gh-issue-readiness gh issue readiness assert_bash_completion_options_match_help gh-branch-stale gh branch stale assert_bash_completion_options_match_help gh-branch-prune gh branch prune + assert_bash_completion_options_match_help gh-review-fetch gh review fetch 2621 + assert_bash_completion_options_match_help gh-review-prune gh review prune assert_bash_completion_options_match_help gh-project-doctor gh project doctor assert_bash_completion_options_match_help gh-project-configure gh project configure } @@ -407,6 +409,8 @@ run_zsh_positional_completion() { assert_zsh_completion_options_match_help gh-issue-readiness gh issue readiness assert_zsh_completion_options_match_help gh-branch-stale gh branch stale assert_zsh_completion_options_match_help gh-branch-prune gh branch prune + assert_zsh_completion_options_match_help gh-review-fetch gh review fetch 2621 + assert_zsh_completion_options_match_help gh-review-prune gh review prune assert_zsh_completion_options_match_help gh-project-doctor gh project doctor assert_zsh_completion_options_match_help gh-project-configure gh project configure } @@ -535,7 +539,7 @@ run_zsh_positional_completion() { run_zsh_positional_completion basectl gh "" [ "$status" -eq 0 ] - [[ "$output" == *"positional=2:gh area:(auth issue pr branch worktree project)"* ]] + [[ "$output" == *"positional=2:gh area:(auth issue pr branch worktree project review)"* ]] run_zsh_positional_completion basectl gh auth "" [ "$status" -eq 0 ] @@ -549,6 +553,14 @@ run_zsh_positional_completion() { [ "$status" -eq 0 ] [[ "$output" == *"positional=4:issue number:"* ]] + run_zsh_positional_completion basectl gh review "" + [ "$status" -eq 0 ] + [[ "$output" == *"positional=3:review command:(fetch prune)"* ]] + + run_zsh_positional_completion basectl gh review fetch "" + [ "$status" -eq 0 ] + [[ "$output" == *"positional=4:pull request number:"* ]] + run_zsh_positional_completion basectl gh project issue set-fields "" [ "$status" -eq 0 ] [[ "$output" == *"positional=5:issue number:"* ]] @@ -605,6 +617,21 @@ run_zsh_positional_completion() { [[ "$block" != *"--assignee"* ]] } +@test "Zsh gh review completion includes fetch and prune options" { + local fetch_options prune_options + + command -v zsh >/dev/null 2>&1 || skip "zsh is not available" + + fetch_options="$(zsh_completion_long_options basectl gh review fetch --)" + prune_options="$(zsh_completion_long_options basectl gh review prune --)" + + [[ "$fetch_options" == *"--name"* ]] + [[ "$fetch_options" == *"--root"* ]] + [[ "$fetch_options" == *"--refresh"* ]] + [[ "$prune_options" == *"--closed-unmerged"* ]] + [[ "$prune_options" == *"--pr"* ]] +} + @test "Zsh gh project configure completion includes replace-project option" { local block