Skip to content

Instantly share code, notes, and snippets.

@isuke01
Last active May 26, 2026 07:24
Show Gist options
  • Select an option

  • Save isuke01/befc68905844b77abcf12f214e0818c2 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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