Last active
May 26, 2026 07:24
-
-
Save isuke01/befc68905844b77abcf12f214e0818c2 to your computer and use it in GitHub Desktop.
Auto create a branch from the current branch and push it to origin with confirmation
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 | |
| # git-b — Interactively create a new branch from origin/stage | |
| # Usage: | |
| # git-b | |
| # Prompts for prefix, suffix (optional), and message. | |
| # Created branch: <prefix>/<suffix>/<message> or <prefix>/<message> | |
| # Names are auto-sanitized to URL-safe lowercase, e.g. "My Feature" → "my-feature" | |
| # | |
| # Flow: | |
| # 1) Check working tree (offer stash if dirty). | |
| # 2) Ensure we're on stage (switch if needed). | |
| # 3) Pull from origin/stage. | |
| # 4) Prompt: Prefix, Suffix, Message | |
| # 5) Confirm and create branch (all sanitized) | |
| set -euo pipefail | |
| # --- Ensure Git repo --- | |
| if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| echo "❌ Error: not a git repository" >&2 | |
| exit 1 | |
| fi | |
| # --- Colors --- | |
| if [ -t 1 ]; then | |
| YELLOW=$(printf '\033[1;33m') | |
| GREEN=$(printf '\033[1;32m') | |
| RED=$(printf '\033[1;31m') | |
| BLUE=$(printf '\033[0;36m') | |
| RESET=$(printf '\033[0m') | |
| else | |
| YELLOW=""; GREEN=""; RED=""; BLUE=""; RESET="" | |
| fi | |
| # --- TTY (for interactive prompts even if stdin is not a TTY) --- | |
| TTY_IN="/dev/tty" | |
| TTY_OUT="/dev/tty" | |
| if [[ ! -r "$TTY_IN" || ! -w "$TTY_OUT" ]]; then | |
| # fallback | |
| TTY_IN="/dev/stdin" | |
| TTY_OUT="/dev/stdout" | |
| fi | |
| # --- Sanitize function --- | |
| sanitize_slug() { | |
| local input="$1" | |
| local out | |
| # Normalize/strip problematic Unicode (copy/paste junk) BEFORE iconv/sed | |
| out="$( | |
| printf '%s' "$input" | perl -CS -pe ' | |
| s/^\s+|\s+$//g; # trim (unicode-aware) | |
| s/[\x{200B}\x{200C}\x{200D}\x{2060}\x{FEFF}]//g; # zero-width (ZWSP/ZWNJ/ZWJ/WORD JOINER/BOM) | |
| s/[\x{FE0E}\x{FE0F}]//g; # variation selectors (emoji style) | |
| s/[\x{00A0}\x{202F}\x{2007}]//g; # NBSP / narrow NBSP / figure space | |
| s/[\x{2010}\x{2011}\x{2012}\x{2013}\x{2014}\x{2212}\x{FE63}\x{FF0D}]/-/g; # unicode dashes -> "-" | |
| ' | |
| )" | |
| # Transliterate to ASCII (best effort) | |
| out="$( | |
| printf '%s' "$out" | iconv -f UTF-8 -t ASCII//TRANSLIT 2>/dev/null || printf '%s' "$out" | |
| )" | |
| # Hard strip to printable ASCII (also nukes weird controls like \r) | |
| out="$(printf '%s' "$out" | LC_ALL=C tr -cd '\11\12\15\40-\176')" | |
| # lowercase + collapse to [a-z0-9-] | |
| out="$(printf '%s' "$out" \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | sed -E 's/[^a-z0-9]+/-/g' \ | |
| | sed -E 's/^-+|-+$//g' \ | |
| | sed -E 's/-+/-/g' | |
| )" | |
| printf '%s' "$out" | |
| } | |
| trim_trailing_ws() { | |
| # removes trailing spaces/tabs/newlines + CR | |
| printf '%s' "$1" | perl -CS -pe 's/\r//g; s/\s+\z//;' | |
| } | |
| normalize_suffix_input() { | |
| local input="$1" | |
| local cleaned ticket looks_like_url=0 | |
| # 1) Remove common invisible chars + trim | |
| cleaned="$( | |
| printf '%s' "$input" | perl -CS -pe ' | |
| s/[\x{200B}\x{200C}\x{200D}\x{2060}\x{FEFF}]//g; # zero-width | |
| s/[\x{FE0E}\x{FE0F}]//g; # variation selectors | |
| s/[\x{00A0}\x{202F}\x{2007}]//g; # NBSP variants | |
| s/\r//g; # CR | |
| s/^\s+|\s+$//g; # trim | |
| ' | |
| )" | |
| # 2) Only extract KEY-123 if it looks like a URL/path/query (typical browser address bar paste) | |
| # You can tweak this heuristic if needed. | |
| if printf '%s' "$cleaned" | grep -Eq '://|/browse/|[/?#]'; then | |
| looks_like_url=1 | |
| fi | |
| if [[ $looks_like_url -eq 1 ]]; then | |
| ticket="$( | |
| printf '%s' "$cleaned" \ | |
| | perl -ne 'while(/([A-Za-z][A-Za-z0-9]+-\d+)/g){$m=$1} END{print $m // ""}' | |
| )" | |
| if [[ -n "$ticket" ]]; then | |
| printf '%s' "$ticket" | tr '[:lower:]' '[:upper:]' | |
| return 0 | |
| fi | |
| fi | |
| # 3) Otherwise: return cleaned as-is (sanitization later will do the rest) | |
| printf '%s' "$cleaned" | |
| } | |
| # --- Abort/Continue selector --- | |
| select_abort_continue() { | |
| local options=("abort" "continue") | |
| local idx=0 | |
| local key | |
| printf "\nWhat do you want to do? (↑/↓, Enter):\n" >"$TTY_OUT" | |
| while true; do | |
| for i in "${!options[@]}"; do | |
| if [[ $i -eq $idx ]]; then | |
| printf " %s> %s%s\n" "$GREEN" "${options[$i]}" "$RESET" >"$TTY_OUT" | |
| else | |
| printf " %s\n" "${options[$i]}" >"$TTY_OUT" | |
| fi | |
| done | |
| IFS= read -rsn1 key <"$TTY_IN" || true | |
| if [[ $key == $'\x1b' ]]; then | |
| IFS= read -rsn2 key <"$TTY_IN" || true | |
| case "$key" in | |
| "[A") ((idx = (idx - 1 + ${#options[@]}) % ${#options[@]})) ;; | |
| "[B") ((idx = (idx + 1) % ${#options[@]})) ;; | |
| esac | |
| elif [[ $key == "" ]]; then | |
| break | |
| fi | |
| printf "\033[%dA" "${#options[@]}" >"$TTY_OUT" | |
| printf "\033[J" >"$TTY_OUT" | |
| done | |
| printf "\n" >"$TTY_OUT" | |
| echo "${options[$idx]}" | |
| } | |
| # --- Prefix selector (arrow navigation, reads from /dev/tty) --- | |
| select_prefix() { | |
| local options=("feature" "bugfix" "hubspot" "qa" "custom") | |
| local idx=0 | |
| local key choice custom_input | |
| while true; do | |
| printf "\nSelect prefix (↑/↓, Enter):\n" >"$TTY_OUT" | |
| while true; do | |
| for i in "${!options[@]}"; do | |
| if [[ $i -eq $idx ]]; then | |
| printf " %s> %s%s\n" "$GREEN" "${options[$i]}" "$RESET" >"$TTY_OUT" | |
| else | |
| printf " %s\n" "${options[$i]}" >"$TTY_OUT" | |
| fi | |
| done | |
| IFS= read -rsn1 key <"$TTY_IN" || true | |
| if [[ $key == $'\x1b' ]]; then | |
| IFS= read -rsn2 key <"$TTY_IN" || true | |
| case "$key" in | |
| "[A") ((idx = (idx - 1 + ${#options[@]}) % ${#options[@]})) ;; # up | |
| "[B") ((idx = (idx + 1) % ${#options[@]})) ;; # down | |
| esac | |
| elif [[ $key == "" ]]; then | |
| break | |
| fi | |
| # Move cursor up by number of options and clear down | |
| printf "\033[%dA" "${#options[@]}" >"$TTY_OUT" | |
| printf "\033[J" >"$TTY_OUT" | |
| done | |
| choice="${options[$idx]}" | |
| printf "\n" >"$TTY_OUT" | |
| if [[ "$choice" == "custom" ]]; then | |
| while true; do | |
| printf "Custom prefix %s(np. client, hotfix, release)%s: " "$BLUE" "$RESET" >"$TTY_OUT" | |
| IFS= read -r custom_input <"$TTY_IN" || true | |
| custom_input="$(sanitize_slug "$custom_input")" | |
| if [[ -z "$custom_input" ]]; then | |
| printf "⚠️ Custom prefix cannot be empty. Try again.\n\n" >"$TTY_OUT" | |
| continue | |
| fi | |
| echo "$custom_input" | |
| return 0 | |
| done | |
| fi | |
| echo "$choice" | |
| return 0 | |
| done | |
| } | |
| STASH_CREATED=0 | |
| # --- 1) Check clean working tree --- | |
| if [[ -n "$(git status --porcelain)" ]]; then | |
| echo "⚠️ ${YELLOW}Working tree is not clean.${RESET}" | |
| git status --short | |
| echo | |
| action="$(select_abort_continue)" | |
| if [[ "$action" == "abort" ]]; then | |
| echo "Aborted." | |
| exit 0 | |
| fi | |
| echo "📦 Stashing current changes..." | |
| git stash push --include-untracked -m "git-b: auto-stash before branch creation" | |
| STASH_CREATED=1 | |
| fi | |
| # --- 2) Ensure stage branch --- | |
| current_branch="$(git rev-parse --abbrev-ref HEAD)" | |
| if [[ "$current_branch" == "HEAD" ]]; then | |
| echo "⚠️ Detached HEAD state." | |
| exit 1 | |
| fi | |
| if [[ "$current_branch" != "stage" ]]; then | |
| echo "🔁 Switching to ${YELLOW}stage${RESET}" | |
| git fetch origin stage >/dev/null 2>&1 || true | |
| if git show-ref --verify --quiet "refs/heads/stage"; then | |
| git checkout stage | |
| elif git show-ref --verify --quiet "refs/remotes/origin/stage"; then | |
| git checkout -b stage origin/stage | |
| else | |
| echo "❌ Cannot find stage branch." | |
| exit 1 | |
| fi | |
| fi | |
| # --- 3) Pull latest stage --- | |
| echo "⬇️ Pulling ${YELLOW}origin/stage${RESET}" | |
| if ! git pull origin stage; then | |
| echo "🛑 ${RED}Pull failed. Resolve conflicts first.${RESET}" | |
| exit 1 | |
| fi | |
| if git status --porcelain | grep -qv '^??'; then | |
| echo "🛑 ${RED}Repository not clean after pull.${RESET}" | |
| git status --short | |
| if [[ $STASH_CREATED -eq 1 ]]; then | |
| echo "📦 Restoring stash..." | |
| git stash pop >/dev/null 2>&1 || true | |
| fi | |
| exit 1 | |
| fi | |
| # --- 4) Ask for branch parts --- | |
| prefix_in="$(select_prefix)" | |
| printf "Suffix %s(np. 245590636737, JIRA-123)%s: " "$BLUE" "$RESET" >"$TTY_OUT" | |
| IFS= read -r suffix_in <"$TTY_IN" || true | |
| suffix_in="$(trim_trailing_ws "$suffix_in")" | |
| while true; do | |
| printf "Message %s(short description)%s: " "$BLUE" "$RESET" >"$TTY_OUT" | |
| IFS= read -r message_in <"$TTY_IN" || true | |
| message_in="$(trim_trailing_ws "$message_in")" | |
| if [[ -z "$message_in" ]]; then | |
| printf "⚠️ Message cannot be empty. Paste/type it again.\n" >"$TTY_OUT" | |
| continue | |
| fi | |
| break | |
| done | |
| prefix="$(sanitize_slug "$prefix_in")" | |
| suffix="$(sanitize_slug "$(normalize_suffix_input "$suffix_in")")" | |
| message="$(sanitize_slug "$message_in")" | |
| if [[ -z "$prefix" || -z "$message" ]]; then | |
| echo "❌ Invalid values after sanitization." | |
| exit 1 | |
| fi | |
| if [[ -n "$suffix" ]]; then | |
| new_branch="${prefix}/${suffix}/${message}" | |
| else | |
| new_branch="${prefix}/${message}" | |
| fi | |
| if git show-ref --verify --quiet "refs/heads/$new_branch"; then | |
| echo "❌ Branch already exists locally: $new_branch" | |
| exit 1 | |
| fi | |
| if git show-ref --verify --quiet "refs/remotes/origin/$new_branch"; then | |
| echo "❌ Branch already exists on origin: $new_branch" | |
| exit 1 | |
| fi | |
| # --- 5) Confirm and create branch --- | |
| printf "\n🌿 Branch to create: %s%s%s\n" "$GREEN" "$new_branch" "$RESET" >"$TTY_OUT" | |
| printf " Proceed? [Y/n]: " >"$TTY_OUT" | |
| IFS= read -r confirm <"$TTY_IN" || true | |
| confirm="$(printf '%s' "$confirm" | tr '[:upper:]' '[:lower:]')" | |
| if [[ "$confirm" == "n" || "$confirm" == "no" ]]; then | |
| echo "Aborted." | |
| if [[ $STASH_CREATED -eq 1 ]]; then | |
| echo "📦 Restoring stash..." | |
| git stash pop >/dev/null 2>&1 || true | |
| fi | |
| exit 0 | |
| fi | |
| git checkout -B "$new_branch" origin/stage 2>/dev/null || git checkout -B "$new_branch" stage | |
| if [[ $STASH_CREATED -eq 1 ]]; then | |
| echo "📦 Restoring stashed changes..." | |
| if git stash apply; then | |
| git stash drop | |
| else | |
| echo "🛑 ${RED}Changes could not be applied cleanly (conflicts).${RESET}" | |
| echo "Your changes are still in the stash. Resolve conflicts manually:" | |
| echo " git stash list" | |
| echo " git stash show -p" | |
| exit 1 | |
| fi | |
| fi | |
| echo "✅ Done:" | |
| echo " ${GREEN}${new_branch}${RESET}" | |
| # sudo mv git-b.sh /usr/local/bin/git-b | |
| # sudo chmod +x /usr/local/bin/git-b | |
| # BASH: | |
| # echo 'alias git-b="bash /usr/local/bin/git-b"' >> ~/.bashrc | |
| # source ~/.bashrc | |
| # ZSH: | |
| # echo 'alias git-b="bash /usr/local/bin/git-b"' >> ~/.zshrc | |
| # source ~/.zshrc |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment