stak-git 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/Makefile +32 -0
- package/README.md +193 -0
- package/_stak +111 -0
- package/package.json +31 -0
- package/packaging/PKGBUILD +30 -0
- package/packaging/install.sh +57 -0
- package/pyproject.toml +43 -0
- package/stak +1209 -0
- package/stak_git/__init__.py +32 -0
- package/stak_git/stak +1209 -0
package/stak
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
STACKS_DIR=".git/stacks"
|
|
5
|
+
CURRENT_STACK_FILE=".git/stack-current"
|
|
6
|
+
|
|
7
|
+
die() { echo "error: $1" >&2; exit 1; }
|
|
8
|
+
|
|
9
|
+
# Get current stack name (default if not set)
|
|
10
|
+
current_stack_name() {
|
|
11
|
+
if [ -f "$CURRENT_STACK_FILE" ]; then
|
|
12
|
+
cat "$CURRENT_STACK_FILE"
|
|
13
|
+
else
|
|
14
|
+
echo "default"
|
|
15
|
+
fi
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Get path to current stack file
|
|
19
|
+
stack_file() {
|
|
20
|
+
echo "$STACKS_DIR/$(current_stack_name)"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Migrate old .git/stack to new format
|
|
24
|
+
migrate_old_stack() {
|
|
25
|
+
if [ -f ".git/stack" ] && [ ! -d "$STACKS_DIR" ]; then
|
|
26
|
+
mkdir -p "$STACKS_DIR"
|
|
27
|
+
mv ".git/stack" "$STACKS_DIR/default"
|
|
28
|
+
echo "default" > "$CURRENT_STACK_FILE"
|
|
29
|
+
fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Check if fzf is available and interactive mode is enabled
|
|
33
|
+
has_fzf() {
|
|
34
|
+
[ -z "$STAK_NO_INTERACTIVE" ] && command -v fzf >/dev/null 2>&1
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Common fzf options for nice display
|
|
38
|
+
FZF_OPTS="--reverse --height=~50% --border=rounded --margin=1 --padding=1"
|
|
39
|
+
|
|
40
|
+
# Build stack display list for fzf
|
|
41
|
+
# Format: "N ● branch (commits)" or "N → branch (commits)"
|
|
42
|
+
build_stack_display() {
|
|
43
|
+
local stack="$1"
|
|
44
|
+
local current=$(current_branch)
|
|
45
|
+
local display=""
|
|
46
|
+
local i=0
|
|
47
|
+
while IFS= read -r branch; do
|
|
48
|
+
i=$((i + 1))
|
|
49
|
+
local marker="○"
|
|
50
|
+
local commits=""
|
|
51
|
+
[ "$branch" = "$current" ] && marker="●"
|
|
52
|
+
if [ $i -gt 1 ]; then
|
|
53
|
+
local prev=$(echo "$stack" | sed -n "$((i - 1))p")
|
|
54
|
+
local count=$(git rev-list --count "$prev".."$branch" 2>/dev/null || echo "0")
|
|
55
|
+
commits="($count)"
|
|
56
|
+
fi
|
|
57
|
+
display+="$i $marker $branch $commits"$'\n'
|
|
58
|
+
done <<< "$stack"
|
|
59
|
+
echo "$display" | sed '/^$/d'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Extract branch name from fzf selection (field 3: "N marker branch")
|
|
63
|
+
extract_branch() {
|
|
64
|
+
echo "$1" | awk '{print $3}'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current_branch() {
|
|
68
|
+
local branch=$(git rev-parse --abbrev-ref HEAD)
|
|
69
|
+
[ "$branch" = "HEAD" ] && die "cannot operate in detached HEAD state"
|
|
70
|
+
echo "$branch"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ensure_git() { git rev-parse --git-dir > /dev/null 2>&1 || die "not a git repository"; }
|
|
74
|
+
|
|
75
|
+
ensure_clean() {
|
|
76
|
+
git diff --quiet && git diff --cached --quiet || die "working tree is dirty - commit or stash changes first"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
validate_stack_name() {
|
|
80
|
+
local name="$1"
|
|
81
|
+
[ -z "$name" ] && return 0
|
|
82
|
+
[[ "$name" == *"/"* ]] && die "invalid stack name: cannot contain '/'"
|
|
83
|
+
[[ "$name" == *".."* ]] && die "invalid stack name: cannot contain '..'"
|
|
84
|
+
[[ "$name" == "."* ]] && die "invalid stack name: cannot start with '.'"
|
|
85
|
+
[[ "$name" =~ [^a-zA-Z0-9_-] ]] && die "invalid stack name: use only letters, numbers, underscore, hyphen"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
validate_branch_name() {
|
|
89
|
+
local name="$1"
|
|
90
|
+
[ -z "$name" ] && return 0
|
|
91
|
+
[[ "$name" == *".."* ]] && die "invalid branch name: cannot contain '..'"
|
|
92
|
+
[[ "$name" == *"~"* ]] && die "invalid branch name: cannot contain '~'"
|
|
93
|
+
[[ "$name" == *"^"* ]] && die "invalid branch name: cannot contain '^'"
|
|
94
|
+
[[ "$name" == *":"* ]] && die "invalid branch name: cannot contain ':'"
|
|
95
|
+
[[ "$name" == *"\\"* ]] && die "invalid branch name: cannot contain '\\'"
|
|
96
|
+
[[ "$name" == *" "* ]] && die "invalid branch name: cannot contain spaces"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
is_rebasing() {
|
|
100
|
+
[ -d ".git/rebase-merge" ] || [ -d ".git/rebase-apply" ]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
warn_if_rebasing() {
|
|
104
|
+
if is_rebasing; then
|
|
105
|
+
echo "⚠️ Rebase in progress. Run 'stak continue' or 'stak abort' first." >&2
|
|
106
|
+
fi
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
load_stack() {
|
|
110
|
+
local sf=$(stack_file)
|
|
111
|
+
[ -f "$sf" ] && grep -v '^$' "$sf" 2>/dev/null || echo ""
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
save_stack() {
|
|
115
|
+
local sf=$(stack_file)
|
|
116
|
+
mkdir -p "$STACKS_DIR"
|
|
117
|
+
echo "$1" > "$sf"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
find_in_stack() {
|
|
121
|
+
local branch="$1"
|
|
122
|
+
local stack="$2"
|
|
123
|
+
echo "$stack" | grep -nFx "$branch" | cut -d: -f1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stack_top() {
|
|
127
|
+
local stack="$1"
|
|
128
|
+
echo "$stack" | tail -n 1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
cmd_new() {
|
|
132
|
+
[ -z "$1" ] && die "usage: stak new <branch-name>"
|
|
133
|
+
local name="$1"
|
|
134
|
+
validate_branch_name "$name"
|
|
135
|
+
local current=$(current_branch)
|
|
136
|
+
local stack=$(load_stack)
|
|
137
|
+
|
|
138
|
+
# Validate: can only create from top of stack or from outside stack
|
|
139
|
+
local pos=""
|
|
140
|
+
if [ -n "$stack" ]; then
|
|
141
|
+
pos=$(find_in_stack "$current" "$stack")
|
|
142
|
+
local top=$(stack_top "$stack")
|
|
143
|
+
if [ -n "$pos" ] && [ "$current" != "$top" ]; then
|
|
144
|
+
die "can only create branches from the top of the stack ('$top')"
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
# Create branch from current position
|
|
149
|
+
git checkout -b "$name"
|
|
150
|
+
|
|
151
|
+
# Add to stack
|
|
152
|
+
if [ -z "$stack" ]; then
|
|
153
|
+
save_stack "$current"$'\n'"$name"
|
|
154
|
+
elif [ -n "$pos" ]; then
|
|
155
|
+
# Current is top of stack, append
|
|
156
|
+
save_stack "$stack"$'\n'"$name"
|
|
157
|
+
else
|
|
158
|
+
# Current not in stack, start new stack
|
|
159
|
+
save_stack "$current"$'\n'"$name"
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
echo "Created '$name' on top of '$current'"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
cmd_insert() {
|
|
166
|
+
ensure_clean
|
|
167
|
+
local head=$(git rev-parse --abbrev-ref HEAD)
|
|
168
|
+
[ "$head" = "HEAD" ] && die "cannot insert in detached HEAD state - checkout a branch first"
|
|
169
|
+
local name="$1"
|
|
170
|
+
local stack=$(load_stack)
|
|
171
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
172
|
+
|
|
173
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
174
|
+
[ "$total" -lt 1 ] && die "stack is empty"
|
|
175
|
+
|
|
176
|
+
# Get insert position
|
|
177
|
+
local after_branch=""
|
|
178
|
+
local after_pos=""
|
|
179
|
+
|
|
180
|
+
if [ -z "$name" ] && has_fzf; then
|
|
181
|
+
# Interactive: first get name
|
|
182
|
+
echo -n "New branch name: "
|
|
183
|
+
read name
|
|
184
|
+
[ -z "$name" ] && die "branch name required"
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
[ -z "$name" ] && die "usage: stak insert <branch-name> [--after <branch>]"
|
|
188
|
+
validate_branch_name "$name"
|
|
189
|
+
|
|
190
|
+
# Check branch doesn't exist
|
|
191
|
+
git rev-parse --verify "$name" >/dev/null 2>&1 && die "branch '$name' already exists"
|
|
192
|
+
|
|
193
|
+
# Get position argument or use fzf
|
|
194
|
+
shift || true
|
|
195
|
+
if [ "$1" = "--after" ] && [ -n "$2" ]; then
|
|
196
|
+
after_branch="$2"
|
|
197
|
+
after_pos=$(find_in_stack "$after_branch" "$stack")
|
|
198
|
+
[ -z "$after_pos" ] && die "'$after_branch' is not in the stack"
|
|
199
|
+
elif has_fzf; then
|
|
200
|
+
# Interactive: select where to insert
|
|
201
|
+
local display=$(build_stack_display "$stack")
|
|
202
|
+
local selected=$(echo "$display" | fzf --prompt="insert after❯ " --header="Insert '$name' after which branch?" $FZF_OPTS)
|
|
203
|
+
[ -z "$selected" ] && exit 0
|
|
204
|
+
|
|
205
|
+
after_branch=$(extract_branch "$selected")
|
|
206
|
+
after_pos=$(find_in_stack "$after_branch" "$stack")
|
|
207
|
+
else
|
|
208
|
+
# Default: insert after first branch (base)
|
|
209
|
+
after_branch=$(echo "$stack" | head -n1)
|
|
210
|
+
after_pos=1
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# Can't insert after the last branch (that's just 'stak new')
|
|
214
|
+
if [ "$after_pos" -eq "$total" ]; then
|
|
215
|
+
die "use 'stak new $name' to add at the top"
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
echo "Inserting '$name' after '$after_branch'..."
|
|
219
|
+
|
|
220
|
+
# Create new branch from after_branch
|
|
221
|
+
git checkout "$after_branch" --quiet
|
|
222
|
+
git checkout -b "$name" --quiet
|
|
223
|
+
|
|
224
|
+
# Update stack file: insert at position after_pos + 1
|
|
225
|
+
local new_stack=""
|
|
226
|
+
local i=0
|
|
227
|
+
while IFS= read -r branch; do
|
|
228
|
+
i=$((i + 1))
|
|
229
|
+
new_stack+="$branch"$'\n'
|
|
230
|
+
if [ "$i" -eq "$after_pos" ]; then
|
|
231
|
+
new_stack+="$name"$'\n'
|
|
232
|
+
fi
|
|
233
|
+
done <<< "$stack"
|
|
234
|
+
save_stack "$(echo "$new_stack" | sed '/^$/d')"
|
|
235
|
+
|
|
236
|
+
# Rebase all branches above onto the new branch
|
|
237
|
+
local rebase_stack=$(load_stack)
|
|
238
|
+
local insert_pos=$((after_pos + 1))
|
|
239
|
+
|
|
240
|
+
echo "Rebasing branches above..."
|
|
241
|
+
for ((j=insert_pos+1; j<=total+1; j++)); do
|
|
242
|
+
local branch_to_rebase=$(echo "$rebase_stack" | sed -n "${j}p")
|
|
243
|
+
[ -z "$branch_to_rebase" ] && continue
|
|
244
|
+
local prev_branch=$(echo "$rebase_stack" | sed -n "$((j-1))p")
|
|
245
|
+
|
|
246
|
+
echo " Rebasing '$branch_to_rebase' onto '$prev_branch'..."
|
|
247
|
+
git checkout "$branch_to_rebase" --quiet
|
|
248
|
+
git rebase "$prev_branch" --quiet || {
|
|
249
|
+
echo ""
|
|
250
|
+
echo "Conflict! Resolve, then run 'stak continue'"
|
|
251
|
+
exit 1
|
|
252
|
+
}
|
|
253
|
+
done
|
|
254
|
+
|
|
255
|
+
git checkout "$name" --quiet
|
|
256
|
+
echo ""
|
|
257
|
+
echo "Inserted '$name' after '$after_branch'"
|
|
258
|
+
cmd_status
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cmd_split() {
|
|
262
|
+
ensure_clean
|
|
263
|
+
local head=$(git rev-parse --abbrev-ref HEAD)
|
|
264
|
+
[ "$head" = "HEAD" ] && die "cannot split in detached HEAD state - checkout a branch first"
|
|
265
|
+
local current=$(current_branch)
|
|
266
|
+
local stack=$(load_stack)
|
|
267
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
268
|
+
|
|
269
|
+
local pos=$(find_in_stack "$current" "$stack")
|
|
270
|
+
[ -z "$pos" ] && die "'$current' is not in the stack"
|
|
271
|
+
[ "$pos" -eq 1 ] && die "cannot split the base branch"
|
|
272
|
+
|
|
273
|
+
local parent=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
274
|
+
|
|
275
|
+
# Get commits between parent and current
|
|
276
|
+
local commits=$(git log --oneline "$parent..$current" --reverse)
|
|
277
|
+
[ -z "$commits" ] && die "no commits to split"
|
|
278
|
+
|
|
279
|
+
local commit_count=$(echo "$commits" | wc -l | tr -d ' ')
|
|
280
|
+
[ "$commit_count" -lt 2 ] && die "need at least 2 commits to split"
|
|
281
|
+
|
|
282
|
+
local new_name="$1"
|
|
283
|
+
|
|
284
|
+
if [ -z "$new_name" ] && has_fzf; then
|
|
285
|
+
echo -n "New branch name (for bottom part): "
|
|
286
|
+
read new_name
|
|
287
|
+
[ -z "$new_name" ] && die "branch name required"
|
|
288
|
+
fi
|
|
289
|
+
|
|
290
|
+
[ -z "$new_name" ] && die "usage: stak split <new-branch-name>"
|
|
291
|
+
validate_branch_name "$new_name"
|
|
292
|
+
|
|
293
|
+
# Check branch doesn't exist
|
|
294
|
+
git rev-parse --verify "$new_name" >/dev/null 2>&1 && die "branch '$new_name' already exists"
|
|
295
|
+
|
|
296
|
+
if has_fzf; then
|
|
297
|
+
echo ""
|
|
298
|
+
echo "Select commits to move to '$new_name' (bottom branch):"
|
|
299
|
+
echo "Remaining commits will stay in '$current'"
|
|
300
|
+
echo ""
|
|
301
|
+
|
|
302
|
+
# Multi-select commits with fzf
|
|
303
|
+
local selected=$(echo "$commits" | fzf --multi --prompt="split❯ " \
|
|
304
|
+
--header="Tab to select commits for '$new_name', Enter when done" $FZF_OPTS)
|
|
305
|
+
[ -z "$selected" ] && exit 0
|
|
306
|
+
|
|
307
|
+
local selected_count=$(echo "$selected" | wc -l | tr -d ' ')
|
|
308
|
+
[ "$selected_count" -eq "$commit_count" ] && die "cannot move all commits - keep at least one in '$current'"
|
|
309
|
+
|
|
310
|
+
# Get the last selected commit (to find split point)
|
|
311
|
+
# We need contiguous commits from the bottom
|
|
312
|
+
local first_commit=$(echo "$commits" | head -n1 | awk '{print $1}')
|
|
313
|
+
local selected_hashes=$(echo "$selected" | awk '{print $1}')
|
|
314
|
+
|
|
315
|
+
# Verify commits are contiguous from bottom
|
|
316
|
+
local split_after=""
|
|
317
|
+
local expected_pos=1
|
|
318
|
+
while IFS= read -r commit_line; do
|
|
319
|
+
local hash=$(echo "$commit_line" | awk '{print $1}')
|
|
320
|
+
local current_pos=$(echo "$commits" | grep -n "^$hash" | cut -d: -f1)
|
|
321
|
+
|
|
322
|
+
if echo "$selected_hashes" | grep -q "^$hash$"; then
|
|
323
|
+
if [ "$current_pos" -ne "$expected_pos" ]; then
|
|
324
|
+
die "commits must be contiguous from the bottom"
|
|
325
|
+
fi
|
|
326
|
+
split_after="$hash"
|
|
327
|
+
expected_pos=$((expected_pos + 1))
|
|
328
|
+
fi
|
|
329
|
+
done <<< "$commits"
|
|
330
|
+
|
|
331
|
+
[ -z "$split_after" ] && die "no commits selected"
|
|
332
|
+
else
|
|
333
|
+
echo "split requires fzf for commit selection."
|
|
334
|
+
echo "Install with: stak setup-interactive"
|
|
335
|
+
exit 1
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
echo ""
|
|
339
|
+
echo "Splitting '$current'..."
|
|
340
|
+
echo " '$new_name' will have $selected_count commit(s)"
|
|
341
|
+
echo " '$current' will have $((commit_count - selected_count)) commit(s)"
|
|
342
|
+
echo ""
|
|
343
|
+
|
|
344
|
+
# Create new branch from parent
|
|
345
|
+
git checkout "$parent" --quiet
|
|
346
|
+
git checkout -b "$new_name" --quiet
|
|
347
|
+
|
|
348
|
+
# Cherry-pick selected commits
|
|
349
|
+
echo "Cherry-picking commits to '$new_name'..."
|
|
350
|
+
echo "$selected_hashes" | while read hash; do
|
|
351
|
+
git cherry-pick "$hash" --quiet || {
|
|
352
|
+
echo ""
|
|
353
|
+
echo "Conflict during cherry-pick!"
|
|
354
|
+
echo "Resolve the conflict, then:"
|
|
355
|
+
echo " git add . && git cherry-pick --continue"
|
|
356
|
+
echo " git checkout $current && git rebase --onto $new_name $split_after"
|
|
357
|
+
exit 1
|
|
358
|
+
}
|
|
359
|
+
done
|
|
360
|
+
|
|
361
|
+
# Update stack: insert new_name before current
|
|
362
|
+
local new_stack=""
|
|
363
|
+
while IFS= read -r branch; do
|
|
364
|
+
if [ "$branch" = "$current" ]; then
|
|
365
|
+
new_stack+="$new_name"$'\n'
|
|
366
|
+
fi
|
|
367
|
+
new_stack+="$branch"$'\n'
|
|
368
|
+
done <<< "$stack"
|
|
369
|
+
save_stack "$(echo "$new_stack" | sed '/^$/d')"
|
|
370
|
+
|
|
371
|
+
# Rebase current onto new_name, excluding the moved commits
|
|
372
|
+
echo "Rebasing '$current' onto '$new_name'..."
|
|
373
|
+
git checkout "$current" --quiet
|
|
374
|
+
git rebase --onto "$new_name" "$split_after" "$current" --quiet || {
|
|
375
|
+
echo ""
|
|
376
|
+
echo "Conflict! Resolve, then run 'stak continue'"
|
|
377
|
+
exit 1
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# Rebase any branches above current
|
|
381
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
382
|
+
if [ "$pos" -lt "$total" ]; then
|
|
383
|
+
echo "Rebasing branches above..."
|
|
384
|
+
local updated_stack=$(load_stack)
|
|
385
|
+
local new_pos=$(find_in_stack "$current" "$updated_stack")
|
|
386
|
+
|
|
387
|
+
for ((j=new_pos+1; j<=total+1; j++)); do
|
|
388
|
+
local branch_to_rebase=$(echo "$updated_stack" | sed -n "${j}p")
|
|
389
|
+
[ -z "$branch_to_rebase" ] && continue
|
|
390
|
+
local prev_branch=$(echo "$updated_stack" | sed -n "$((j-1))p")
|
|
391
|
+
|
|
392
|
+
echo " Rebasing '$branch_to_rebase' onto '$prev_branch'..."
|
|
393
|
+
git checkout "$branch_to_rebase" --quiet
|
|
394
|
+
git rebase "$prev_branch" --quiet || {
|
|
395
|
+
echo ""
|
|
396
|
+
echo "Conflict! Resolve, then run 'stak continue'"
|
|
397
|
+
exit 1
|
|
398
|
+
}
|
|
399
|
+
done
|
|
400
|
+
fi
|
|
401
|
+
|
|
402
|
+
git checkout "$new_name" --quiet
|
|
403
|
+
echo ""
|
|
404
|
+
echo "Split complete! Now on '$new_name'"
|
|
405
|
+
cmd_status
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
cmd_up() {
|
|
409
|
+
local current=$(current_branch)
|
|
410
|
+
local stack=$(load_stack)
|
|
411
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
412
|
+
|
|
413
|
+
local pos=$(find_in_stack "$current" "$stack")
|
|
414
|
+
[ -z "$pos" ] && die "'$current' is not in the stack"
|
|
415
|
+
|
|
416
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
417
|
+
[ "$pos" -ge "$total" ] && die "already at top of stack"
|
|
418
|
+
|
|
419
|
+
local next=$(echo "$stack" | sed -n "$((pos + 1))p")
|
|
420
|
+
git checkout "$next"
|
|
421
|
+
echo "Moved up to '$next'"
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
cmd_down() {
|
|
425
|
+
local current=$(current_branch)
|
|
426
|
+
local stack=$(load_stack)
|
|
427
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
428
|
+
|
|
429
|
+
local pos=$(find_in_stack "$current" "$stack")
|
|
430
|
+
[ -z "$pos" ] && die "'$current' is not in the stack"
|
|
431
|
+
|
|
432
|
+
[ "$pos" -le 1 ] && die "already at bottom of stack"
|
|
433
|
+
|
|
434
|
+
local prev=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
435
|
+
git checkout "$prev"
|
|
436
|
+
echo "Moved down to '$prev'"
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
cmd_status() {
|
|
440
|
+
warn_if_rebasing
|
|
441
|
+
local branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
442
|
+
local current="$branch"
|
|
443
|
+
[ "$branch" = "HEAD" ] && current="DETACHED"
|
|
444
|
+
local stack=$(load_stack)
|
|
445
|
+
local stack_name=$(current_stack_name)
|
|
446
|
+
|
|
447
|
+
if [ -z "$stack" ]; then
|
|
448
|
+
echo "No stack. Use 'stak new <name>' to start."
|
|
449
|
+
return
|
|
450
|
+
fi
|
|
451
|
+
|
|
452
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
453
|
+
|
|
454
|
+
echo ""
|
|
455
|
+
echo "Stack: $stack_name"
|
|
456
|
+
echo ""
|
|
457
|
+
local i=0
|
|
458
|
+
while IFS= read -r branch; do
|
|
459
|
+
i=$((i + 1))
|
|
460
|
+
local commits=""
|
|
461
|
+
local line_char="│"
|
|
462
|
+
|
|
463
|
+
if [ $i -gt 1 ]; then
|
|
464
|
+
local prev=$(echo "$stack" | sed -n "$((i - 1))p")
|
|
465
|
+
local count=$(git rev-list --count "$prev".."$branch" 2>/dev/null || echo "0")
|
|
466
|
+
if [ "$count" -eq 0 ]; then
|
|
467
|
+
commits="(no commits)"
|
|
468
|
+
elif [ "$count" -eq 1 ]; then
|
|
469
|
+
commits="(1 commit)"
|
|
470
|
+
else
|
|
471
|
+
commits="($count commits)"
|
|
472
|
+
fi
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
# Visual tree
|
|
476
|
+
if [ $i -eq 1 ]; then
|
|
477
|
+
# Base branch
|
|
478
|
+
if [ "$branch" = "$current" ]; then
|
|
479
|
+
echo " ● $branch (base) ◀ you are here"
|
|
480
|
+
else
|
|
481
|
+
echo " ○ $branch (base)"
|
|
482
|
+
fi
|
|
483
|
+
elif [ $i -eq "$total" ]; then
|
|
484
|
+
# Top branch
|
|
485
|
+
if [ "$branch" = "$current" ]; then
|
|
486
|
+
echo " ╰─▶ $branch $commits ◀ you are here"
|
|
487
|
+
else
|
|
488
|
+
echo " ╰── $branch $commits"
|
|
489
|
+
fi
|
|
490
|
+
else
|
|
491
|
+
# Middle branch
|
|
492
|
+
if [ "$branch" = "$current" ]; then
|
|
493
|
+
echo " ├─▶ $branch $commits ◀ you are here"
|
|
494
|
+
else
|
|
495
|
+
echo " ├── $branch $commits"
|
|
496
|
+
fi
|
|
497
|
+
fi
|
|
498
|
+
done <<< "$stack"
|
|
499
|
+
echo ""
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
cmd_sync() {
|
|
503
|
+
ensure_clean
|
|
504
|
+
local stack=$(load_stack)
|
|
505
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
506
|
+
|
|
507
|
+
local original=$(current_branch)
|
|
508
|
+
|
|
509
|
+
# Validate all branches exist
|
|
510
|
+
while IFS= read -r branch; do
|
|
511
|
+
git rev-parse --verify "$branch" >/dev/null 2>&1 || \
|
|
512
|
+
die "branch '$branch' no longer exists. Remove it from stack with: git branch -D $branch (if needed) and edit .git/stacks/$(current_stack_name)"
|
|
513
|
+
done <<< "$stack"
|
|
514
|
+
|
|
515
|
+
# Collect branch tips BEFORE any rebasing
|
|
516
|
+
declare -a branches
|
|
517
|
+
declare -a old_tips
|
|
518
|
+
local i=0
|
|
519
|
+
while IFS= read -r branch; do
|
|
520
|
+
branches[$i]="$branch"
|
|
521
|
+
old_tips[$i]=$(git rev-parse "$branch")
|
|
522
|
+
i=$((i + 1))
|
|
523
|
+
done <<< "$stack"
|
|
524
|
+
|
|
525
|
+
echo "Syncing stack..."
|
|
526
|
+
|
|
527
|
+
for ((i=1; i<${#branches[@]}; i++)); do
|
|
528
|
+
local branch="${branches[$i]}"
|
|
529
|
+
local prev="${branches[$((i-1))]}"
|
|
530
|
+
local old_base="${old_tips[$((i-1))]}"
|
|
531
|
+
|
|
532
|
+
echo " [$i/${#branches[@]}] Rebasing '$branch' onto '$prev'..."
|
|
533
|
+
git checkout "$branch" --quiet
|
|
534
|
+
|
|
535
|
+
# Use --onto to handle case where prev was also rebased
|
|
536
|
+
# This replays only commits unique to $branch (after old $prev tip)
|
|
537
|
+
git rebase --onto "$prev" "$old_base" "$branch" --quiet || {
|
|
538
|
+
echo ""
|
|
539
|
+
echo "Rebase conflict in '$branch'. Resolve, then run:"
|
|
540
|
+
echo " git add <files>"
|
|
541
|
+
echo " stak continue"
|
|
542
|
+
echo ""
|
|
543
|
+
echo "Or to abort:"
|
|
544
|
+
echo " stak abort"
|
|
545
|
+
exit 1
|
|
546
|
+
}
|
|
547
|
+
done
|
|
548
|
+
|
|
549
|
+
git checkout "$original" --quiet
|
|
550
|
+
echo "Stack synced."
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
cmd_push() {
|
|
554
|
+
local stack=$(load_stack)
|
|
555
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
556
|
+
|
|
557
|
+
local arg="$1"
|
|
558
|
+
|
|
559
|
+
# Get pushable branches (skip base)
|
|
560
|
+
local pushable=$(echo "$stack" | tail -n +2)
|
|
561
|
+
[ -z "$pushable" ] && die "no branches to push"
|
|
562
|
+
|
|
563
|
+
local to_push=""
|
|
564
|
+
|
|
565
|
+
if [ "$arg" = "--all" ] || [ "$arg" = "-a" ]; then
|
|
566
|
+
# Push all (skip interactive)
|
|
567
|
+
to_push="$pushable"
|
|
568
|
+
elif [ -z "$arg" ] && has_fzf; then
|
|
569
|
+
# Interactive multi-select with fzf
|
|
570
|
+
local display=$(build_stack_display "$stack" | tail -n +2)
|
|
571
|
+
local selected=$(echo "$display" | fzf --multi --prompt="push❯ " --header="Select branches (Tab to toggle, Enter to push)" $FZF_OPTS)
|
|
572
|
+
[ -z "$selected" ] && exit 0
|
|
573
|
+
|
|
574
|
+
# Extract branch names
|
|
575
|
+
to_push=$(echo "$selected" | while read -r line; do extract_branch "$line"; done)
|
|
576
|
+
else
|
|
577
|
+
# No fzf, push all
|
|
578
|
+
to_push="$pushable"
|
|
579
|
+
fi
|
|
580
|
+
|
|
581
|
+
echo "Pushing selected branches..."
|
|
582
|
+
|
|
583
|
+
while IFS= read -r branch; do
|
|
584
|
+
[ -z "$branch" ] && continue
|
|
585
|
+
echo " Pushing '$branch'..."
|
|
586
|
+
git push -f origin "$branch" 2>/dev/null || git push -u origin "$branch"
|
|
587
|
+
done <<< "$to_push"
|
|
588
|
+
|
|
589
|
+
echo "Done."
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
cmd_drop() {
|
|
593
|
+
ensure_clean
|
|
594
|
+
local current=$(current_branch)
|
|
595
|
+
local stack=$(load_stack)
|
|
596
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
597
|
+
|
|
598
|
+
local pos=$(find_in_stack "$current" "$stack")
|
|
599
|
+
[ -z "$pos" ] && die "'$current' is not in the stack"
|
|
600
|
+
[ "$pos" -eq 1 ] && die "cannot drop the base of the stack"
|
|
601
|
+
|
|
602
|
+
local top=$(stack_top "$stack")
|
|
603
|
+
[ "$current" != "$top" ] && die "can only drop from the top of the stack (currently '$top')"
|
|
604
|
+
|
|
605
|
+
local prev=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
606
|
+
|
|
607
|
+
# Remove from stack
|
|
608
|
+
local new_stack=$(echo "$stack" | sed "${pos}d")
|
|
609
|
+
save_stack "$new_stack"
|
|
610
|
+
|
|
611
|
+
# Checkout previous and delete branch
|
|
612
|
+
git checkout "$prev"
|
|
613
|
+
git branch -D "$current"
|
|
614
|
+
|
|
615
|
+
echo "Dropped '$current', now on '$prev'"
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
show_branch_log() {
|
|
619
|
+
local branch="$1"
|
|
620
|
+
local prev="$2"
|
|
621
|
+
local current="$3"
|
|
622
|
+
|
|
623
|
+
local count=$(git rev-list --count "$prev".."$branch" 2>/dev/null || echo "0")
|
|
624
|
+
local marker="│"
|
|
625
|
+
[ "$branch" = "$current" ] && marker="▶"
|
|
626
|
+
|
|
627
|
+
echo ""
|
|
628
|
+
if [ "$count" -eq 0 ]; then
|
|
629
|
+
echo "$marker ─── $branch (no commits yet)"
|
|
630
|
+
else
|
|
631
|
+
echo "$marker ─── $branch ($count commits)"
|
|
632
|
+
git log --oneline --format="$marker %C(yellow)%h%C(reset) %s" "$prev".."$branch"
|
|
633
|
+
fi
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
cmd_log() {
|
|
637
|
+
local stack=$(load_stack)
|
|
638
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
639
|
+
|
|
640
|
+
local current=$(current_branch)
|
|
641
|
+
local arg="$1"
|
|
642
|
+
|
|
643
|
+
if [ -n "$arg" ] && [ "$arg" != "--all" ] && [ "$arg" != "-a" ]; then
|
|
644
|
+
# Specific branch by name/number
|
|
645
|
+
if [[ "$arg" =~ ^[0-9]+$ ]]; then
|
|
646
|
+
local branch=$(echo "$stack" | sed -n "${arg}p")
|
|
647
|
+
else
|
|
648
|
+
local branch=$(echo "$stack" | grep -F "$arg" | head -n1)
|
|
649
|
+
fi
|
|
650
|
+
[ -z "$branch" ] && die "branch not found: $arg"
|
|
651
|
+
|
|
652
|
+
local pos=$(find_in_stack "$branch" "$stack")
|
|
653
|
+
[ "$pos" -eq 1 ] && die "no commits to show for base branch"
|
|
654
|
+
|
|
655
|
+
local prev=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
656
|
+
show_branch_log "$branch" "$prev" "$current"
|
|
657
|
+
return
|
|
658
|
+
elif [ -z "$arg" ] && has_fzf; then
|
|
659
|
+
# Interactive select
|
|
660
|
+
local display=$(build_stack_display "$stack" | tail -n +2)
|
|
661
|
+
[ -z "$display" ] && die "no branches to show"
|
|
662
|
+
|
|
663
|
+
local selected=$(echo "$display" | fzf --prompt="log❯ " --header="Select branch to view log" $FZF_OPTS)
|
|
664
|
+
[ -z "$selected" ] && exit 0
|
|
665
|
+
|
|
666
|
+
local branch=$(extract_branch "$selected")
|
|
667
|
+
local pos=$(find_in_stack "$branch" "$stack")
|
|
668
|
+
local prev=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
669
|
+
|
|
670
|
+
show_branch_log "$branch" "$prev" "$current"
|
|
671
|
+
return
|
|
672
|
+
fi
|
|
673
|
+
|
|
674
|
+
# Show all branches
|
|
675
|
+
echo ""
|
|
676
|
+
local prev=""
|
|
677
|
+
while IFS= read -r branch; do
|
|
678
|
+
if [ -n "$prev" ]; then
|
|
679
|
+
show_branch_log "$branch" "$prev" "$current"
|
|
680
|
+
else
|
|
681
|
+
if [ "$branch" = "$current" ]; then
|
|
682
|
+
echo "● $branch (base) ◀ you are here"
|
|
683
|
+
else
|
|
684
|
+
echo "○ $branch (base)"
|
|
685
|
+
fi
|
|
686
|
+
fi
|
|
687
|
+
prev="$branch"
|
|
688
|
+
done <<< "$stack"
|
|
689
|
+
echo ""
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
cmd_land() {
|
|
693
|
+
ensure_clean
|
|
694
|
+
local force=false
|
|
695
|
+
[ "$1" = "--force" ] || [ "$1" = "-f" ] && force=true
|
|
696
|
+
|
|
697
|
+
local stack=$(load_stack)
|
|
698
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
699
|
+
|
|
700
|
+
local base=$(echo "$stack" | head -n 1)
|
|
701
|
+
local first=$(echo "$stack" | sed -n '2p')
|
|
702
|
+
[ -z "$first" ] && die "no branches to land"
|
|
703
|
+
|
|
704
|
+
# Check if first branch is merged into base
|
|
705
|
+
git checkout "$base" --quiet
|
|
706
|
+
git fetch origin "$base" --quiet 2>/dev/null || true
|
|
707
|
+
|
|
708
|
+
if ! git merge-base --is-ancestor "$first" "$base" 2>/dev/null; then
|
|
709
|
+
if [ "$force" = true ]; then
|
|
710
|
+
echo "Warning: '$first' is not an ancestor of '$base' (squash merge?)"
|
|
711
|
+
echo "Proceeding with --force..."
|
|
712
|
+
else
|
|
713
|
+
echo "error: '$first' is not yet merged into '$base'"
|
|
714
|
+
echo ""
|
|
715
|
+
echo "If the PR was squash-merged, use:"
|
|
716
|
+
echo " stak land --force"
|
|
717
|
+
exit 1
|
|
718
|
+
fi
|
|
719
|
+
else
|
|
720
|
+
echo "'$first' is merged into '$base'. Cleaning up stack..."
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
# Remove the landed branch from stack
|
|
724
|
+
local new_stack=$(echo "$stack" | sed '2d')
|
|
725
|
+
save_stack "$new_stack"
|
|
726
|
+
|
|
727
|
+
# Delete the local branch
|
|
728
|
+
git branch -D "$first" 2>/dev/null || true
|
|
729
|
+
|
|
730
|
+
# Sync remaining stack onto updated base
|
|
731
|
+
local remaining=$(echo "$new_stack" | wc -l | tr -d ' ')
|
|
732
|
+
if [ "$remaining" -gt 1 ]; then
|
|
733
|
+
echo "Syncing remaining stack onto '$base'..."
|
|
734
|
+
cmd_sync
|
|
735
|
+
fi
|
|
736
|
+
|
|
737
|
+
echo "Landed '$first'. Stack updated."
|
|
738
|
+
cmd_status
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
do_fold() {
|
|
742
|
+
local branch_to_fold="$1"
|
|
743
|
+
local direction="$2"
|
|
744
|
+
local stack=$(load_stack)
|
|
745
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
746
|
+
local pos=$(find_in_stack "$branch_to_fold" "$stack")
|
|
747
|
+
|
|
748
|
+
if [ "$direction" = "up" ]; then
|
|
749
|
+
local parent=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
750
|
+
|
|
751
|
+
echo "Folding '$branch_to_fold' up into '$parent'..."
|
|
752
|
+
|
|
753
|
+
git checkout "$parent" --quiet
|
|
754
|
+
git reset --hard "$branch_to_fold" --quiet
|
|
755
|
+
|
|
756
|
+
local new_stack=$(echo "$stack" | sed "${pos}d")
|
|
757
|
+
save_stack "$new_stack"
|
|
758
|
+
|
|
759
|
+
git branch -D "$branch_to_fold" 2>/dev/null
|
|
760
|
+
|
|
761
|
+
echo "Folded '$branch_to_fold' into '$parent'."
|
|
762
|
+
[ "$pos" -lt "$total" ] && echo "Branches above are still correctly based."
|
|
763
|
+
else
|
|
764
|
+
local child=$(echo "$stack" | sed -n "$((pos + 1))p")
|
|
765
|
+
|
|
766
|
+
echo "Folding '$branch_to_fold' down into '$child'..."
|
|
767
|
+
|
|
768
|
+
local new_stack=$(echo "$stack" | sed "${pos}d")
|
|
769
|
+
save_stack "$new_stack"
|
|
770
|
+
|
|
771
|
+
git checkout "$child" --quiet
|
|
772
|
+
git branch -D "$branch_to_fold" 2>/dev/null
|
|
773
|
+
|
|
774
|
+
echo "Folded '$branch_to_fold' into '$child'."
|
|
775
|
+
fi
|
|
776
|
+
|
|
777
|
+
cmd_status
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
cmd_fold() {
|
|
781
|
+
ensure_clean
|
|
782
|
+
local stack=$(load_stack)
|
|
783
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
784
|
+
|
|
785
|
+
local total=$(echo "$stack" | wc -l | tr -d ' ')
|
|
786
|
+
local arg="$1"
|
|
787
|
+
|
|
788
|
+
# Interactive mode with fzf (no args)
|
|
789
|
+
if [ -z "$arg" ] && has_fzf; then
|
|
790
|
+
# Step 1: Select branch to fold
|
|
791
|
+
local display=$(build_stack_display "$stack")
|
|
792
|
+
# Filter: can't fold base (pos 1)
|
|
793
|
+
local foldable=$(echo "$display" | tail -n +2) # skip first line (base)
|
|
794
|
+
|
|
795
|
+
[ -z "$foldable" ] && die "no branches to fold"
|
|
796
|
+
|
|
797
|
+
local selected=$(echo "$foldable" | fzf --prompt="fold❯ " --header="Select branch to fold" $FZF_OPTS)
|
|
798
|
+
[ -z "$selected" ] && exit 0
|
|
799
|
+
|
|
800
|
+
local branch_to_fold=$(extract_branch "$selected")
|
|
801
|
+
local pos=$(find_in_stack "$branch_to_fold" "$stack")
|
|
802
|
+
|
|
803
|
+
# Step 2: Select direction
|
|
804
|
+
local directions=""
|
|
805
|
+
local parent=$(echo "$stack" | sed -n "$((pos - 1))p")
|
|
806
|
+
local child=$(echo "$stack" | sed -n "$((pos + 1))p")
|
|
807
|
+
|
|
808
|
+
# Can fold up if not into base (pos > 2)
|
|
809
|
+
[ "$pos" -gt 2 ] && directions+="↑ up into $parent"$'\n'
|
|
810
|
+
# Can fold down if not at top
|
|
811
|
+
[ "$pos" -lt "$total" ] && directions+="↓ down into $child"$'\n'
|
|
812
|
+
|
|
813
|
+
directions=$(echo "$directions" | sed '/^$/d')
|
|
814
|
+
|
|
815
|
+
if [ -z "$directions" ]; then
|
|
816
|
+
die "cannot fold '$branch_to_fold' - no valid direction"
|
|
817
|
+
elif [ $(echo "$directions" | wc -l) -eq 1 ]; then
|
|
818
|
+
# Only one option, use it
|
|
819
|
+
local dir_choice="$directions"
|
|
820
|
+
else
|
|
821
|
+
local dir_choice=$(echo "$directions" | fzf --prompt="❯ " --header="Fold '$branch_to_fold' into:" $FZF_OPTS)
|
|
822
|
+
[ -z "$dir_choice" ] && exit 0
|
|
823
|
+
fi
|
|
824
|
+
|
|
825
|
+
if [[ "$dir_choice" == ↑* ]]; then
|
|
826
|
+
do_fold "$branch_to_fold" "up"
|
|
827
|
+
else
|
|
828
|
+
do_fold "$branch_to_fold" "down"
|
|
829
|
+
fi
|
|
830
|
+
return
|
|
831
|
+
fi
|
|
832
|
+
|
|
833
|
+
# Non-interactive mode (current behavior)
|
|
834
|
+
local direction="up"
|
|
835
|
+
[ "$arg" = "--down" ] || [ "$arg" = "-d" ] && direction="down"
|
|
836
|
+
|
|
837
|
+
local current=$(current_branch)
|
|
838
|
+
local pos=$(find_in_stack "$current" "$stack")
|
|
839
|
+
[ -z "$pos" ] && die "'$current' is not in the stack"
|
|
840
|
+
[ "$pos" -eq 1 ] && die "cannot fold the base of the stack"
|
|
841
|
+
|
|
842
|
+
if [ "$direction" = "up" ]; then
|
|
843
|
+
[ "$pos" -eq 2 ] && die "cannot fold into the base - use 'land' after merging"
|
|
844
|
+
else
|
|
845
|
+
[ "$pos" -eq "$total" ] && die "cannot fold down - no branch above"
|
|
846
|
+
fi
|
|
847
|
+
|
|
848
|
+
do_fold "$current" "$direction"
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
cmd_goto() {
|
|
852
|
+
local current=$(current_branch)
|
|
853
|
+
local stack=$(load_stack)
|
|
854
|
+
[ -z "$stack" ] && die "no stack exists"
|
|
855
|
+
|
|
856
|
+
local target="$1"
|
|
857
|
+
local display=$(build_stack_display "$stack")
|
|
858
|
+
|
|
859
|
+
if [ -n "$target" ]; then
|
|
860
|
+
# Direct jump by number or name
|
|
861
|
+
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
|
862
|
+
local branch=$(echo "$stack" | sed -n "${target}p")
|
|
863
|
+
[ -z "$branch" ] && die "invalid position: $target"
|
|
864
|
+
else
|
|
865
|
+
local branch=$(echo "$stack" | grep -F "$target" | head -n1)
|
|
866
|
+
[ -z "$branch" ] && die "branch not found: $target"
|
|
867
|
+
fi
|
|
868
|
+
elif has_fzf; then
|
|
869
|
+
local selected=$(echo "$display" | fzf --prompt="goto❯ " --header="Select branch (↑/↓ move, Enter select)" $FZF_OPTS)
|
|
870
|
+
[ -z "$selected" ] && exit 0
|
|
871
|
+
local branch=$(extract_branch "$selected")
|
|
872
|
+
else
|
|
873
|
+
echo "fzf not found. Options:"
|
|
874
|
+
echo ""
|
|
875
|
+
echo " stak goto <number> Jump by position"
|
|
876
|
+
echo " stak goto <name> Jump by branch name"
|
|
877
|
+
echo " stak setup-interactive Install fzf"
|
|
878
|
+
echo ""
|
|
879
|
+
echo "Current stack:"
|
|
880
|
+
echo "$display"
|
|
881
|
+
exit 1
|
|
882
|
+
fi
|
|
883
|
+
|
|
884
|
+
[ "$branch" = "$current" ] && echo "Already on '$branch'" && exit 0
|
|
885
|
+
|
|
886
|
+
git checkout "$branch"
|
|
887
|
+
echo "Switched to '$branch'"
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
cmd_continue() {
|
|
891
|
+
# Check if we're in a rebase
|
|
892
|
+
if [ ! -d ".git/rebase-merge" ] && [ ! -d ".git/rebase-apply" ]; then
|
|
893
|
+
die "no rebase in progress"
|
|
894
|
+
fi
|
|
895
|
+
|
|
896
|
+
echo "Continuing rebase..."
|
|
897
|
+
git rebase --continue || {
|
|
898
|
+
echo ""
|
|
899
|
+
echo "Still have conflicts. Fix them, then run:"
|
|
900
|
+
echo " git add <files>"
|
|
901
|
+
echo " stak continue"
|
|
902
|
+
exit 1
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
echo ""
|
|
906
|
+
echo "Rebase continued. Running sync to finish remaining branches..."
|
|
907
|
+
cmd_sync
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
cmd_abort() {
|
|
911
|
+
# Check if we're in a rebase
|
|
912
|
+
if [ ! -d ".git/rebase-merge" ] && [ ! -d ".git/rebase-apply" ]; then
|
|
913
|
+
die "no rebase in progress"
|
|
914
|
+
fi
|
|
915
|
+
|
|
916
|
+
git rebase --abort
|
|
917
|
+
echo "Rebase aborted. Stack unchanged."
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
cmd_setup_interactive() {
|
|
921
|
+
echo "Stack Interactive Setup"
|
|
922
|
+
echo "========================"
|
|
923
|
+
echo ""
|
|
924
|
+
|
|
925
|
+
# Check if fzf already installed
|
|
926
|
+
if command -v fzf >/dev/null 2>&1; then
|
|
927
|
+
echo "✓ fzf is already installed"
|
|
928
|
+
echo ""
|
|
929
|
+
echo "Interactive mode is ready. Try: stak goto"
|
|
930
|
+
exit 0
|
|
931
|
+
fi
|
|
932
|
+
|
|
933
|
+
echo "fzf is required for interactive branch selection."
|
|
934
|
+
echo ""
|
|
935
|
+
|
|
936
|
+
# Detect platform and package manager
|
|
937
|
+
local pkg_manager=""
|
|
938
|
+
local install_cmd=""
|
|
939
|
+
|
|
940
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
941
|
+
if command -v brew >/dev/null 2>&1; then
|
|
942
|
+
pkg_manager="Homebrew"
|
|
943
|
+
install_cmd="brew install fzf"
|
|
944
|
+
else
|
|
945
|
+
echo "macOS detected but Homebrew not found."
|
|
946
|
+
echo "Install Homebrew first: https://brew.sh"
|
|
947
|
+
echo "Then run: brew install fzf"
|
|
948
|
+
exit 1
|
|
949
|
+
fi
|
|
950
|
+
elif command -v apt >/dev/null 2>&1; then
|
|
951
|
+
pkg_manager="apt"
|
|
952
|
+
install_cmd="sudo apt install -y fzf"
|
|
953
|
+
elif command -v dnf >/dev/null 2>&1; then
|
|
954
|
+
pkg_manager="dnf"
|
|
955
|
+
install_cmd="sudo dnf install -y fzf"
|
|
956
|
+
elif command -v yum >/dev/null 2>&1; then
|
|
957
|
+
pkg_manager="yum"
|
|
958
|
+
install_cmd="sudo yum install -y fzf"
|
|
959
|
+
elif command -v pacman >/dev/null 2>&1; then
|
|
960
|
+
pkg_manager="pacman"
|
|
961
|
+
install_cmd="sudo pacman -S --noconfirm fzf"
|
|
962
|
+
elif command -v apk >/dev/null 2>&1; then
|
|
963
|
+
pkg_manager="apk"
|
|
964
|
+
install_cmd="sudo apk add fzf"
|
|
965
|
+
else
|
|
966
|
+
echo "Could not detect package manager."
|
|
967
|
+
echo ""
|
|
968
|
+
echo "Install fzf manually:"
|
|
969
|
+
echo " https://github.com/junegunn/fzf#installation"
|
|
970
|
+
exit 1
|
|
971
|
+
fi
|
|
972
|
+
|
|
973
|
+
echo "Detected: $pkg_manager"
|
|
974
|
+
echo "Command: $install_cmd"
|
|
975
|
+
echo ""
|
|
976
|
+
printf "Install fzf now? [y/N]: "
|
|
977
|
+
read -r confirm
|
|
978
|
+
|
|
979
|
+
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
|
980
|
+
echo ""
|
|
981
|
+
echo "Running: $install_cmd"
|
|
982
|
+
echo ""
|
|
983
|
+
eval "$install_cmd" || {
|
|
984
|
+
echo ""
|
|
985
|
+
echo "Installation failed. Try running manually:"
|
|
986
|
+
echo " $install_cmd"
|
|
987
|
+
exit 1
|
|
988
|
+
}
|
|
989
|
+
echo ""
|
|
990
|
+
echo "✓ fzf installed successfully"
|
|
991
|
+
echo ""
|
|
992
|
+
echo "Interactive mode is ready. Try: stak goto"
|
|
993
|
+
else
|
|
994
|
+
echo ""
|
|
995
|
+
echo "Skipped. To install later, run:"
|
|
996
|
+
echo " $install_cmd"
|
|
997
|
+
fi
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
cmd_stacks() {
|
|
1001
|
+
mkdir -p "$STACKS_DIR"
|
|
1002
|
+
local current=$(current_stack_name)
|
|
1003
|
+
|
|
1004
|
+
echo ""
|
|
1005
|
+
echo "Stacks:"
|
|
1006
|
+
echo ""
|
|
1007
|
+
|
|
1008
|
+
if [ ! -d "$STACKS_DIR" ] || [ -z "$(ls -A "$STACKS_DIR" 2>/dev/null)" ]; then
|
|
1009
|
+
echo " (no stacks yet)"
|
|
1010
|
+
else
|
|
1011
|
+
for sf in "$STACKS_DIR"/*; do
|
|
1012
|
+
[ -f "$sf" ] || continue
|
|
1013
|
+
local name=$(basename "$sf")
|
|
1014
|
+
local count=$(wc -l < "$sf" | tr -d ' ')
|
|
1015
|
+
local branches=$((count - 1))
|
|
1016
|
+
[ $branches -lt 0 ] && branches=0
|
|
1017
|
+
local label="branches"
|
|
1018
|
+
[ $branches -eq 1 ] && label="branch"
|
|
1019
|
+
|
|
1020
|
+
if [ "$name" = "$current" ]; then
|
|
1021
|
+
echo " ● $name ($branches $label) ◀ current"
|
|
1022
|
+
else
|
|
1023
|
+
echo " ○ $name ($branches $label)"
|
|
1024
|
+
fi
|
|
1025
|
+
done
|
|
1026
|
+
fi
|
|
1027
|
+
echo ""
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
cmd_use() {
|
|
1031
|
+
local name="$1"
|
|
1032
|
+
|
|
1033
|
+
# Interactive mode if no argument
|
|
1034
|
+
if [ -z "$name" ]; then
|
|
1035
|
+
if ! has_fzf; then
|
|
1036
|
+
die "usage: stak use <name>"
|
|
1037
|
+
fi
|
|
1038
|
+
|
|
1039
|
+
# Build stack list for fzf
|
|
1040
|
+
local current=$(current_stack_name)
|
|
1041
|
+
local stacks=""
|
|
1042
|
+
for sf in "$STACKS_DIR"/*; do
|
|
1043
|
+
[ ! -f "$sf" ] && continue
|
|
1044
|
+
local sname=$(basename "$sf")
|
|
1045
|
+
local count=$(wc -l < "$sf" | tr -d ' ')
|
|
1046
|
+
local branches=$((count - 1))
|
|
1047
|
+
[ $branches -lt 0 ] && branches=0
|
|
1048
|
+
local label="branches"
|
|
1049
|
+
[ $branches -eq 1 ] && label="branch"
|
|
1050
|
+
|
|
1051
|
+
if [ "$sname" = "$current" ]; then
|
|
1052
|
+
stacks+="● $sname ($branches $label) ◀ current"$'\n'
|
|
1053
|
+
else
|
|
1054
|
+
stacks+="○ $sname ($branches $label)"$'\n'
|
|
1055
|
+
fi
|
|
1056
|
+
done
|
|
1057
|
+
|
|
1058
|
+
[ -z "$stacks" ] && die "no stacks exist"
|
|
1059
|
+
|
|
1060
|
+
local selected=$(echo "$stacks" | fzf --prompt="use❯ " --header="Select stack to switch to" $FZF_OPTS)
|
|
1061
|
+
[ -z "$selected" ] && exit 0
|
|
1062
|
+
|
|
1063
|
+
# Extract stack name (second field after marker)
|
|
1064
|
+
name=$(echo "$selected" | awk '{print $2}')
|
|
1065
|
+
fi
|
|
1066
|
+
|
|
1067
|
+
validate_stack_name "$name"
|
|
1068
|
+
local sf="$STACKS_DIR/$name"
|
|
1069
|
+
mkdir -p "$STACKS_DIR"
|
|
1070
|
+
|
|
1071
|
+
if [ -f "$sf" ]; then
|
|
1072
|
+
# Stack exists - switch to it
|
|
1073
|
+
local current=$(current_stack_name)
|
|
1074
|
+
if [ "$name" = "$current" ]; then
|
|
1075
|
+
echo "Already on stack '$name'"
|
|
1076
|
+
else
|
|
1077
|
+
echo "$name" > "$CURRENT_STACK_FILE"
|
|
1078
|
+
echo "Switched to stack '$name'"
|
|
1079
|
+
fi
|
|
1080
|
+
else
|
|
1081
|
+
# Create new stack
|
|
1082
|
+
touch "$sf"
|
|
1083
|
+
echo "$name" > "$CURRENT_STACK_FILE"
|
|
1084
|
+
echo "Created stack '$name'"
|
|
1085
|
+
echo "Use 'stak new <branch>' to add your first branch."
|
|
1086
|
+
return
|
|
1087
|
+
fi
|
|
1088
|
+
cmd_status
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
cmd_rm_stack() {
|
|
1092
|
+
local name="$1"
|
|
1093
|
+
local current=$(current_stack_name)
|
|
1094
|
+
|
|
1095
|
+
# Interactive mode if no argument
|
|
1096
|
+
if [ -z "$name" ]; then
|
|
1097
|
+
if ! has_fzf; then
|
|
1098
|
+
die "usage: stak rm-stack <name>"
|
|
1099
|
+
fi
|
|
1100
|
+
|
|
1101
|
+
# Build list of deletable stacks (exclude current)
|
|
1102
|
+
local stacks=""
|
|
1103
|
+
for sf in "$STACKS_DIR"/*; do
|
|
1104
|
+
[ ! -f "$sf" ] && continue
|
|
1105
|
+
local sname=$(basename "$sf")
|
|
1106
|
+
[ "$sname" = "$current" ] && continue # Can't delete current
|
|
1107
|
+
|
|
1108
|
+
local count=$(wc -l < "$sf" | tr -d ' ')
|
|
1109
|
+
local branches=$((count - 1))
|
|
1110
|
+
[ $branches -lt 0 ] && branches=0
|
|
1111
|
+
local label="branches"
|
|
1112
|
+
[ $branches -eq 1 ] && label="branch"
|
|
1113
|
+
|
|
1114
|
+
stacks+="○ $sname ($branches $label)"$'\n'
|
|
1115
|
+
done
|
|
1116
|
+
|
|
1117
|
+
[ -z "$stacks" ] && die "no other stacks to delete (can't delete current stack)"
|
|
1118
|
+
|
|
1119
|
+
local selected=$(echo "$stacks" | fzf --prompt="delete❯ " --header="Select stack to delete" $FZF_OPTS)
|
|
1120
|
+
[ -z "$selected" ] && exit 0
|
|
1121
|
+
|
|
1122
|
+
name=$(echo "$selected" | awk '{print $2}')
|
|
1123
|
+
fi
|
|
1124
|
+
|
|
1125
|
+
validate_stack_name "$name"
|
|
1126
|
+
local sf="$STACKS_DIR/$name"
|
|
1127
|
+
[ ! -f "$sf" ] && die "stack '$name' does not exist"
|
|
1128
|
+
[ "$name" = "$current" ] && die "cannot delete current stack - switch first"
|
|
1129
|
+
|
|
1130
|
+
rm "$sf"
|
|
1131
|
+
echo "Deleted stack '$name'"
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
cmd_help() {
|
|
1135
|
+
cat << 'EOF'
|
|
1136
|
+
stak - minimal stacked changes for git
|
|
1137
|
+
|
|
1138
|
+
Branch Commands:
|
|
1139
|
+
new <name> Create a new branch on top of current
|
|
1140
|
+
insert [name] Insert branch at any position (fzf)
|
|
1141
|
+
split [name] Split current branch's commits (fzf)
|
|
1142
|
+
up / down Move up or down the stack
|
|
1143
|
+
goto [n|name] Jump to branch (interactive with fzf)
|
|
1144
|
+
status Show the current stack
|
|
1145
|
+
sync Rebase entire stack
|
|
1146
|
+
continue Continue after resolving conflicts
|
|
1147
|
+
abort Abort current rebase
|
|
1148
|
+
push [-a] Push branches (interactive, -a for all)
|
|
1149
|
+
drop Remove top branch
|
|
1150
|
+
fold [--down] Fold branch into parent or child
|
|
1151
|
+
land [-f] Clean up after PR merge (-f for squash)
|
|
1152
|
+
log [n|name] Show commits (-a for all)
|
|
1153
|
+
|
|
1154
|
+
Stack Management:
|
|
1155
|
+
ls List all stacks
|
|
1156
|
+
use [name] Switch to stack (fzf or creates if needed)
|
|
1157
|
+
rm-stack [name] Delete a stack (fzf or by name)
|
|
1158
|
+
|
|
1159
|
+
Setup:
|
|
1160
|
+
setup-interactive Install fzf for interactive mode
|
|
1161
|
+
|
|
1162
|
+
Environment:
|
|
1163
|
+
STAK_NO_INTERACTIVE=1 Disable fzf interactive mode
|
|
1164
|
+
|
|
1165
|
+
Workflow:
|
|
1166
|
+
1. Start on main/master
|
|
1167
|
+
2. stak new feature-part1 # create first branch
|
|
1168
|
+
3. <make changes, commit>
|
|
1169
|
+
4. stak new feature-part2 # create second branch on top
|
|
1170
|
+
5. <make changes, commit>
|
|
1171
|
+
6. stak status # see your stack
|
|
1172
|
+
7. stak down # go back to part1
|
|
1173
|
+
8. <fix something, commit>
|
|
1174
|
+
9. stak sync # rebase part2 onto updated part1
|
|
1175
|
+
10. stak push # push all for review
|
|
1176
|
+
EOF
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
# Main
|
|
1180
|
+
# Commands that don't require git
|
|
1181
|
+
case "${1:-}" in
|
|
1182
|
+
setup-interactive) cmd_setup_interactive; exit 0 ;;
|
|
1183
|
+
help|--help|-h|"") cmd_help; exit 0 ;;
|
|
1184
|
+
esac
|
|
1185
|
+
|
|
1186
|
+
ensure_git
|
|
1187
|
+
migrate_old_stack
|
|
1188
|
+
|
|
1189
|
+
case "${1:-}" in
|
|
1190
|
+
new) cmd_new "$2" ;;
|
|
1191
|
+
insert) cmd_insert "$2" "$3" "$4" ;;
|
|
1192
|
+
split) cmd_split "$2" ;;
|
|
1193
|
+
up) cmd_up ;;
|
|
1194
|
+
down) cmd_down ;;
|
|
1195
|
+
goto) cmd_goto "$2" ;;
|
|
1196
|
+
status|st) cmd_status ;;
|
|
1197
|
+
sync) cmd_sync ;;
|
|
1198
|
+
continue) cmd_continue ;;
|
|
1199
|
+
abort) cmd_abort ;;
|
|
1200
|
+
push) cmd_push "$2" ;;
|
|
1201
|
+
drop) cmd_drop ;;
|
|
1202
|
+
fold) cmd_fold "$2" ;;
|
|
1203
|
+
land) cmd_land "$2" ;;
|
|
1204
|
+
log) cmd_log "$2" ;;
|
|
1205
|
+
ls) cmd_stacks ;;
|
|
1206
|
+
use) cmd_use "$2" ;;
|
|
1207
|
+
rm-stack) cmd_rm_stack "$2" ;;
|
|
1208
|
+
*) die "unknown command: $1" ;;
|
|
1209
|
+
esac
|