Created
December 22, 2025 11:34
-
-
Save dashw00d/a78c832fca25f976d24f4cc059dd10f5 to your computer and use it in GitHub Desktop.
Git PR Toolkit - Helpers for a Smooth PR Workflow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ##### ───────────────────────── zsh git/PR toolkit ───────────────────────── ##### | |
| # ── helpers ────────────────────────────────────────────────────────────────── | |
| _slug() { | |
| print -r -- "$*" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//g' | |
| } | |
| _base_branch() { | |
| local b | |
| b="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)" | |
| b="${b#origin/}" | |
| [[ -n "$b" ]] && { print -r -- "$b"; return; } | |
| git show-ref --verify --quiet refs/heads/main && { print -r -- main; return; } | |
| git show-ref --verify --quiet refs/heads/master && { print -r -- master; return; } | |
| print -r -- main | |
| } | |
| _repo_slug() { | |
| local o r | |
| o="$(git config --get remote.origin.url)" || return 1 | |
| o="${o%.git}" | |
| if [[ "$o" == git@github.com:* ]]; then | |
| r="${o#git@github.com:}" | |
| elif [[ "$o" == https://github.com/* ]]; then | |
| r="${o#https://github.com/}" | |
| else | |
| return 1 | |
| fi | |
| print -r -- "$r" | |
| } | |
| _open_url() { | |
| command -v open >/dev/null && open "$1" \ | |
| || xdg-open "$1" >/dev/null 2>&1 \ | |
| || print -r -- "$1" | |
| } | |
| _gh_api() { | |
| [[ -z "$GITHUB_TOKEN" ]] && return 1 | |
| local method="$1" path="$2" data="$3" | |
| curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -X "$method" ${data:+-d "$data"} "https://api.github.com$path" | |
| } | |
| _json_pr_payload() { | |
| # args: title base head [body] | |
| local title="$1" base="$2" head="$3" body="$4" | |
| if command -v jq >/dev/null 2>&1; then | |
| jq -cn --arg t "$title" --arg b "$base" --arg h "$head" --arg bd "$body" \ | |
| '{title:$t, base:$b, head:$h, draft:true} + ( ($bd|length)>0 ? {body:$bd} : {} )' | |
| else | |
| printf '{"title":"%s","base":"%s","head":"%s","draft":true}' \ | |
| "${title//\"/\\\"}" "${base//\"/\\\"}" "${head//\"/\\\"}" | |
| fi | |
| } | |
| _pr_number_for_branch() { | |
| local repo branch owner json | |
| repo="$(_repo_slug)" || return 1 | |
| branch="$(git branch --show-current)" | |
| owner="${repo%%/*}" | |
| json="$(_gh_api GET "/repos/$repo/pulls?state=open&head=$owner:$branch")" || return 1 | |
| print -r -- "$json" | sed -nE 's/.*"number":[[:space:]]*([0-9]+).*/\1/p' | head -n1 | |
| } | |
| # helper: stage-all safely when user explicitly asked | |
| _stage_all() { | |
| git add -A || return $? | |
| } | |
| # helper: commit ONLY what is already staged | |
| _commit_staged_only() { | |
| local msg="$1" noverify="$2" signoff="$3" | |
| # bail if nothing staged | |
| git diff --cached --quiet && { | |
| echo "No staged changes to commit."; return 1; | |
| } | |
| local args=() | |
| [[ "$noverify" -eq 1 ]] && args+=("--no-verify") | |
| [[ "$signoff" -eq 1 ]] && args+=("--signoff") | |
| git commit -m "$msg" "${args[@]}" | |
| } | |
| _push_upstream() { | |
| git rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1 \ | |
| && git push || git push -u origin HEAD | |
| } | |
| _create_or_open_pr() { | |
| local title="$1" body="$2" base="$3" head="$4" | |
| local repo pr_json pr_url compare_url | |
| repo="$(_repo_slug)" || { | |
| echo "Pushed. No GitHub origin detected; skipping PR creation." | |
| return 0 | |
| } | |
| if [[ -n "$GITHUB_TOKEN" ]]; then | |
| local payload="$(_json_pr_payload "$title" "$base" "$head" "$body")" | |
| pr_json="$(_gh_api POST "/repos/$repo/pulls" "$payload" 2>/dev/null)" || true | |
| pr_url="$(print -r -- "$pr_json" | sed -nE 's/.*"html_url":[[:space:]]*"([^"]+)".*/\1/p')" | |
| if [[ -n "$pr_url" ]]; then | |
| echo "Draft PR created: $pr_url" | |
| _open_url "$pr_url" | |
| else | |
| compare_url="https://github.com/$repo/compare/$base...$head?expand=1" | |
| echo "Pushed. Could not auto-create PR." | |
| echo "Open compare: $compare_url" | |
| _open_url "$compare_url" | |
| fi | |
| else | |
| compare_url="https://github.com/$repo/compare/$base...$head?expand=1" | |
| echo "Pushed. Set \$GITHUB_TOKEN to auto-create PRs." | |
| echo "Open compare: $compare_url" | |
| _open_url "$compare_url" | |
| fi | |
| } | |
| # gbranch <new-branch-name> | |
| # - On main: ensure clean tree, ff-only pull, branch from origin/main, rebase to origin/main | |
| # - Not on main: require HEAD == origin/main or fail; branch from HEAD, rebase to origin/main | |
| gbranch() { | |
| local new="$1" | |
| [[ -z "$new" ]] && { echo "Usage: gbranch <new-branch-name>"; return 1; } | |
| # Helper: resolve base branch (main/master or origin HEAD) | |
| _gb__base() { | |
| local b | |
| b="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)" | |
| b="${b#origin/}" | |
| [[ -n "$b" ]] && { printf "%s" "$b"; return; } | |
| git show-ref --verify --quiet refs/heads/main && { printf "main"; return; } | |
| git show-ref --verify --quiet refs/heads/master && { printf "master"; return; } | |
| printf "main" | |
| } | |
| # Ensure we're in a git repo | |
| git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "Not a git repo."; return 1; } | |
| local base cur origin_base head_hash origin_base_hash | |
| base="$(_gb__base)" | |
| cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || return 1 | |
| # Get latest refs | |
| git fetch origin --prune || { echo "Failed to fetch origin."; return 1; } | |
| # Verify origin/base exists | |
| if ! git show-ref --verify --quiet "refs/remotes/origin/$base"; then | |
| echo "Remote branch origin/$base not found." | |
| return 1 | |
| fi | |
| origin_base="origin/$base" | |
| origin_base_hash="$(git rev-parse --verify "$origin_base")" || return 1 | |
| head_hash="$(git rev-parse --verify HEAD)" || return 1 | |
| if [[ "$cur" == "$base" ]]; then | |
| # On main/master: require clean tree before pulling | |
| if ! git diff --quiet || ! git diff --cached --quiet; then | |
| echo "Working tree not clean on $base. Commit/stash your changes first." | |
| return 1 | |
| fi | |
| # Fast-forward main | |
| git pull --ff-only || { echo "Failed to fast-forward $base."; return 1; } | |
| # Create from latest origin/main to be explicit | |
| if git show-ref --verify --quiet "refs/heads/$new"; then | |
| echo "Branch '$new' already exists." | |
| return 1 | |
| fi | |
| git switch -c "$new" "$origin_base" || return 1 | |
| # Safety: rebase to origin/main (should be no-op) | |
| git rebase --rebase-merges --autostash "$origin_base" || return 1 | |
| echo "✅ Created and switched to '$new' based on $origin_base." | |
| return 0 | |
| else | |
| # Not on main: require exact equality with origin/main | |
| if [[ "$head_hash" != "$origin_base_hash" ]]; then | |
| echo "❌ Current branch '$cur' is not identical to $origin_base." | |
| echo " HEAD: $head_hash" | |
| echo " $origin_base: $origin_base_hash" | |
| echo " Move to '$base' and sync (e.g., 'git switch $base && git pull --ff-only')," | |
| echo " or rebase your branch to $origin_base before running gbranch." | |
| return 1 | |
| fi | |
| # Equal to origin/main — allowed | |
| if git show-ref --verify --quiet "refs/heads/$new"; then | |
| echo "Branch '$new' already exists." | |
| return 1 | |
| fi | |
| git switch -c "$new" || return 1 | |
| # Safety: rebase to origin/main (no-op) | |
| git rebase --rebase-merges --autostash "$origin_base" || return 1 | |
| echo "✅ Created and switched to '$new' (from $cur @ $origin_base)." | |
| return 0 | |
| fi | |
| } | |
| # ── gship: create branch (from base) OR ship staged work on feature branch ──── | |
| # Usage: | |
| # gship "Title" [Body...] | |
| # gship --all "Title" [Body...] # stage *everything* first | |
| # | |
| # Behavior: | |
| # - If you're on base (main/master): we assume you're starting new work. | |
| # 1. sync base | |
| # 2. make new feat/branch | |
| # 3. stage all | |
| # 4. commit | |
| # 5. push + open draft PR | |
| # | |
| # - If you're on a feature branch: | |
| # - default: commit ONLY staged changes | |
| # - --all: stage everything first, then commit | |
| # | |
| gship() { | |
| local add_all=0 | |
| while [[ "$1" == --* ]]; do | |
| case "$1" in | |
| --all) add_all=1 ;; | |
| *) echo "Unknown flag: $1" ;; | |
| esac | |
| shift | |
| done | |
| local title="$1"; shift | |
| local body="$*" | |
| [[ -z "$title" ]] && { echo "Usage: gship [--all] \"Title\" [Body]"; return 1; } | |
| local base cur ts name newbranch | |
| base="$(_base_branch)" || return $? | |
| cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || return $? | |
| git fetch origin --prune || return $? | |
| if [[ "$cur" == "$base" ]]; then | |
| # starting new work off base | |
| git switch "$base" || return $? | |
| git pull --ff-only || return $? | |
| ts="$(date +%Y%m%d-%H%M)" | |
| name="$(_slug "$title")" | |
| newbranch="feat/${name:-change}-${ts}" | |
| git switch -c "$newbranch" || return $? | |
| # always stage-all on FIRST ship-from-main. This matches your habit. | |
| git add -A || return $? | |
| git commit -m "$title" || return $? | |
| _push_upstream || return $? | |
| _create_or_open_pr "$title" "$body" "$base" "$newbranch" | |
| return 0 | |
| fi | |
| # we're on a feature branch | |
| # if user said --all, stage all first; otherwise leave the index alone | |
| if [[ "$add_all" -eq 1 ]]; then | |
| _stage_all || return $? | |
| fi | |
| # commit ONLY what's staged | |
| _commit_staged_only "$title" 0 0 || return $? | |
| # push branch | |
| _push_upstream || return $? | |
| # Try to open (or create) a PR if one doesn't exist | |
| local prnum | |
| prnum="$(_pr_number_for_branch)" || true | |
| if [[ -n "$prnum" ]]; then | |
| # PR already exists, just show it | |
| local repo pr_url | |
| repo="$(_repo_slug)" || return 0 | |
| pr_url="https://github.com/$repo/pull/$prnum" | |
| echo "Updated branch & pushed. PR already exists: $pr_url" | |
| _open_url "$pr_url" | |
| else | |
| # create new draft PR | |
| _create_or_open_pr "$title" "$body" "$base" "$cur" | |
| fi | |
| } | |
| # ── gfix: follow-up commit/push helper ──────────────────────────────────────── | |
| # gfix [-a|--amend] [--no-push] [-n|--no-verify] [-S|--signoff] [-f|--force-main] "msg" | |
| gfix() { | |
| local push=1 amend=0 noverify=0 signoff=0 force_main=0 | |
| while [[ "$1" == -* ]]; do | |
| case "$1" in | |
| -a|--amend) amend=1 ;; | |
| --no-push) push=0 ;; | |
| -n|--no-verify) noverify=1 ;; | |
| -S|--signoff) signoff=1 ;; | |
| -f|--force-main) force_main=1 ;; | |
| --) shift; break ;; | |
| *) break ;; | |
| esac | |
| shift | |
| done | |
| local msg="$*" | |
| local cur base | |
| cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || { echo "Not a git repo."; return 1; } | |
| base="$(_base_branch)" | |
| if [[ "$cur" == "$base" && "$force_main" -ne 1 ]]; then | |
| echo "You're on $base. Use gship to branch+PR, or --force-main to commit here." | |
| return 1 | |
| fi | |
| if [[ "$amend" -eq 0 && -z "$msg" ]]; then | |
| vared -p "Commit message [chore: quick fix]: " -c msg | |
| [[ -z "$msg" ]] && msg="chore: quick fix" | |
| fi | |
| git add -A || return $? | |
| local args=() | |
| [[ "$noverify" -eq 1 ]] && args+=("--no-verify") | |
| [[ "$signoff" -eq 1 ]] && args+=("--signoff") | |
| if [[ "$amend" -eq 1 ]]; then | |
| if [[ -n "$msg" ]]; then | |
| git commit --amend -m "$msg" "${args[@]}" || { echo "Nothing to amend."; return 1; } | |
| else | |
| git commit --amend --no-edit "${args[@]}" || { echo "Nothing to amend."; return 1; } | |
| fi | |
| else | |
| git diff --cached --quiet && { echo "No staged changes to commit."; return 1; } | |
| git commit -m "$msg" "${args[@]}" || return $? | |
| fi | |
| if [[ "$push" -eq 1 ]]; then | |
| _push_upstream | |
| fi | |
| echo "✓ $( [[ "$amend" -eq 1 ]] && echo 'Amended' || echo 'Committed' ) on $cur$( [[ "$push" -eq 1 ]] && echo ' and pushed')." | |
| } | |
| alias gf='gfix' | |
| # ── gmerge: squash merge & cleanup ──────────────────────────────────────────── | |
| gmerge() { | |
| local base branch repo prnum | |
| base="$(_base_branch)" | |
| branch="$(git branch --show-current)" | |
| repo="$(_repo_slug)" || true | |
| [[ "$branch" == "$base" ]] && { echo "You're on $base; switch to a feature branch."; return 1; } | |
| if [[ -n "$GITHUB_TOKEN" && -n "$repo" ]]; then | |
| prnum="$(_pr_number_for_branch)" || true | |
| if [[ -n "$prnum" ]]; then | |
| echo "Attempting PR squash merge via API (#$prnum)…" | |
| _gh_api PUT "/repos/$repo/pulls/$prnum/merge" '{"merge_method":"squash"}' >/dev/null && { | |
| echo "PR merged. Deleting remote branch…" | |
| _gh_api DELETE "/repos/$repo/git/refs/heads/$branch" >/dev/null || true | |
| git switch "$base" && git pull --ff-only | |
| return 0 | |
| } | |
| echo "API merge failed; falling back to local squash." | |
| fi | |
| fi | |
| echo "Local squash merge into $base…" | |
| git switch "$base" || return $? | |
| git pull --ff-only || return $? | |
| git merge --squash "$branch" || return $? | |
| git commit -m "Squash merge $branch" || return $? | |
| git push || return $? | |
| git push origin --delete "$branch" 2>/dev/null || true | |
| git branch -D "$branch" 2>/dev/null || true | |
| echo "Merged locally and cleaned up." | |
| } | |
| # ── gmain: sync base branch fast ────────────────────────────────────────────── | |
| gmain() { | |
| local base="$(_base_branch)" | |
| git switch "$base" && git pull --ff-only && echo "➜ on $base (synced)" | |
| } | |
| # ── gprune: delete merged local branches ───────────────────────────────────── | |
| gprune() { | |
| local base="$(_base_branch)" | |
| git fetch --prune | |
| git branch --merged "$base" | grep -vE "^\*|$base" | xargs -r git branch -d | |
| echo "Cleaned merged branches." | |
| } | |
| # ── guard: refuse commits directly on main/master unless forced ───────────── | |
| ginstall-hook-no-main-commit() { | |
| mkdir -p .git/hooks | |
| cat > .git/hooks/pre-commit <<'HOOK' | |
| #!/usr/bin/env bash | |
| b="$(git rev-parse --abbrev-ref HEAD)" | |
| if [ "$b" = "main" ] || [ "$b" = "master" ]; then | |
| echo "pre-commit: refusing to commit directly to $b." | |
| echo "Use: gship \"desc\" to branch+PR." | |
| exit 1 | |
| fi | |
| HOOK | |
| chmod +x .git/hooks/pre-commit | |
| echo "Installed pre-commit guard." | |
| } | |
| # ── convenience: reload zsh quickly ────────────────────────────────────────── | |
| reload-zsh() { | |
| source ~/.zshrc && echo "🔁 reloaded ~/.zshrc (zsh $ZSH_VERSION)" | |
| } | |
| alias rz='reload-zsh' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment