diff --git a/lib/bash/gh/README.md b/lib/bash/gh/README.md index 681c3a8..289c1f9 100644 --- a/lib/bash/gh/README.md +++ b/lib/bash/gh/README.md @@ -27,6 +27,38 @@ import "/path/to/base-bash-libs/lib/bash/gh/lib_gh.sh" Runs `gh "$@"` after command availability checks. On command failure, it reports the failed command and auth diagnostics while preserving the original exit status. +- `gh_repo_from_remote_url ` + Parses supported GitHub SSH and HTTPS remote URLs into `owner/repo`. Returns + non-zero for non-GitHub or malformed remotes and leaves the result variable + unchanged on failure. +- `gh_infer_repo_from_origin [--optional]` + Reads the `origin` remote from a local Git repository and stores `owner/repo` + when it points to GitHub. With `--optional`, missing or non-GitHub remotes + store an empty string and return success. +- `gh_detect_default_branch ` + Detects a local repository's default branch from `origin/HEAD`, then + `origin/main`, local `main`, `origin/master`, and local `master`. Returns + non-zero when no default branch can be detected. +- `gh_repo_default_branch ` + Uses `gh repo view` to read the GitHub repository default branch. +- `gh_api_with_retry [gh api args...]` + Runs `gh api "$@"` with bounded retries for API pressure and transient server + errors such as secondary rate limits, `Retry-After`, abuse detection, and + 502/503/504-style failures. `BASE_GH_API_MAX_ATTEMPTS` defaults to `2`. + `BASE_GH_API_RETRY_DELAY_SECONDS` defaults to `2` when the error output does + not include a `Retry-After` value. +- `gh_worktree_path_for_branch [repo_dir]` + Prints the path of the Git worktree attached to a local branch. Returns + non-zero when no worktree is attached. +- `gh_list_worktree_branches [repo_dir]` + Prints tab-separated `pathbranch` rows from `git worktree list + --porcelain`. +- `gh_branch_upstream ` + Prints the configured upstream ref for a local branch. +- `gh_branch_merged_to_ref ` + Returns success when `refs/heads/` is an ancestor of ``. +- `gh_list_remote_branches [repo_dir]` + Prints branch names from `git ls-remote --heads origin`. ## Boundary diff --git a/lib/bash/gh/lib_gh.sh b/lib/bash/gh/lib_gh.sh index dfe8522..cccfa72 100644 --- a/lib/bash/gh/lib_gh.sh +++ b/lib/bash/gh/lib_gh.sh @@ -53,3 +53,320 @@ gh_run() { ((status == 0)) && return 0 gh_report_command_failure "$status" "$@" } + +gh_repo_from_remote_url() { + local remote_url="$1" + local result_var="${2:-}" + local parsed_repo + + if [[ -z "$remote_url" || -z "$result_var" ]]; then + log_error "Usage: gh_repo_from_remote_url " + return 1 + fi + assert_variable_name "$result_var" + + case "$remote_url" in + git@github.com:*.git) + parsed_repo="${remote_url#git@github.com:}" + parsed_repo="${parsed_repo%.git}" + ;; + git@github.com:*) + parsed_repo="${remote_url#git@github.com:}" + ;; + https://github.com/*.git) + parsed_repo="${remote_url#https://github.com/}" + parsed_repo="${parsed_repo%.git}" + ;; + https://github.com/*) + parsed_repo="${remote_url#https://github.com/}" + ;; + *) + return 1 + ;; + esac + + [[ "$parsed_repo" == */* && "$parsed_repo" != */*/* ]] || return 1 + printf -v "$result_var" '%s' "$parsed_repo" +} + +gh_infer_repo_from_origin() { + local repo_dir="$1" + local result_var="${2:-}" + local optional=0 + local inferred_repo remote_url + + if [[ -z "$repo_dir" || -z "$result_var" ]]; then + log_error "Usage: gh_infer_repo_from_origin [--optional]" + return 1 + fi + assert_variable_name "$result_var" + + if [[ "${3:-}" == "--optional" ]]; then + optional=1 + fi + + remote_url="$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)" + if [[ -z "$remote_url" ]] || ! gh_repo_from_remote_url "$remote_url" inferred_repo; then + if ((optional)); then + printf -v "$result_var" '%s' "" + return 0 + fi + return 1 + fi + + printf -v "$result_var" '%s' "$inferred_repo" +} + +gh_detect_default_branch() { + local repo_dir="$1" + local result_var="${2:-}" + local default_branch + + if [[ -z "$repo_dir" || -z "$result_var" ]]; then + log_error "Usage: gh_detect_default_branch " + return 1 + fi + assert_variable_name "$result_var" + + if default_branch="$(git -C "$repo_dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)"; then + default_branch="${default_branch#origin/}" + if [[ -n "$default_branch" ]]; then + printf -v "$result_var" '%s' "$default_branch" + return 0 + fi + fi + + if git -C "$repo_dir" show-ref --verify --quiet refs/remotes/origin/main || + git -C "$repo_dir" show-ref --verify --quiet refs/heads/main; then + printf -v "$result_var" '%s' main + return 0 + fi + + if git -C "$repo_dir" show-ref --verify --quiet refs/remotes/origin/master || + git -C "$repo_dir" show-ref --verify --quiet refs/heads/master; then + printf -v "$result_var" '%s' master + return 0 + fi + + return 1 +} + +gh_repo_default_branch() { + local repo="$1" + local result_var="${2:-}" + local default_branch status + + if [[ -z "$repo" || -z "$result_var" ]]; then + log_error "Usage: gh_repo_default_branch " + return 1 + fi + assert_variable_name "$result_var" + + gh_require_cli || return 1 + default_branch="$(gh repo view "$repo" --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)" + status=$? + if ((status != 0)); then + gh_report_command_failure "$status" repo view "$repo" --json defaultBranchRef --jq .defaultBranchRef.name + return $? + fi + if [[ -z "$default_branch" ]]; then + log_error "GitHub repository '$repo' does not report a default branch." + return 1 + fi + + printf -v "$result_var" '%s' "$default_branch" +} + +__gh_api_failure_retryable() { + local output="${1,,}" + + [[ "$output" == *"secondary rate limit"* || + "$output" == *"rate limit"* || + "$output" == *"retry-after"* || + "$output" == *"abuse detection"* || + "$output" == *"http 502"* || + "$output" == *"http 503"* || + "$output" == *"http 504"* || + "$output" == *"bad gateway"* || + "$output" == *"service unavailable"* || + "$output" == *"gateway timeout"* ]] +} + +__gh_api_retry_delay_seconds() { + local output="${1,,}" + local configured_delay="${BASE_GH_API_RETRY_DELAY_SECONDS:-2}" + + if [[ "$output" =~ retry-after:[[:space:]]*([0-9]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return 0 + fi + + if [[ "$configured_delay" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$configured_delay" + return 0 + fi + + printf '%s\n' 2 +} + +gh_api_with_retry() { + local max_attempts="${BASE_GH_API_MAX_ATTEMPTS:-2}" + local attempt=1 + local output status delay + + gh_require_cli || return 1 + if [[ ! "$max_attempts" =~ ^[0-9]+$ ]] || ((max_attempts < 1)); then + log_warn "BASE_GH_API_MAX_ATTEMPTS must be a positive integer; using 2." + max_attempts=2 + fi + + while ((attempt <= max_attempts)); do + output="$(gh api "$@" 2>&1)" + status=$? + if ((status == 0)); then + [[ -z "$output" ]] || printf '%s\n' "$output" + return 0 + fi + + if ((attempt == max_attempts)) || ! __gh_api_failure_retryable "$output"; then + [[ -z "$output" ]] || printf '%s\n' "$output" >&2 + return "$status" + fi + + if ((max_attempts == 2)); then + log_warn "GitHub API call failed on attempt $attempt; retrying once." + else + log_warn "GitHub API call failed on attempt $attempt; retrying (attempt $((attempt + 1)) of $max_attempts)." + fi + delay="$(__gh_api_retry_delay_seconds "$output")" + sleep "$delay" + attempt=$((attempt + 1)) + done +} + +gh_worktree_path_for_branch() { + local branch="$1" + local repo_dir="${2:-}" + local target_ref="refs/heads/$branch" + local line path="" ref + + [[ -n "$branch" ]] || { + log_error "Usage: gh_worktree_path_for_branch [repo_dir]" + return 1 + } + + if [[ -n "$repo_dir" ]]; then + while IFS= read -r line; do + case "$line" in + "worktree "*) + path="${line#worktree }" + ;; + "branch "*) + ref="${line#branch }" + if [[ "$ref" == "$target_ref" ]]; then + printf '%s\n' "$path" + return 0 + fi + ;; + esac + done < <(git -C "$repo_dir" worktree list --porcelain) + else + while IFS= read -r line; do + case "$line" in + "worktree "*) + path="${line#worktree }" + ;; + "branch "*) + ref="${line#branch }" + if [[ "$ref" == "$target_ref" ]]; then + printf '%s\n' "$path" + return 0 + fi + ;; + esac + done < <(git worktree list --porcelain) + fi + + return 1 +} + +gh_list_worktree_branches() { + local repo_dir="${1:-}" + local line path="" branch="" + + if [[ -n "$repo_dir" ]]; then + while IFS= read -r line; do + case "$line" in + "") + if [[ -n "$path" && -n "$branch" ]]; then + branch="${branch#refs/heads/}" + printf '%s\t%s\n' "$path" "$branch" + fi + path="" + branch="" + ;; + "worktree "*) + path="${line#worktree }" + ;; + "branch "*) + branch="${line#branch }" + ;; + esac + done < <(git -C "$repo_dir" worktree list --porcelain; printf '\n') + else + while IFS= read -r line; do + case "$line" in + "") + if [[ -n "$path" && -n "$branch" ]]; then + branch="${branch#refs/heads/}" + printf '%s\t%s\n' "$path" "$branch" + fi + path="" + branch="" + ;; + "worktree "*) + path="${line#worktree }" + ;; + "branch "*) + branch="${line#branch }" + ;; + esac + done < <(git worktree list --porcelain; printf '\n') + fi +} + +gh_branch_upstream() { + local repo_dir="$1" + local branch="$2" + + if [[ -z "$repo_dir" || -z "$branch" ]]; then + log_error "Usage: gh_branch_upstream " + return 1 + fi + + git -C "$repo_dir" for-each-ref --format='%(upstream:short)' "refs/heads/$branch" +} + +gh_branch_merged_to_ref() { + local repo_dir="$1" + local branch="$2" + local ref="$3" + + if [[ -z "$repo_dir" || -z "$branch" || -z "$ref" ]]; then + log_error "Usage: gh_branch_merged_to_ref " + return 1 + fi + + git -C "$repo_dir" merge-base --is-ancestor "refs/heads/$branch" "$ref" >/dev/null 2>&1 +} + +gh_list_remote_branches() { + local repo_dir="${1:-.}" + local output ref + + output="$(git -C "$repo_dir" ls-remote --heads origin)" || return 1 + while read -r _sha ref; do + [[ "$ref" == refs/heads/* ]] || continue + printf '%s\n' "${ref#refs/heads/}" + done <<< "$output" +} diff --git a/lib/bash/gh/tests/lib_gh.bats b/lib/bash/gh/tests/lib_gh.bats index 50ec004..1b9f46f 100644 --- a/lib/bash/gh/tests/lib_gh.bats +++ b/lib/bash/gh/tests/lib_gh.bats @@ -4,6 +4,7 @@ load ../../tests/test_helper.sh setup() { setup_test_tmpdir + export TEST_TMPDIR mkdir -p "$TEST_TMPDIR/bin" PATH="$TEST_TMPDIR/bin:$BASE_TEST_ORIG_PATH" source "$BASE_BASH_DIR/std/lib_std.sh" @@ -17,6 +18,13 @@ create_fake_gh() { chmod +x "$script" } +create_fake_git() { + local script="$TEST_TMPDIR/bin/git" + + cat > "$script" + chmod +x "$script" +} + @test "lib_gh can be sourced more than once" { source "$BASE_BASH_DIR/gh/lib_gh.sh" @@ -111,3 +119,256 @@ EOF [[ "$output" == *"gh auth status: not logged in"* ]] [[ "$output" == *"Run 'gh auth login -h github.com' and retry."* ]] } + +@test "gh_repo_from_remote_url parses supported GitHub remotes" { + local repo + + gh_repo_from_remote_url "git@github.com:owner/repo.git" repo + [ "$repo" = "owner/repo" ] + + gh_repo_from_remote_url "git@github.com:owner/repo" repo + [ "$repo" = "owner/repo" ] + + gh_repo_from_remote_url "https://github.com/owner/repo.git" repo + [ "$repo" = "owner/repo" ] + + gh_repo_from_remote_url "https://github.com/owner/repo" repo + [ "$repo" = "owner/repo" ] +} + +@test "gh_repo_from_remote_url rejects non-GitHub and malformed remotes" { + local repo="sentinel" + + bats_run gh_repo_from_remote_url "https://example.com/owner/repo.git" repo + + [ "$status" -eq 1 ] + [ "$repo" = "sentinel" ] + + bats_run gh_repo_from_remote_url "https://github.com/owner" repo + + [ "$status" -eq 1 ] + [ "$repo" = "sentinel" ] +} + +@test "gh_infer_repo_from_origin reads origin through git -C" { + local repo_dir="$TEST_TMPDIR/repo" + local repo="" + + init_git_repo "$repo_dir" + git -C "$repo_dir" remote add origin "git@github.com:owner/repo.git" + + gh_infer_repo_from_origin "$repo_dir" repo + + [ "$repo" = "owner/repo" ] +} + +@test "gh_infer_repo_from_origin returns empty success for non-GitHub remotes when optional" { + local repo_dir="$TEST_TMPDIR/repo" + local repo="sentinel" + + init_git_repo "$repo_dir" + git -C "$repo_dir" remote add origin "https://example.com/owner/repo.git" + + gh_infer_repo_from_origin "$repo_dir" repo --optional + + [ "$repo" = "" ] +} + +@test "gh_detect_default_branch prefers origin HEAD and falls back to local main or master" { + local repo_dir="$TEST_TMPDIR/repo" + local branch="" + + init_git_repo "$repo_dir" + printf 'base\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Initial commit" + git -C "$repo_dir" update-ref refs/remotes/origin/trunk HEAD + git -C "$repo_dir" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk + + gh_detect_default_branch "$repo_dir" branch + + [ "$branch" = "trunk" ] + + git -C "$repo_dir" symbolic-ref -d refs/remotes/origin/HEAD + gh_detect_default_branch "$repo_dir" branch + + [ "$branch" = "main" ] +} + +@test "gh_detect_default_branch reports failure when no default branch can be detected" { + local repo_dir="$TEST_TMPDIR/repo" + local branch="sentinel" + + init_git_repo "$repo_dir" feature + printf 'base\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Initial commit" + + bats_run gh_detect_default_branch "$repo_dir" branch + + [ "$status" -eq 1 ] + [ "$branch" = "sentinel" ] +} + +@test "gh_repo_default_branch reads GitHub repository default branch" { + local branch="" + + create_fake_gh <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "repo" && "$2" == "view" ]]; then + printf 'develop\n' + exit 0 +fi +exit 99 +EOF + + gh_repo_default_branch "owner/repo" branch + + [ "$branch" = "develop" ] +} + +@test "gh_api_with_retry retries retryable API pressure once" { + create_fake_gh <<'EOF' +#!/usr/bin/env bash +state_file="${TEST_TMPDIR:?}/gh-api-count" +count=0 +[[ -f "$state_file" ]] && read -r count < "$state_file" +count=$((count + 1)) +printf '%s\n' "$count" > "$state_file" +if ((count == 1)); then + printf 'secondary rate limit; retry-after: 0\n' >&2 + exit 1 +fi +printf 'ok\n' +EOF + + capture_command gh_api_with_retry repos/owner/repo --jq .name + + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub API call failed on attempt 1; retrying once."* ]] + [[ "$output" == *"ok"* ]] + [ "$(cat "$TEST_TMPDIR/gh-api-count")" = "2" ] +} + +@test "gh_api_with_retry preserves non-retryable failures" { + create_fake_gh <<'EOF' +#!/usr/bin/env bash +printf 'not found\n' >&2 +exit 4 +EOF + + capture_command gh_api_with_retry repos/owner/missing + + [ "$status" -eq 4 ] + [[ "$output" == *"not found"* ]] + [[ "$output" != *"retrying"* ]] +} + +@test "gh_worktree_path_for_branch finds the worktree for a local branch" { + create_fake_git <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "worktree" && "$2" == "list" ]]; then + cat <<'OUT' +worktree /tmp/main +HEAD abc123 +branch refs/heads/main + +worktree /tmp/feature +HEAD def456 +branch refs/heads/feature/test +OUT + exit 0 +fi +exit 99 +EOF + + capture_command gh_worktree_path_for_branch feature/test + + [ "$status" -eq 0 ] + [ "$output" = "/tmp/feature" ] +} + +@test "gh_list_worktree_branches emits path and branch pairs" { + create_fake_git <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "worktree" && "$2" == "list" ]]; then + cat <<'OUT' +worktree /tmp/main +HEAD abc123 +branch refs/heads/main + +worktree /tmp/feature +HEAD def456 +branch refs/heads/feature/test +OUT + exit 0 +fi +exit 99 +EOF + + capture_command gh_list_worktree_branches + + [ "$status" -eq 0 ] + [[ "$output" == *$'/tmp/main\tmain'* ]] + [[ "$output" == *$'/tmp/feature\tfeature/test'* ]] +} + +@test "gh_branch_upstream prints the configured upstream" { + local repo_dir="$TEST_TMPDIR/repo" + local remote_dir="$TEST_TMPDIR/remote.git" + + create_tracked_repo_with_upstream "$repo_dir" "$remote_dir" "data.txt" "base" + + capture_command gh_branch_upstream "$repo_dir" main + + [ "$status" -eq 0 ] + [ "$output" = "origin/main" ] +} + +@test "gh_branch_merged_to_ref succeeds only for ancestors" { + local repo_dir="$TEST_TMPDIR/repo" + + init_git_repo "$repo_dir" + printf 'base\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Initial commit" + git -C "$repo_dir" branch feature + printf 'main\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Main change" + + gh_branch_merged_to_ref "$repo_dir" feature main +} + +@test "gh_branch_merged_to_ref fails for branches not merged to the ref" { + local repo_dir="$TEST_TMPDIR/repo" + + init_git_repo "$repo_dir" + printf 'base\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Initial commit" + git -C "$repo_dir" switch -c feature >/dev/null 2>&1 + printf 'feature\n' > "$repo_dir/data.txt" + commit_all "$repo_dir" "Feature change" + git -C "$repo_dir" switch main >/dev/null 2>&1 + + bats_run gh_branch_merged_to_ref "$repo_dir" feature main + + [ "$status" -eq 1 ] +} + +@test "gh_list_remote_branches lists remote heads" { + create_fake_git <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "-C" ]]; then + shift 2 +fi +if [[ "$1" == "ls-remote" && "$2" == "--heads" && "$3" == "origin" ]]; then + printf 'abc\trefs/heads/main\n' + printf 'def\trefs/heads/feature/test\n' + exit 0 +fi +exit 99 +EOF + + capture_command gh_list_remote_branches "$TEST_TMPDIR/repo" + + [ "$status" -eq 0 ] + [[ "$output" == *"main"* ]] + [[ "$output" == *"feature/test"* ]] +}