Created
February 23, 2026 02:38
-
-
Save evelynmitchell/0266f4b0c53fc6289b22cf68e77009b4 to your computer and use it in GitHub Desktop.
Claude hook to make new branch before starting to plan
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
| #!/usr/bin/env bash | |
| # Hook: ensure-fresh-branch.sh | |
| # Fires on SubagentStart(Plan). | |
| # Ensures we're on a fresh branch before starting any new task planning. | |
| # save as .claude/hooks/ensure-fresh-branch.sh | |
| # | |
| # Behavior: | |
| # - Non-Plan subagent: no-op (exit 0 immediately) | |
| # - On master/main: fetches latest, creates claude/task-<slug> branch | |
| # - On a branch with a merged PR: switches to master, creates new branch | |
| # - On a clean unmerged branch: passes through (no-op) | |
| # | |
| # Dependencies: jq (JSON parsing), gh (GitHub CLI, merged-PR detection) | |
| # Output: When branch changes occur, emits JSON with additionalContext | |
| # describing the branch state; on no-op paths emits nothing. | |
| set -euo pipefail | |
| # Read stdin JSON and filter: only act on Plan subagents | |
| input=$(cat) | |
| if ! command -v jq >/dev/null 2>&1; then | |
| echo "jq not found — cannot parse task JSON. Install jq or remove this hook." >&2 | |
| exit 2 | |
| fi | |
| agent_type=$(echo "$input" | jq -r '.agent_type // .tool_input.subagent_type // empty' 2>/dev/null) | |
| [ "$agent_type" != "Plan" ] && exit 0 | |
| # Not a git repo? Nothing to do. | |
| git rev-parse --git-dir >/dev/null 2>&1 || exit 0 | |
| # gh CLI required for merged-PR detection | |
| if ! command -v gh >/dev/null 2>&1; then | |
| echo "gh CLI not found — cannot check for merged PRs. Install gh or remove this hook." >&2 | |
| exit 2 | |
| fi | |
| branch=$(git branch --show-current 2>/dev/null) | |
| if [ -z "$branch" ]; then | |
| echo "[ensure-fresh-branch] Detached HEAD (no current branch); skipping branch hygiene checks." >&2 | |
| exit 0 | |
| fi | |
| # Determine the default branch (prefer repo's configured default) | |
| default_branch="master" | |
| if origin_head=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null); then | |
| origin_head="${origin_head#origin/}" | |
| if [ -n "$origin_head" ]; then | |
| default_branch="$origin_head" | |
| fi | |
| else | |
| # Fallback: check both local and remote refs for main vs master | |
| has_main=false | |
| if git show-ref --verify --quiet refs/heads/main 2>/dev/null || \ | |
| git show-ref --verify --quiet refs/remotes/origin/main 2>/dev/null; then | |
| has_main=true | |
| fi | |
| has_master=false | |
| if git show-ref --verify --quiet refs/heads/master 2>/dev/null || \ | |
| git show-ref --verify --quiet refs/remotes/origin/master 2>/dev/null; then | |
| has_master=true | |
| fi | |
| if [ "$has_main" = true ] && [ "$has_master" = false ]; then | |
| default_branch="main" | |
| fi | |
| fi | |
| # Generate a slug from timestamp (human-readable, reasonably unique) | |
| generate_slug() { | |
| # Avoid %N (nanoseconds) — not supported by BSD date on macOS. | |
| # Use seconds-level timestamp plus PID to reduce collision likelihood. | |
| date +"%Y%m%d-%H%M%S-$$" | |
| } | |
| # Fetch and fast-forward the default branch. Returns 1 if ff-only merge fails. | |
| fetch_and_ff_default() { | |
| if ! git fetch origin "$default_branch" --quiet 2>/dev/null; then | |
| echo "Warning: could not fetch 'origin/$default_branch'. Proceeding with local state." >&2 | |
| fi | |
| if ! git merge --ff-only "origin/$default_branch" --quiet 2>/dev/null; then | |
| echo "Error: could not fast-forward '$default_branch' to 'origin/$default_branch'. Resolve the divergence and try again." >&2 | |
| return 1 | |
| fi | |
| } | |
| # Check if the current branch has a merged PR (prints PR number or empty) | |
| has_merged_pr() { | |
| gh pr list --state merged --head "$1" --json number -q '.[0].number' 2>/dev/null || true | |
| } | |
| # Case 1: Already on default branch — need to branch off | |
| if [ "$branch" = "$default_branch" ]; then | |
| fetch_and_ff_default || exit 2 | |
| new_branch="claude/task-$(generate_slug)" | |
| if ! git checkout -b "$new_branch" --quiet; then | |
| echo "Error: could not create branch '$new_branch'. A branch or file with that name may already exist." >&2 | |
| exit 2 | |
| fi | |
| jq -n --arg msg "Auto-created branch '$new_branch' from $default_branch. You were on $default_branch — never commit directly to it." \ | |
| '{additionalContext: $msg}' | |
| exit 0 | |
| fi | |
| # Case 2: On a branch that already has a merged PR — stale branch | |
| merged_pr=$(has_merged_pr "$branch") | |
| if [ -n "$merged_pr" ]; then | |
| # Check for uncommitted or untracked changes | |
| if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null || [ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then | |
| echo "Branch '$branch' has merged PR #$merged_pr but you have uncommitted or untracked changes. Stash, commit, or clean them first." >&2 | |
| exit 2 | |
| fi | |
| # Ensure the default branch exists locally before checking it out | |
| default_branch_created=false | |
| if git show-ref --verify --quiet "refs/heads/$default_branch" 2>/dev/null; then | |
| git checkout "$default_branch" --quiet | |
| elif git show-ref --verify --quiet "refs/remotes/origin/$default_branch" 2>/dev/null; then | |
| git checkout -b "$default_branch" "origin/$default_branch" --quiet | |
| default_branch_created=true | |
| else | |
| echo "Default branch '$default_branch' does not exist locally or on origin. Cannot create a fresh task branch." >&2 | |
| exit 2 | |
| fi | |
| if [ "$default_branch_created" = false ]; then | |
| if ! fetch_and_ff_default; then | |
| # Return user to their original branch on error | |
| if ! git checkout "$branch" --quiet 2>/dev/null; then | |
| echo "Failed to fast-forward '$default_branch', and could not switch back to '$branch'. You are now on '$default_branch'." >&2 | |
| else | |
| echo "Failed to fast-forward '$default_branch'. You have been returned to '$branch'." >&2 | |
| fi | |
| exit 2 | |
| fi | |
| fi | |
| new_branch="claude/task-$(generate_slug)" | |
| if ! git checkout -b "$new_branch" --quiet; then | |
| echo "Error: could not create branch '$new_branch'. A branch or file with that name may already exist." >&2 | |
| exit 2 | |
| fi | |
| jq -n --arg msg "Branch '$branch' already had merged PR #$merged_pr. Auto-created fresh branch '$new_branch' from $default_branch." \ | |
| '{additionalContext: $msg}' | |
| exit 0 | |
| fi | |
| # Case 3: On a working branch with no merged PR — all good | |
| exit 0 |
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
| Save the files as .claude/settings.json and .claude/hooks/ensure-fresh-branch.sh |
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
| { | |
| "hooks": { | |
| "SubagentStart": [ | |
| { | |
| "matcher": "Plan", | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": ".claude/hooks/ensure-fresh-branch.sh" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment