Skip to content

Commit a508f8f

Browse files
feat(git): git stack
1 parent 94f7607 commit a508f8f

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

bin/git-stack

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# === git-stack ===
5+
# Create the next branch in a stack following the pattern {NAMESPACE}/{TASK}/{SUFFIX}
6+
#
7+
# Usage: git stack [STACK_ITEM]
8+
# STACK_ITEM - optional suffix for the new branch (must match [a-z]+)
9+
#
10+
# Stack items follow this sequence:
11+
# a, b, c, ..., z (single letters)
12+
# ba, bb, bc, ... (inserted between b and c when c exists)
13+
# st is a special "stack top" branch, excluded from auto-generation
14+
15+
# === Parse arguments ===
16+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
17+
echo "Usage: git stack [STACK_ITEM]"
18+
echo ""
19+
echo "Create the next branch in a stack from the current branch."
20+
echo "Current branch must match pattern '{NAMESPACE}/{TASK}/{SUFFIX}'"
21+
echo ""
22+
echo "Arguments:"
23+
echo " STACK_ITEM Optional suffix for new branch (must match [a-z]+)"
24+
echo " If omitted, automatically determines the next item"
25+
echo ""
26+
echo "Examples:"
27+
echo " git stack # Auto-generate next stack item"
28+
echo " git stack a # Create branch with suffix 'a'"
29+
echo " git stack ba # Create branch with suffix 'ba'"
30+
exit 0
31+
fi
32+
33+
STACK_ITEM="${1:-}"
34+
35+
# === Get current branch ===
36+
current_branch=$(git branch --show-current)
37+
38+
if [[ -z "$current_branch" ]]; then
39+
echo "Error: Not on a branch (detached HEAD state)" >&2
40+
exit 1
41+
fi
42+
43+
# === Validate branch pattern ===
44+
if [[ ! "$current_branch" =~ ^([^/]+)/([^/]+)/([^/]+)$ ]]; then
45+
echo "Error: Current branch '$current_branch' does not match pattern '{NAMESPACE}/{TASK}/{SUFFIX}'" >&2
46+
echo "Expected format: {NAMESPACE}/{TASK}/{SUFFIX}" >&2
47+
exit 1
48+
fi
49+
50+
NAMESPACE="${BASH_REMATCH[1]}"
51+
TASK="${BASH_REMATCH[2]}"
52+
SUFFIX="${BASH_REMATCH[3]}"
53+
54+
# Validate suffix matches [a-z]+
55+
if [[ ! "$SUFFIX" =~ ^[a-z]+$ ]]; then
56+
echo "Error: Branch suffix '$SUFFIX' must match [a-z]+" >&2
57+
exit 1
58+
fi
59+
60+
# === Check git tree is clean ===
61+
if ! git diff --quiet || ! git diff --cached --quiet; then
62+
echo "Error: Git tree is dirty. Please commit or stash changes first." >&2
63+
exit 1
64+
fi
65+
66+
# === Helper functions ===
67+
68+
# Get all existing stack suffixes (excluding 'st')
69+
get_existing_suffixes() {
70+
git for-each-ref --format='%(refname:short)' "refs/heads/${NAMESPACE}/${TASK}/*" 2>/dev/null | while IFS= read -r branch; do
71+
suffix="${branch##*/}"
72+
if [[ "$suffix" =~ ^[a-z]+$ ]] && [[ "$suffix" != "st" ]]; then
73+
echo "$suffix"
74+
fi
75+
done
76+
}
77+
78+
# Check if a suffix exists in the list
79+
suffix_exists() {
80+
local target="$1"
81+
shift
82+
for s in "$@"; do
83+
if [[ "$s" == "$target" ]]; then
84+
return 0
85+
fi
86+
done
87+
return 1
88+
}
89+
90+
# Increment a suffix (a->b, z->za, ba->bb, bz->bza)
91+
increment_suffix() {
92+
local s="$1"
93+
local last_char="${s: -1}"
94+
local prefix="${s%?}"
95+
96+
if [[ "$last_char" == "z" ]]; then
97+
echo "${s}a"
98+
else
99+
# Increment last character
100+
local next_char
101+
next_char=$(printf "\\x$(printf '%02x' $(($(printf '%d' "'$last_char") + 1)))")
102+
echo "${prefix}${next_char}"
103+
fi
104+
}
105+
106+
# === Determine target suffix ===
107+
if [[ -n "$STACK_ITEM" ]]; then
108+
# User provided explicit stack item
109+
if [[ ! "$STACK_ITEM" =~ ^[a-z]+$ ]]; then
110+
echo "Error: Stack item '$STACK_ITEM' must match [a-z]+" >&2
111+
exit 1
112+
fi
113+
TARGET_SUFFIX="$STACK_ITEM"
114+
else
115+
# Auto-generate next suffix
116+
existing_suffixes=()
117+
while IFS= read -r s; do
118+
[[ -n "$s" ]] && existing_suffixes+=("$s")
119+
done < <(get_existing_suffixes)
120+
121+
if [[ "$SUFFIX" == "st" ]]; then
122+
# From 'st', find first available single letter
123+
TARGET_SUFFIX=""
124+
for c in {a..z}; do
125+
if ! suffix_exists "$c" "${existing_suffixes[@]+"${existing_suffixes[@]}"}"; then
126+
TARGET_SUFFIX="$c"
127+
break
128+
fi
129+
done
130+
if [[ -z "$TARGET_SUFFIX" ]]; then
131+
echo "Error: Stack limit reached - all single-letter branches a-z exist" >&2
132+
exit 1
133+
fi
134+
else
135+
# Compute next_major (first char of current suffix + 1)
136+
first_char="${SUFFIX:0:1}"
137+
if [[ "$first_char" == "z" ]]; then
138+
echo "Error: Stack limit reached - cannot create branches after z*" >&2
139+
exit 1
140+
fi
141+
142+
next_major_char=$(printf "\\x$(printf '%02x' $(($(printf '%d' "'$first_char") + 1)))")
143+
144+
if ! suffix_exists "$next_major_char" "${existing_suffixes[@]+"${existing_suffixes[@]}"}"; then
145+
TARGET_SUFFIX="$next_major_char"
146+
else
147+
# next_major exists, insert in current sub-stack
148+
TARGET_SUFFIX=$(increment_suffix "$SUFFIX")
149+
150+
# Safety limit to prevent infinite loops
151+
iterations=0
152+
max_iterations=1000
153+
154+
while suffix_exists "$TARGET_SUFFIX" "${existing_suffixes[@]+"${existing_suffixes[@]}"}"; do
155+
TARGET_SUFFIX=$(increment_suffix "$TARGET_SUFFIX")
156+
iterations=$((iterations + 1))
157+
if [[ $iterations -ge $max_iterations ]]; then
158+
echo "Error: Could not find available suffix after $max_iterations attempts" >&2
159+
exit 1
160+
fi
161+
done
162+
fi
163+
fi
164+
fi
165+
166+
TARGET_BRANCH="${NAMESPACE}/${TASK}/${TARGET_SUFFIX}"
167+
168+
# === Check for collision ===
169+
if git show-ref --verify --quiet "refs/heads/${TARGET_BRANCH}"; then
170+
echo "Error: Branch '$TARGET_BRANCH' already exists" >&2
171+
exit 1
172+
fi
173+
174+
# === Create and switch to new branch ===
175+
git checkout -b "$TARGET_BRANCH"
176+
echo "Created and switched to branch '$TARGET_BRANCH'"

0 commit comments

Comments
 (0)