|
#!/usr/bin/env bash |
|
# git-prlog: Git log with expanded PR commits |
|
# For squash-merged PRs, shows the original PR commits indented under each main commit |
|
# https://gist.github.com/darcyparker/6eb3625fa02e1b072d65d5b53356aac1 |
|
# |
|
# Usage: git prlog [git-log-options] |
|
# git prls [git-log-options] (short form, via symlink or alias) |
|
# |
|
# Examples: |
|
# git prlog -10 # Last 10 commits with PR details |
|
# git prlog main~5..main # Range of commits |
|
# git prls # Short form output |
|
|
|
# Detect if we're in short mode (prls) or long mode (prlog) |
|
SCRIPT_NAME=$(basename "$0") |
|
SHORT_MODE=false |
|
if [[ "$SCRIPT_NAME" == "git-prls" ]]; then |
|
SHORT_MODE=true |
|
fi |
|
|
|
# Parse options - check for our custom flags first |
|
GIT_ARGS=() |
|
USE_PAGER=true |
|
DEBUG_MODE=false |
|
# Max nesting levels of PRs to expand. 1 = only the PR merged to main |
|
# (original behavior); 2 = also expand any sub-PRs inside that PR; etc. |
|
MAX_DEPTH=2 |
|
# Visual indent stops growing past this many nesting levels. |
|
INDENT_CAP=3 |
|
# When true, fall back to `gh api .../commits/{sha}/pulls` if the commit |
|
# subject doesn't contain a (#N) marker. Off by default because the lookup |
|
# is one network round-trip per non-matching commit, which dominates runtime |
|
# on PRs full of leaf commits that aren't associated with their own PR. |
|
API_FALLBACK=false |
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--short | -s) |
|
SHORT_MODE=true |
|
shift |
|
;; |
|
--long | -l) |
|
SHORT_MODE=false |
|
shift |
|
;; |
|
--no-pager) |
|
USE_PAGER=false |
|
shift |
|
;; |
|
--debug) |
|
DEBUG_MODE=true |
|
USE_PAGER=false |
|
shift |
|
;; |
|
--max-depth=*) |
|
MAX_DEPTH="${1#*=}" |
|
shift |
|
;; |
|
--max-depth) |
|
MAX_DEPTH="$2" |
|
shift 2 |
|
;; |
|
--api-fallback) |
|
API_FALLBACK=true |
|
shift |
|
;; |
|
*) |
|
GIT_ARGS+=("$1") |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
# Validate MAX_DEPTH (non-negative integer) |
|
if ! [[ "$MAX_DEPTH" =~ ^[0-9]+$ ]]; then |
|
echo "git-prlog: --max-depth must be a non-negative integer (got: $MAX_DEPTH)" >&2 |
|
exit 2 |
|
fi |
|
|
|
# Use git's configured pager (respects core.pager, $GIT_PAGER, $PAGER) |
|
GIT_PAGER=$(git var GIT_PAGER 2>/dev/null || echo "${PAGER:-less}") |
|
|
|
# Only use pager if stdout is a terminal |
|
if [[ ! -t 1 ]]; then |
|
USE_PAGER=false |
|
fi |
|
|
|
# Colors (matching git's color scheme) |
|
C_YELLOW='\033[33m' |
|
C_RED='\033[31m' |
|
C_BLUE='\033[34m' |
|
C_CYAN='\033[36m' |
|
C_GREEN='\033[32m' |
|
C_DIM='\033[2m' |
|
C_RESET='\033[0m' |
|
|
|
# Disable colors if not using pager and not a terminal |
|
# (pagers like delta/less -R handle colors fine) |
|
if [[ ! -t 1 ]] && [[ "$USE_PAGER" == "false" ]]; then |
|
C_YELLOW='' |
|
C_RED='' |
|
C_BLUE='' |
|
C_CYAN='' |
|
C_GREEN='' |
|
C_DIM='' |
|
C_RESET='' |
|
fi |
|
|
|
# Check if gh is available and authenticated (called once at startup, before any pipes) |
|
gh_available() { |
|
command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1 |
|
} |
|
|
|
# Check gh availability early, before entering any pipe context |
|
GH_AVAILABLE="false" |
|
if gh_available; then |
|
GH_AVAILABLE="true" |
|
fi |
|
|
|
# Debug output |
|
if $DEBUG_MODE; then |
|
echo "=== DEBUG INFO ===" >&2 |
|
echo "GH_AVAILABLE: $GH_AVAILABLE" >&2 |
|
echo "SHORT_MODE: $SHORT_MODE" >&2 |
|
echo "USE_PAGER: $USE_PAGER" >&2 |
|
echo "MAX_DEPTH: $MAX_DEPTH" >&2 |
|
echo "API_FALLBACK: $API_FALLBACK" >&2 |
|
echo "GIT_PAGER: $(git var GIT_PAGER 2>/dev/null)" >&2 |
|
echo "GIT_ARGS: ${GIT_ARGS[*]}" >&2 |
|
echo "stdout is tty: $([[ -t 1 ]] && echo yes || echo no)" >&2 |
|
echo "gh path: $(command -v gh)" >&2 |
|
echo "=================" >&2 |
|
fi |
|
|
|
# Caches: PR number -> commits payload, and commit SHA -> PR number ("" = none) |
|
# Avoids duplicate gh API calls when the same PR/commit appears in multiple |
|
# branches of the recursion (cherry-picks, repeated sub-PR references). |
|
declare -A PR_COMMITS_CACHE |
|
declare -A COMMIT_PR_CACHE |
|
# Track PRs currently in the recursion stack to short-circuit cycles. |
|
declare -A EXPANDING_PRS |
|
|
|
# Extract PR number from commit message |
|
# Looks for patterns like "(#123)" or "Merge pull request #123" |
|
extract_pr_number() { |
|
local message="$1" |
|
# Match (#123) at end of first line (squash merge default) |
|
if [[ "$message" =~ \(#([0-9]+)\) ]]; then |
|
echo "${BASH_REMATCH[1]}" |
|
return |
|
fi |
|
# Match "Merge pull request #123" (merge commit) |
|
if [[ "$message" =~ Merge\ pull\ request\ #([0-9]+) ]]; then |
|
echo "${BASH_REMATCH[1]}" |
|
return |
|
fi |
|
} |
|
|
|
# Look up PR number for a commit via gh API |
|
# Opt-in fallback (--api-fallback) for when extract_pr_number can't parse the |
|
# subject (e.g., rebase merges or commits predating the (#N) suffix convention). |
|
# Returns empty when the commit isn't associated with any PR. |
|
get_pr_number_from_commit() { |
|
local sha="$1" |
|
# Cache hit (including cached-empty results) |
|
if [[ -n "${COMMIT_PR_CACHE[$sha]+x}" ]]; then |
|
printf '%s' "${COMMIT_PR_CACHE[$sha]}" |
|
return |
|
fi |
|
local result |
|
result=$(gh api "repos/{owner}/{repo}/commits/${sha}/pulls" \ |
|
--jq '.[0].number // empty' \ |
|
2>/dev/null || true) |
|
COMMIT_PR_CACHE[$sha]="$result" |
|
printf '%s' "$result" |
|
} |
|
|
|
# Find the PR that introduced a commit. Tries the cheap subject heuristic |
|
# first; falls back to the gh API lookup keyed by SHA only when the user |
|
# opted in with --api-fallback (one round-trip per miss adds up fast). |
|
find_pr_for_commit() { |
|
local sha="$1" |
|
local subject="$2" |
|
local pr |
|
pr=$(extract_pr_number "$subject") |
|
if [[ -z "$pr" && "$API_FALLBACK" == "true" ]]; then |
|
pr=$(get_pr_number_from_commit "$sha") |
|
fi |
|
printf '%s' "$pr" |
|
} |
|
|
|
# Get PR commits using gh API |
|
get_pr_commits() { |
|
local pr_number="$1" |
|
if [[ -n "${PR_COMMITS_CACHE[$pr_number]+x}" ]]; then |
|
printf '%s' "${PR_COMMITS_CACHE[$pr_number]}" |
|
return |
|
fi |
|
# Returns: sha|author_name|date|message (one per line) |
|
local result |
|
result=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}/commits" \ |
|
--jq '.[] | "\(.sha)|\(.commit.author.name)|\(.commit.author.date)|\(.commit.message | split("\n")[0])"' \ |
|
2>/dev/null || true) |
|
PR_COMMITS_CACHE[$pr_number]="$result" |
|
printf '%s' "$result" |
|
} |
|
|
|
# Format a single PR commit (short form) |
|
format_pr_commit_short() { |
|
local sha="$1" |
|
local author="$2" |
|
local message="$3" |
|
local prefix="$4" |
|
local indent="$5" |
|
local short_sha="${sha:0:7}" |
|
printf "%s${C_DIM}%s${C_RESET} ${C_CYAN}%s${C_RESET} %s ${C_BLUE}[%s]${C_RESET}\n" \ |
|
"$indent" "$prefix" "$short_sha" "$message" "$author" |
|
} |
|
|
|
# Format a single PR commit (long form) |
|
format_pr_commit_long() { |
|
local sha="$1" |
|
local author="$2" |
|
local date="$3" |
|
local message="$4" |
|
local indent="$5" |
|
local formatted_date |
|
formatted_date=$(date -d "$date" "+%a %b %d %H:%M:%S %Y %z" 2>/dev/null || echo "$date") |
|
printf "%s${C_DIM}│${C_RESET} ${C_CYAN}commit %s${C_RESET}\n" "$indent" "$sha" |
|
printf "%s${C_DIM}│${C_RESET} Author: %s\n" "$indent" "$author" |
|
printf "%s${C_DIM}│${C_RESET} Date: %s\n" "$indent" "$formatted_date" |
|
printf "%s${C_DIM}│${C_RESET}\n" "$indent" |
|
printf "%s${C_DIM}│${C_RESET} %s\n" "$indent" "$message" |
|
printf "%s${C_DIM}│${C_RESET}\n" "$indent" |
|
} |
|
|
|
# Recursively expand a PR's commits, optionally descending into sub-PRs. |
|
# Args: |
|
# pr_number: PR to expand |
|
# depth: 1 for the PR merged into main, 2 for sub-PRs of that PR, etc. |
|
# Recursion stops when depth >= MAX_DEPTH. Visual indent stops growing past |
|
# INDENT_CAP nesting levels so deep trees stay readable. |
|
expand_pr_commits() { |
|
local pr_number="$1" |
|
local depth="$2" |
|
|
|
# Cycle guard: skip if this PR is already on the recursion stack |
|
if [[ -n "${EXPANDING_PRS[$pr_number]+x}" ]]; then |
|
return |
|
fi |
|
EXPANDING_PRS[$pr_number]=1 |
|
|
|
local pr_commits |
|
pr_commits=$(get_pr_commits "$pr_number") |
|
if [[ -z "$pr_commits" ]]; then |
|
unset 'EXPANDING_PRS[$pr_number]' |
|
return |
|
fi |
|
|
|
# Compute leading indent: 2 spaces per visible nesting level, capped. |
|
local visual_depth=$((depth < INDENT_CAP ? depth : INDENT_CAP)) |
|
local indent="" |
|
local k |
|
for ((k = 0; k < visual_depth; k++)); do |
|
indent+=" " |
|
done |
|
|
|
local commit_count |
|
commit_count=$(echo "$pr_commits" | wc -l) |
|
|
|
if $SHORT_MODE; then |
|
local i=0 |
|
local pr_sha pr_author pr_date pr_message |
|
while IFS='|' read -r pr_sha pr_author pr_date pr_message; do |
|
[[ -z "$pr_sha" ]] && continue |
|
((i++)) || true |
|
local prefix="├─" |
|
[[ $i -eq $commit_count ]] && prefix="└─" |
|
format_pr_commit_short "$pr_sha" "$pr_author" "$pr_message" "$prefix" "$indent" |
|
|
|
if ((depth < MAX_DEPTH)); then |
|
local sub_pr |
|
sub_pr=$(find_pr_for_commit "$pr_sha" "$pr_message") |
|
if [[ -n "$sub_pr" && "$sub_pr" != "$pr_number" ]]; then |
|
expand_pr_commits "$sub_pr" "$((depth + 1))" |
|
fi |
|
fi |
|
done <<<"$pr_commits" |
|
else |
|
printf "%s${C_DIM}┌─ PR #%s commits:${C_RESET}\n" "$indent" "$pr_number" |
|
local pr_sha pr_author pr_date pr_message |
|
while IFS='|' read -r pr_sha pr_author pr_date pr_message; do |
|
[[ -z "$pr_sha" ]] && continue |
|
format_pr_commit_long "$pr_sha" "$pr_author" "$pr_date" "$pr_message" "$indent" |
|
|
|
if ((depth < MAX_DEPTH)); then |
|
local sub_pr |
|
sub_pr=$(find_pr_for_commit "$pr_sha" "$pr_message") |
|
if [[ -n "$sub_pr" && "$sub_pr" != "$pr_number" ]]; then |
|
expand_pr_commits "$sub_pr" "$((depth + 1))" |
|
fi |
|
fi |
|
done <<<"$pr_commits" |
|
printf "%s${C_DIM}└─────────────────${C_RESET}\n" "$indent" |
|
# Trailing blank line only after the outermost box, to separate main commits |
|
[[ "$depth" -eq 1 ]] && printf "\n" |
|
fi |
|
|
|
unset 'EXPANDING_PRS[$pr_number]' |
|
} |
|
|
|
# Process a single commit |
|
process_commit() { |
|
local hash="$1" |
|
local short_hash="$2" |
|
local author="$3" |
|
local date="$4" |
|
local decorations="$5" |
|
local subject="$6" |
|
local gh_ok="$7" |
|
|
|
if $SHORT_MODE; then |
|
# Short format (like git ls) |
|
printf "${C_YELLOW}%s${C_RED}%s${C_RESET} %s ${C_BLUE}[%s]${C_RESET}\n" \ |
|
"$short_hash" "$decorations" "$subject" "$author" |
|
else |
|
# Long format (like git log) |
|
printf "${C_YELLOW}commit %s${C_RED}%s${C_RESET}\n" "$hash" "$decorations" |
|
printf "Author: %s\n" "$author" |
|
local formatted_date |
|
formatted_date=$(date -d "$date" "+%a %b %d %H:%M:%S %Y %z" 2>/dev/null || echo "$date") |
|
printf "Date: %s\n" "$formatted_date" |
|
printf "\n %s\n\n" "$subject" |
|
fi |
|
|
|
if [[ "$gh_ok" == "true" && "$MAX_DEPTH" -ge 1 ]]; then |
|
local pr_number |
|
pr_number=$(find_pr_for_commit "$hash" "$subject") |
|
if [[ -n "$pr_number" ]]; then |
|
expand_pr_commits "$pr_number" 1 |
|
fi |
|
fi |
|
} |
|
|
|
# Main processing |
|
main() { |
|
# Use pre-computed GH_AVAILABLE (checked before entering pipe context) |
|
local gh_ok="$GH_AVAILABLE" |
|
if [[ "$gh_ok" != "true" ]]; then |
|
echo -e "${C_DIM}# Note: gh not available or not authenticated; PR commits won't be expanded${C_RESET}" >&2 |
|
fi |
|
|
|
# Use NUL as record separator to handle all edge cases |
|
# Format: hash<NUL>short_hash<NUL>author<NUL>date<NUL>decorations<NUL>subject<NUL> |
|
local log_format="%H%x00%h%x00%an%x00%aI%x00%d%x00%s%x00" |
|
|
|
# Read commits separated by NUL characters |
|
local hash short_hash author date decorations subject |
|
while IFS= read -r -d '' hash && |
|
IFS= read -r -d '' short_hash && |
|
IFS= read -r -d '' author && |
|
IFS= read -r -d '' date && |
|
IFS= read -r -d '' decorations && |
|
IFS= read -r -d '' subject; do |
|
# git's --pretty=format: inserts an LF between entries, which lands at the |
|
# front of the first field of every commit after the first. Strip it. |
|
hash="${hash#$'\n'}" |
|
process_commit "$hash" "$short_hash" "$author" "$date" "$decorations" "$subject" "$gh_ok" |
|
done < <(git log --pretty=format:"$log_format" "${GIT_ARGS[@]}") |
|
} |
|
|
|
# Run main, optionally through pager |
|
if [[ "$USE_PAGER" == "true" ]]; then |
|
main | $GIT_PAGER |
|
else |
|
main |
|
fi |