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