Last active
February 19, 2026 10:40
-
-
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 β Auto create a branch from the current branch and push it to origin with confirmation | |
| # Usage: | |
| # git-b | |
| # will get you prompt for prefix surfix and message, | |
| # created branch will be in format: <prefix>/<surfix>/<message> | |
| # names are auto escaped to be URL safe and lowercase, e.g. "My Feature" β "my-feature" | |
| # | |
| # Flow: | |
| # 1) Abort if there are uncommitted changes. | |
| # 2) Ensure we're on stage (switch if needed). | |
| # 3) Pull from origin/stage. | |
| # 4) Prompt: Prefix, Suffix, Message | |
| # 5) Create: prefix/suffix/message (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" | |
| } | |
| # --- Prefix selector (arrow navigation, reads from /dev/tty) --- | |
| select_prefix() { | |
| local options=("feature" "bugfix" "hubspot" "qa" "hubspot" "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 | |
| } | |
| # --- 1) Check clean working tree --- | |
| if [[ -n "$(git status --porcelain)" ]]; then | |
| echo "π ${RED}Working tree is not clean.${RESET}" | |
| git status --short | |
| echo | |
| echo "Commit/stash/discard changes first." | |
| exit 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 [[ -n "$(git status --porcelain)" ]]; then | |
| echo "π Repository not clean after pull." | |
| git status --short | |
| 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="$(normalize_suffix_input "$suffix_in")" | |
| message="$(sanitize_slug "$message_in")" | |
| if [[ -z "$prefix" || -z "$suffix" || -z "$message" ]]; then | |
| echo "β Invalid values after sanitization." | |
| exit 1 | |
| fi | |
| new_branch="${prefix}/${suffix}/${message}" | |
| 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) Create branch --- | |
| echo "πΏ Creating branch: ${GREEN}${new_branch}${RESET}" | |
| git checkout -B "$new_branch" origin/stage 2>/dev/null || git checkout -B "$new_branch" stage | |
| 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