Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/bash/gh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote_url> <result_var>`
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 <repo_dir> <result_var> [--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 <repo_dir> <result_var>`
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 <owner/repo> <result_var>`
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 <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 `path<TAB>branch` rows from `git worktree list
--porcelain`.
- `gh_branch_upstream <repo_dir> <branch>`
Prints the configured upstream ref for a local branch.
- `gh_branch_merged_to_ref <repo_dir> <branch> <ref>`
Returns success when `refs/heads/<branch>` is an ancestor of `<ref>`.
- `gh_list_remote_branches [repo_dir]`
Prints branch names from `git ls-remote --heads origin`.

## Boundary

Expand Down
317 changes: 317 additions & 0 deletions lib/bash/gh/lib_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote_url> <result_variable_name>"
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 <repo_dir> <result_variable_name> [--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 <repo_dir> <result_variable_name>"
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 <owner/repo> <result_variable_name>"
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 <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 <repo_dir> <branch>"
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 <repo_dir> <branch> <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"
}
Loading
Loading