From 424e5d979bf47bd2c10256278d60e729875c7c19 Mon Sep 17 00:00:00 2001 From: sergiopaniego Date: Thu, 29 Oct 2026 14:00:00 +0100 Subject: [PATCH 1/4] Check the repo gh pr create runs in, in the pre-PR hook --- .claude/hooks/pre-pr-check.sh | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.claude/hooks/pre-pr-check.sh b/.claude/hooks/pre-pr-check.sh index e7f073add2..6be3cb6de2 100755 --- a/.claude/hooks/pre-pr-check.sh +++ b/.claude/hooks/pre-pr-check.sh @@ -13,21 +13,31 @@ if [[ "$COMMAND" != *"gh pr create"* ]]; then exit 0 fi -# Determine base branch (default: main) -BASE="main" -if echo "$COMMAND" | grep -qoP '(?<=--base\s)\S+'; then - BASE=$(echo "$COMMAND" | grep -oP '(?<=--base\s)\S+') +# Check the repo the command runs in: the target of a leading `cd`, else the session's cwd +REPO_DIR=$(echo "$COMMAND" | sed -nE 's/^[[:space:]]*cd[[:space:]]+([^;&|[:space:]]+).*/\1/p') +REPO_DIR="${REPO_DIR/#\~/$HOME}" +if [[ -z "$REPO_DIR" ]]; then + REPO_DIR=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) fi +REPO_DIR="${REPO_DIR:-.}" + +# Determine base branch (default: main) and the PR's head branch (default: the checked-out one). +# sed -E rather than grep -P, which macOS grep does not support. +BASE=$(echo "$COMMAND" | sed -nE 's/.*--base[[:space:]=]+([^[:space:]]+).*/\1/p') +BASE="${BASE:-main}" +HEAD_BRANCH=$(echo "$COMMAND" | sed -nE 's/.*--head[[:space:]=]+([^[:space:]]+).*/\1/p') +HEAD_BRANCH="${HEAD_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)}" # Fetch latest base and check freshness -git fetch origin "$BASE" --quiet 2>/dev/null || true -BEHIND=$(git rev-list --count HEAD.."origin/$BASE" 2>/dev/null || echo "?") +git -C "$REPO_DIR" fetch origin "$BASE" --quiet 2>/dev/null || true +BEHIND=$(git -C "$REPO_DIR" rev-list --count "$HEAD_BRANCH".."origin/$BASE" 2>/dev/null || echo "?") if [[ "$BEHIND" != "0" && "$BEHIND" != "?" ]]; then cat >&2 << EOF =================================================================== - PR BLOCKED: Branch is $BEHIND commit(s) behind $BASE + PR BLOCKED: Branch $HEAD_BRANCH is $BEHIND commit(s) behind $BASE + (checked in $REPO_DIR) =================================================================== Your PR will show "out of date with base branch" on GitHub. @@ -45,13 +55,12 @@ EOF exit 2 fi -# Check we're not on main/master -BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) -if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then +# Check we're not opening a PR from main/master +if [[ "$HEAD_BRANCH" == "main" || "$HEAD_BRANCH" == "master" ]]; then cat >&2 << EOF =================================================================== - PR BLOCKED: Cannot create PR from $BRANCH + PR BLOCKED: Cannot create PR from $HEAD_BRANCH =================================================================== Create a feature branch first: From f220dcd275261c3d8b6b783ea64e34054fdfa9ff Mon Sep 17 00:00:00 2001 From: sergiopaniego Date: Fri, 30 Oct 2026 09:35:41 +0100 Subject: [PATCH 2/4] Only parse the command line itself in the pre-PR hook --- .claude/hooks/pre-pr-check.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/pre-pr-check.sh b/.claude/hooks/pre-pr-check.sh index 6be3cb6de2..07363eebc9 100755 --- a/.claude/hooks/pre-pr-check.sh +++ b/.claude/hooks/pre-pr-check.sh @@ -13,8 +13,13 @@ if [[ "$COMMAND" != *"gh pr create"* ]]; then exit 0 fi +# Parse only the command line itself: join backslash continuations and keep the first line, since a heredoc +# body follows it. Quoted strings (the --title and --body values) are dropped before reading flags, so text +# inside them cannot match. +CMD_LINE=$(printf '%s\n' "$COMMAND" | sed -e ':a' -e '/\\$/N' -e 's/\\\n//' -e 'ta' | head -n1) + # Check the repo the command runs in: the target of a leading `cd`, else the session's cwd -REPO_DIR=$(echo "$COMMAND" | sed -nE 's/^[[:space:]]*cd[[:space:]]+([^;&|[:space:]]+).*/\1/p') +REPO_DIR=$(printf '%s\n' "$CMD_LINE" | sed -nE "s/^[[:space:]]*cd[[:space:]]+(\"([^\"]+)\"|'([^']+)'|([^;&|[:space:]]+)).*/\2\3\4/p") REPO_DIR="${REPO_DIR/#\~/$HOME}" if [[ -z "$REPO_DIR" ]]; then REPO_DIR=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) @@ -22,10 +27,11 @@ fi REPO_DIR="${REPO_DIR:-.}" # Determine base branch (default: main) and the PR's head branch (default: the checked-out one). -# sed -E rather than grep -P, which macOS grep does not support. -BASE=$(echo "$COMMAND" | sed -nE 's/.*--base[[:space:]=]+([^[:space:]]+).*/\1/p') +# grep -oE rather than grep -P, which macOS grep does not support. +FLAGS=$(printf '%s\n' "$CMD_LINE" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g") +BASE=$(printf '%s\n' "$FLAGS" | grep -oE -- '--base[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--base[[:space:]=]+//') BASE="${BASE:-main}" -HEAD_BRANCH=$(echo "$COMMAND" | sed -nE 's/.*--head[[:space:]=]+([^[:space:]]+).*/\1/p') +HEAD_BRANCH=$(printf '%s\n' "$FLAGS" | grep -oE -- '--head[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--head[[:space:]=]+//') HEAD_BRANCH="${HEAD_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)}" # Fetch latest base and check freshness From a6f61ea279017fc780ba4a822c3c767e01ce7eff Mon Sep 17 00:00:00 2001 From: sergiopaniego Date: Fri, 30 Oct 2026 10:30:55 +0100 Subject: [PATCH 3/4] Keep quoted --base and --head values when parsing the PR command --- .claude/hooks/pre-pr-check.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/pre-pr-check.sh b/.claude/hooks/pre-pr-check.sh index 07363eebc9..4166a8d84c 100755 --- a/.claude/hooks/pre-pr-check.sh +++ b/.claude/hooks/pre-pr-check.sh @@ -28,7 +28,8 @@ REPO_DIR="${REPO_DIR:-.}" # Determine base branch (default: main) and the PR's head branch (default: the checked-out one). # grep -oE rather than grep -P, which macOS grep does not support. -FLAGS=$(printf '%s\n' "$CMD_LINE" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g") +# Unquote --base/--head values first, then drop the remaining quoted strings (--title, --body). +FLAGS=$(printf '%s\n' "$CMD_LINE" | sed -E "s/(--(base|head)[[:space:]=]+)\"([^\"]*)\"/\1\3/g; s/(--(base|head)[[:space:]=]+)'([^']*)'/\1\3/g; s/\"[^\"]*\"//g; s/'[^']*'//g") BASE=$(printf '%s\n' "$FLAGS" | grep -oE -- '--base[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--base[[:space:]=]+//') BASE="${BASE:-main}" HEAD_BRANCH=$(printf '%s\n' "$FLAGS" | grep -oE -- '--head[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--head[[:space:]=]+//') From a13769083b4922f86bd88b5b05c2b0aa2c1d7cfb Mon Sep 17 00:00:00 2001 From: sergiopaniego Date: Fri, 30 Oct 2026 10:54:23 +0100 Subject: [PATCH 4/4] Parse the whole command in the pre-PR hook --- .claude/hooks/pre-pr-check.sh | 44 +++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/.claude/hooks/pre-pr-check.sh b/.claude/hooks/pre-pr-check.sh index 4166a8d84c..c449a0d1c6 100755 --- a/.claude/hooks/pre-pr-check.sh +++ b/.claude/hooks/pre-pr-check.sh @@ -13,28 +13,42 @@ if [[ "$COMMAND" != *"gh pr create"* ]]; then exit 0 fi -# Parse only the command line itself: join backslash continuations and keep the first line, since a heredoc -# body follows it. Quoted strings (the --title and --body values) are dropped before reading flags, so text -# inside them cannot match. -CMD_LINE=$(printf '%s\n' "$COMMAND" | sed -e ':a' -e '/\\$/N' -e 's/\\\n//' -e 'ta' | head -n1) +# Parse the command as one line, with backslash continuations joined. Quoted strings (the --title and --body +# values, including a heredoc inside "$(...)") and a bare heredoc are dropped before reading flags, so text inside +# them cannot match. --base/--head values are unquoted first so they survive. +ONE_LINE=$(printf '%s\n' "$COMMAND" | sed -e ':a' -e '/\\$/N' -e 's/\\\n//' -e 'ta' | tr '\n' ' ') +FLAGS=$(printf '%s\n' "$ONE_LINE" | sed -E "s/((--base|-B|--head|-H)[[:space:]=]+)\"([^\"]*)\"/\1\3/g; s/((--base|-B|--head|-H)[[:space:]=]+)'([^']*)'/\1\3/g; s/\"[^\"]*\"//g; s/'[^']*'//g; s/<<.*//") + +# Only a command that runs gh pr create, not one that mentions it (a commit message, a grep) +RUNS_PR_CREATE='(^|[;&|(])[[:space:]]*gh[[:space:]]+pr[[:space:]]+create' +if ! [[ "$FLAGS" =~ $RUNS_PR_CREATE ]]; then + exit 0 +fi -# Check the repo the command runs in: the target of a leading `cd`, else the session's cwd -REPO_DIR=$(printf '%s\n' "$CMD_LINE" | sed -nE "s/^[[:space:]]*cd[[:space:]]+(\"([^\"]+)\"|'([^']+)'|([^;&|[:space:]]+)).*/\2\3\4/p") +# Check the repo the command runs in: the target of the last `cd` before gh pr create, else the session's cwd. +# A relative target is resolved against the session's cwd. +CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) +BEFORE="; ${ONE_LINE%%gh pr create*}" +REPO_DIR=$(printf '%s\n' "$BEFORE" | sed -nE "s/.*[;&|][[:space:]]*cd[[:space:]]+(\"([^\"]+)\"|'([^']+)'|([^;&|[:space:]]+)).*/\2\3\4/p") REPO_DIR="${REPO_DIR/#\~/$HOME}" -if [[ -z "$REPO_DIR" ]]; then - REPO_DIR=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) +if [[ -n "$REPO_DIR" && "$REPO_DIR" != /* && -n "$CWD" ]]; then + REPO_DIR="$CWD/$REPO_DIR" fi -REPO_DIR="${REPO_DIR:-.}" +REPO_DIR="${REPO_DIR:-${CWD:-.}}" -# Determine base branch (default: main) and the PR's head branch (default: the checked-out one). -# grep -oE rather than grep -P, which macOS grep does not support. -# Unquote --base/--head values first, then drop the remaining quoted strings (--title, --body). -FLAGS=$(printf '%s\n' "$CMD_LINE" | sed -E "s/(--(base|head)[[:space:]=]+)\"([^\"]*)\"/\1\3/g; s/(--(base|head)[[:space:]=]+)'([^']*)'/\1\3/g; s/\"[^\"]*\"//g; s/'[^']*'//g") -BASE=$(printf '%s\n' "$FLAGS" | grep -oE -- '--base[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--base[[:space:]=]+//') +# Determine base branch (default: main) and the PR's head branch (default: the checked-out one). gh uses the last +# value of a repeated flag, and --head may be owner:branch. grep -oE rather than grep -P, which macOS grep lacks. +BASE=$(printf '%s\n' "$FLAGS" | grep -oE -- '(^|[[:space:]])(--base|-B)[[:space:]=]+[^[:space:];&|)]+' | tail -n1 | sed -E 's/^[[:space:]]*(--base|-B)[[:space:]=]+//') BASE="${BASE:-main}" -HEAD_BRANCH=$(printf '%s\n' "$FLAGS" | grep -oE -- '--head[[:space:]=]+[^[:space:]]+' | head -n1 | sed -E 's/^--head[[:space:]=]+//') +HEAD_BRANCH=$(printf '%s\n' "$FLAGS" | grep -oE -- '(^|[[:space:]])(--head|-H)[[:space:]=]+[^[:space:];&|)]+' | tail -n1 | sed -E 's/^[[:space:]]*(--head|-H)[[:space:]=]+//') +HEAD_BRANCH="${HEAD_BRANCH#*:}" HEAD_BRANCH="${HEAD_BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null)}" +# A value that looks like an option would be passed to git as one, so skip the check instead +if [[ "$BASE" == -* || "$HEAD_BRANCH" == -* ]]; then + exit 0 +fi + # Fetch latest base and check freshness git -C "$REPO_DIR" fetch origin "$BASE" --quiet 2>/dev/null || true BEHIND=$(git -C "$REPO_DIR" rev-list --count "$HEAD_BRANCH".."origin/$BASE" 2>/dev/null || echo "?")