Something went wrong. Try again.
Dotfiles for my macos setup
Something went wrong. Try again.
20 kB · 651 lines
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652#!/usr/bin/env bashset -euo pipefail
temp_files=()cleanup() { rm -f "${temp_files[@]}"}trap cleanup EXIT
# Ctrl+C: gum handles SIGINT itself and exits 130, so also handle the case where# the signal does reach us (plain read/select fallbacks).abort() { echo "Aborted." >&2 exit 130}trap abort INT TERM
die() { echo "$*" >&2 exit 1}
# Bail out when an interactive prompt was cancelled with Ctrl+C (gum exits 130);# other non-zero statuses (e.g. Esc = no selection) are treated as empty input.check_cancelled() { local status=$1 [[ $status -eq 130 ]] && abort return 0}
has_gum() { command -v gum >/dev/null 2>&1}
# new_tempfile <var> [suffix]: create a temp file, register it for cleanup and# store its path in <var> (no subshell, so temp_files stays in sync).new_tempfile() { local f f=$(mktemp -t pr-create.XXXXXX) if [[ -n "${2:-}" ]]; then mv "$f" "$f$2" f+=$2 fi temp_files+=("$f") printf -v "$1" '%s' "$f"}
trim() { local s=$1 s=${s#"${s%%[![:space:]]*}"} s=${s%"${s##*[![:space:]]}"} printf '%s' "$s"}
usage() { cat <<'EOF'Usage: git pr [options]
Fills in every field automatically, then shows a review screen where you canchange anything before the PR is created. --yes skips the review.
Defaults: title last commit subject (single conventional commit), else generated by pi body generated by pi from the branch diff base repo default branch linear "Closes <ID>" for a ticket ID at the start of the branch name draft no, reviewers none, push yes
Options: --title <text> PR title (conventional, e.g. "feat: ...", "fix(api): ...") --base <branch> Base branch (default: repo default) --draft / --no-draft Create as draft PR --reviewers <group|handles> Reviewer group from config, comma separated handles, or "none" (repeatable) --body-file <path> Use this file as the PR body (skips generation) --linear <"Closes ABC-1"> Linear link line (repeatable, replaces branch detection; "" = none) --push / --no-push Push the branch if needed (default: push) --no-generate Do not call pi for title or body -y, --yes Skip the review screen and create directly -h, --help Show this help
Title and body come from the /pr-title and /pr-body pi commands (pi-extensionspr-create), using the "fast" chain in ~/.pi/agent/extension-models.json.Reviewer groups are read from: ${GIT_PR_REVIEWERS_FILE:-${XDG_CONFIG_HOME:-~/.config}/git-pr/reviewers.conf}EOF}
title=""base=""draft="no"reviewer_args=()selected_reviewers=()body_file=""body_source="none"linear_links=()linear_given=0push="yes"generate="yes"assume_yes=0
while [[ $# -gt 0 ]]; do case "$1" in --title) title=$2 shift 2 ;; --base) base=$2 shift 2 ;; --draft) draft="yes" shift ;; --no-draft) draft="no" shift ;; --reviewers) reviewer_args+=("$2") shift 2 ;; --body-file) [[ -f $2 ]] || die "Body file not found: $2" # Work on a copy so edits and Linear lines never touch the caller's file. new_tempfile body_file .md cp "$2" "$body_file" body_source="file" shift 2 ;; --linear) [[ -n $2 ]] && linear_links+=("$2") linear_given=1 shift 2 ;; --push) push="yes" shift ;; --no-push) push="no" shift ;; --no-generate) generate="no" shift ;; -y | --yes) assume_yes=1 shift ;; -h | --help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esacdone
# --- prompts ---------------------------------------------------------------
# Echo the question/answer pair so the transcript stays visible (gum clears its# own UI once an answer is given).record_answer() { local label=$1 answer=$2 [[ -z "$answer" ]] && answer="(empty)" if has_gum; then gum style --foreground 2 "✓ $label: $answer" >&2 else printf '✓ %s: %s\n' "$label" "$answer" >&2 fi}
prompt_input() { local prompt=$1 placeholder=${2:-} initial=${3:-} value if has_gum; then value=$(gum input --width 0 --prompt "$prompt: " --value "$initial" ${placeholder:+--placeholder "$placeholder"}) || check_cancelled $? else read -r -e -i "$initial" -p "$prompt: " value || abort fi record_answer "$prompt" "$value" printf '%s\n' "$value"}
prompt_menu() { local title=$1 choice shift if has_gum; then choice=$(gum choose --header "$title" --cursor "❯ " --header.foreground "#cba6f7" --header.bold --cursor.foreground "#f5c2e7" "$@") || check_cancelled $? else select choice in "$@"; do [[ -n "$choice" ]] && break done fi printf '%s\n' "$choice"}
# Multi-select with fuzzy search; prints one selection per line.prompt_multi() { local title=$1 selection answer shift [[ $# -eq 0 ]] && return if has_gum; then selection=$(gum filter --no-limit --height 15 --header "$title" --placeholder "type to search, tab to select, enter to confirm" "$@") || check_cancelled $? else read -r -p "$title (space separated, empty for none): " answer || abort # shellcheck disable=SC2086 # intentional word splitting selection=$(printf '%s\n' $answer) fi record_answer "$title" "$(printf '%s' "$selection" | tr '\n' ' ')" printf '%s\n' "$selection"}
# --- reviewers -------------------------------------------------------------
reviewers_file=${GIT_PR_REVIEWERS_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/git-pr/reviewers.conf}reviewer_group_names=()reviewer_group_members=()all_reviewers=()all_reviewer_labels=()
# dedupe_list <items...>: print items once each, preserving order.dedupe_list() { local out=() item existing seen for item in "$@"; do [[ -z "$item" ]] && continue seen=0 for existing in "${out[@]:-}"; do [[ "$existing" == "$item" ]] && seen=1 && break done [[ $seen -eq 0 ]] && out+=("$item") done [[ ${#out[@]} -gt 0 ]] && printf '%s\n' "${out[@]}" return 0}
# Config format (INI-ish, sections optional; default section is [people]):# [groups]# Core Network Squad = onomondo/core-network-squad# [people]# peterbornerup = Peter S. Bornerupadd_person() { local handle=$1 name=$2 existing [[ -z "$handle" ]] && return 0 for existing in "${all_reviewers[@]:-}"; do [[ "$existing" == "$handle" ]] && return 0 done all_reviewers+=("$handle") if [[ -n "$name" ]]; then all_reviewer_labels+=("$handle - $name") else all_reviewer_labels+=("$handle") fi}
load_reviewer_config() { [[ -f "$reviewers_file" ]] || return 0 local line section=people key value handle local handles=() while IFS= read -r line || [[ -n "$line" ]]; do line=$(trim "${line%%#*}") [[ -z "$line" ]] && continue if [[ "$line" =~ ^\[(.+)\]$ ]]; then section=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') continue fi if [[ "$line" == *=* ]]; then key=$(trim "${line%%=*}") value=$(trim "${line#*=}") else key=$line value="" fi [[ -z "$key" ]] && continue if [[ "$section" == "groups" ]]; then [[ -z "$value" ]] && continue value=$(printf '%s' "$value" | tr -d '[:space:]') reviewer_group_names+=("$key") reviewer_group_members+=("$value") IFS=',' read -r -a handles <<<"$value" for handle in "${handles[@]}"; do # Team refs (org/team) are not individually selectable. [[ -n "$handle" && "$handle" != */* ]] && add_person "$handle" "" done else add_person "$key" "$value" fi done <"$reviewers_file"}
group_members() { local want=$1 i for i in "${!reviewer_group_names[@]}"; do if [[ "${reviewer_group_names[$i]}" == "$want" ]]; then printf '%s' "${reviewer_group_members[$i]}" return 0 fi done return 1}
add_reviewers() { local spec=$1 members handle local handles=() [[ "$(printf '%s' "$spec" | tr '[:upper:]' '[:lower:]')" == "none" ]] && return 0 if members=$(group_members "$spec"); then spec=$members fi IFS=',' read -r -a handles <<<"$spec" for handle in "${handles[@]}"; do handle=$(trim "$handle") [[ -n "$handle" ]] && selected_reviewers+=("$handle") done return 0}
pick_reviewers() { local choices=() label group manual for group in "${reviewer_group_names[@]}"; do choices+=("Group: $group") done choices+=("${all_reviewer_labels[@]}") selected_reviewers=() while IFS= read -r label; do [[ -z "$label" ]] && continue if [[ "$label" == "Group: "* ]]; then add_reviewers "${label#Group: }" else # Labels look like "handle - Full Name"; keep the handle. selected_reviewers+=("${label%% *}") fi done < <(prompt_multi "Reviewers" "${choices[@]}") manual=$(prompt_input "Extra handles" "comma separated, optional") [[ -n "$manual" ]] && add_reviewers "$manual" return 0}
reviewers_csv() { local out out=$(dedupe_list "${selected_reviewers[@]:-}" | paste -sd, -) printf '%s' "$out"}
# --- title -----------------------------------------------------------------
allowed_types=(feat fix docs style refactor perf test build ci chore revert deps)types_alt=$( IFS='|' printf '%s' "${allowed_types[*]}")title_re="^(${types_alt})(\([^)]+\))?!?: .+"loose_title_re="^(${types_alt})(\([^)]+\))?(!)?[[:space:]]+(.+)$"title_hint="<type>[(scope)][!]: <subject> | types: ${allowed_types[*]}"
# Print a valid conventional title for $1 (fixing gitmoji prefixes and a missing# colon), or fail.normalize_title() { local t t=$(trim "$1") [[ "$t" =~ $title_re ]] && printf '%s\n' "$t" && return 0 # shortcut: strips :gitmoji: codes only, not unicode emoji; extend if those show up. while [[ "$t" =~ ^:[a-z0-9_+-]+:[[:space:]]*(.*)$ ]]; do t=${BASH_REMATCH[1]} done if [[ "$t" =~ $loose_title_re ]]; then t="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}: ${BASH_REMATCH[4]}" fi [[ "$t" =~ $title_re ]] && printf '%s\n' "$t" && return 0 return 1}
# --- branch context --------------------------------------------------------
default_branch() { git default-branch 2>/dev/null && return 0 gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null}
resolve_base_ref() { local b=${1#origin/} ref [[ -n "$b" ]] || return 1 for ref in "origin/$b" "$b"; do if git rev-parse --verify --quiet "$ref" >/dev/null; then printf '%s' "$ref" return 0 fi done return 1}
base_ref=""
# Resolve the base ref; generation is disabled when it cannot be resolved.build_context() { base_ref=$(resolve_base_ref "${base:-$default_base}") || { echo "Could not resolve base '${base:-$default_base}': generation disabled." >&2 base_ref="" } return 0}
# Linear ticket IDs at the start of the branch or after a slash: ONO-123-x, joe/ono-123-x.branch_linear_links() { local rest id rest=$(git branch --show-current) while [[ "$rest" =~ (^|/)([A-Za-z][A-Za-z0-9]{1,9}-[0-9]+)(-|/|$) ]]; do id=${BASH_REMATCH[2]} printf 'Closes %s\n' "${id^^}" rest=${rest#*"${BASH_REMATCH[0]}"} done}
# --- pi generation ---------------------------------------------------------
# run_pi <label> <command>: run a pr-create extension generator (/pr-title,# /pr-body) in print mode. The extension computes the diff, builds its own prompt# with the pr/unslop skills, tries the "fast" model chain and validates output.run_pi() { local label=$1 command=$2 out out_file [[ -n "$base_ref" ]] || return 1 if has_gum; then # gum spin --show-stdout prints to the terminal instead of stdout when # run on a tty, so capture through a file. new_tempfile out_file # shellcheck disable=SC2016 # $1/$2 expand in the inner sh gum spin --title "$label..." -- sh -c 'pi -p --no-session "$1" >"$2"' _ "$command $base_ref" "$out_file" || check_cancelled $? out=$(<"$out_file") rm -f "$out_file" # run_pi runs in a subshell, so the EXIT cleanup misses it else echo "$label..." >&2 out=$(pi -p --no-session "$command $base_ref") || true fi [[ -n "$out" ]] || return 1 printf '%s\n' "$out"}
generate_title() { [[ $generate == yes ]] || return 1 run_pi "Generating PR title" /pr-title}
generate_body() { [[ $generate == yes ]] || return 1 local out out=$(run_pi "Generating PR description" /pr-body) || { echo "Description generation failed." >&2 return 1 } [[ -n "$body_file" ]] || new_tempfile body_file .md printf '%s\n' "$out" >"$body_file" body_source="generated"}
# Single conventional commit: use its subject. Otherwise ask pi, falling back# to the last commit subject.auto_title() { local subject count=0 subject=$(git log -1 --pretty=%s) [[ -n "$base_ref" ]] && count=$(git rev-list --count "$base_ref..HEAD") if [[ $count -eq 1 ]] && normalize_title "$subject"; then return 0 fi generate_title || normalize_title "$subject" || true}
# --- review ----------------------------------------------------------------
push_status() { local n if ! git rev-parse --verify --quiet '@{u}' >/dev/null; then printf '%s' "new branch" return fi n=$(git rev-list --count '@{u}..HEAD') if [[ $n -eq 0 ]]; then printf 'up to date'; else printf '%s unpushed' "$n"; fi}
show_body() { [[ -s "$body_file" ]] || return 0 if has_gum; then gum style --foreground "#cba6f7" --bold --margin "1 0 0 0" "── PR body ──────────────────────────────" >&2 fi if command -v glow >/dev/null 2>&1; then glow -w 100 "$body_file" >&2 elif has_gum; then gum format --type markdown <"$body_file" >&2 else cat "$body_file" >&2 fi}
show_summary() { local body_info="none (gh fills from commits)" reviewers linear [[ -s "$body_file" ]] && body_info="$(wc -l <"$body_file" | tr -d ' ') lines, $body_source" reviewers=$(reviewers_csv) linear=$(printf '%s\n' "${linear_links[@]:-}" | paste -sd, - | sed 's/,/, /g') local k=$'\e[1;38;2;137;180;250m' r=$'\e[0m' local lines=( "${k}Title${r} ${title:-(missing)}" "${k}Base${r} ${base:-$default_base (default)}" "${k}Draft${r} $draft" "${k}Reviewers${r} ${reviewers:-none}" "${k}Linear${r} ${linear:-none}" "${k}Body${r} $body_info" "${k}Push${r} $push ($(push_status))" ) if has_gum; then gum style --border rounded --border-foreground "#cba6f7" --padding "0 1" --margin "1 0" "${lines[@]}" >&2 else printf '%s\n' "${lines[@]}" >&2 fi}
edit_title() { local t t=$(prompt_input "Title" "$title_hint" "$title") if t=$(normalize_title "$t"); then title=$t else echo "Invalid title, kept the old one. Format: $title_hint" >&2 fi}
edit_linear() { local raw item local items=() raw=$(prompt_input "Linear" "Closes ABC-1, Ref ABC-2 (empty = none)" "$(printf '%s\n' "${linear_links[@]:-}" | paste -sd, - | sed 's/,/, /g')") linear_links=() IFS=',' read -r -a items <<<"$raw" for item in "${items[@]}"; do item=$(trim "$item") [[ -z "$item" ]] && continue [[ "$item" =~ ^(Closes|Ref)[[:space:]] ]] || item="Closes $item" linear_links+=("$item") done}
review() { show_body while true; do show_summary case "$(prompt_menu "Review PR" "Create" "Edit title" "Regenerate title" "Edit body" "Regenerate body" "Base" "Toggle draft" "Reviewers" "Linear" "Cancel")" in Create) [[ -n "$title" ]] && return 0 echo "Title is required." >&2 ;; "Edit title") edit_title ;; "Regenerate title") local t t=$(generate_title) && title=$t ;; "Edit body") [[ -n "$body_file" ]] || new_tempfile body_file .md "${VISUAL:-${EDITOR:-nvim}}" "$body_file" body_source="edited" ;; "Regenerate body") generate_body && show_body ;; Base) base=$(prompt_input "Base branch" "empty = $default_base" "$base") build_context [[ "$body_source" == generated ]] && echo "Body was generated against the old base. Regenerate it if needed." >&2 ;; "Toggle draft") if [[ $draft == yes ]]; then draft=no; else draft=yes; fi ;; Reviewers) pick_reviewers ;; Linear) edit_linear ;; *) echo "Cancelled." >&2 exit 1 ;; esac done}
# --- create ----------------------------------------------------------------
ensure_branch_pushed() { if ! git rev-parse --verify --quiet '@{u}' >/dev/null; then [[ $push == yes ]] || die "Current branch has no upstream. Push branch first." git push --set-upstream origin "$(git branch --show-current)" >&2 elif [[ $(git rev-list --count '@{u}..HEAD') -ne 0 ]]; then [[ $push == yes ]] || die "Current branch has unpushed commits. Push branch first." git push >&2 fi}
create_pr() { local reviewers pr_url edit_body_file ensure_branch_pushed
if [[ -s "$body_file" && ${#linear_links[@]} -gt 0 ]]; then printf '\n\n%s\n' "$(printf '%s\n' "${linear_links[@]}")" >>"$body_file" fi
local args=(pr create --assignee @me --title "$title" --fill) [[ -s "$body_file" ]] && args+=(--body-file "$body_file") [[ -n "$base" ]] && args+=(--base "$base") [[ $draft == yes ]] && args+=(--draft) reviewers=$(reviewers_csv) [[ -n "$reviewers" ]] && args+=(--reviewer "$reviewers")
pr_url=$(gh "${args[@]}") printf '%s\n' "$pr_url"
# No body: gh filled it from commits, so append Linear links afterwards. if [[ ! -s "$body_file" && ${#linear_links[@]} -gt 0 ]]; then new_tempfile edit_body_file .md gh pr view "$pr_url" --json body --jq '.body' >"$edit_body_file" printf '\n\n%s\n' "$(printf '%s\n' "${linear_links[@]}")" >>"$edit_body_file" gh pr edit "$pr_url" --body-file "$edit_body_file" >/dev/null echo "Added Linear links to PR description." >&2 fi}
# --- main ------------------------------------------------------------------
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "Not inside a git repository."
load_reviewer_configfor spec in "${reviewer_args[@]}"; do add_reviewers "$spec"done
if [[ -n "$title" ]]; then title=$(normalize_title "$title") || die "Invalid PR title. Format: $title_hint"fi
if [[ $linear_given -eq 0 ]]; then while IFS= read -r link; do linear_links+=("$link") done < <(branch_linear_links)fi
default_base=$(default_branch || true)build_context
[[ -z "$title" ]] && title=$(auto_title)[[ -z "$body_file" ]] && { generate_body || true; }
if [[ $assume_yes -eq 1 ]]; then [[ -n "$title" ]] || die "Could not derive a PR title. Pass --title."else reviewfi
create_pr