-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodespace-stack
More file actions
executable file
·1224 lines (1060 loc) · 35.7 KB
/
codespace-stack
File metadata and controls
executable file
·1224 lines (1060 loc) · 35.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
set -euo pipefail
DIRNAME="$(dirname "$(realpath "$0")")"
# shellcheck source=codespace-utils
source "$DIRNAME/codespace-utils"
# auto-detect agent environments and disable interactive prompts
test -n "${CS_NO_INTERACTIVE:-}" || {
test -n "${CURSOR_AGENT:-}" || test -n "${CI:-}" && {
export CS_NO_INTERACTIVE=1
}
}
# codespace-stack: multi-repo codespace management
REPO_VALUES_INFO="\
- repo names (siblings in org directory)
- clone URLs
- objects: { \"name\": \"repo\", \"cloneURL\": \"url\" }"
STACK_HELP="\
Usage:
codespace stack [create] <branch> [-s stack_name] [-b base] [--clone|--worktree]
codespace stack init [<path>]
codespace stack extend <name>[,<name2>]...
<branch> branch name to create across all repos in the stack.
<name> stack config name from stacks.json, or repo name from org directory.
optional flags:
-b, --base <branch>
base branch to create from (default: remote HEAD).
-s, --stack <stack_name>
stack config name from stacks.json (default: \"default\").
--clone force clone mode (fresh clones for all repos).
--worktree force worktree mode (create worktrees from local repos).
env vars:
CS_STACK_DEFAULT_CREATE_MODE
default creation mode: \"worktree\" (default) or \"clone\".
CS_NO_INTERACTIVE
if set, skip interactive prompts and use defaults.
inferred if CURSOR_AGENT, CI, other vars are set.
config:
stacks.json located in \$CODESPACE_CONFIG_ROOT/<org>/stacks.json
format: { \"version\": \"0\", \"stacks\": { \"stack-id\": [\"repo1\", \"repo2\"] } }
repo values:
$(echo "$REPO_VALUES_INFO" | sed 's/^/ /')
sub-commands:
create <branch> [-s stack_name] [-b base] [--clone|--worktree]
create a new stack with repos from a stack configuration.
\"create\" is implied if omitted.
post-create [-s stack_name]
run the stack post-create script in an existing stack.
must be run from within a stack directory (or child).
[-s stack_name] specifies which stack config to use (default: \"default\").
extend <name>[,<name2>]...
extend current stack with repos from stack configs or repo names.
first checks stacks.json, then falls back to repos in <org> directory.
must be run from within an existing stack directory (or child).
uses the same creation mode (clone/worktree) as existing repos.
init [<path>] create a stacks.json configuration file in CODESPACE_CONFIG_ROOT.
<path> is the directory where stacks will be held.
if <path> is provided, uses that directory.
otherwise, prompts to select current or parent directory.
examples:
codespace stack create feature1 # create stack \"feature1\",
# add repos defined in \"default\" stack config in stacks.json,
# checkout each repo into \"feature1\" branch.
codespace stack create feature1 -s config2 # create stack from \"config2\" config in stacks.json.
codespace stack create feature1 -b develop # base the stack off of the \"develop\" branch,
# instead of repository's default branch.
codespace stack create feature1 --clone # create clones, instead of worktrees.
codespace stack post-create # run stack post-create script (uses \"default\" config)
codespace stack post-create -s full # run post-create using \"full\" stack config
codespace stack extend be # add repos from \"be\" stack config
codespace stack extend be,infra # add repos from \"be\" and \"infra\" stack configs
codespace stack extend backend # add \"backend\" repo directly
codespace stack init # create stacks.json config file (interactive)
codespace stack init ~/projects/myorg # create config for stacks held in specified path
"
cs_stack_main() {
# handle help
case "${1:-}" in
-h|--help)
echo "$STACK_HELP"
exit 0
;;
"")
>&2 echo "$STACK_HELP"
exit 1
;;
esac
# dispatch subcommands
cmd="$1"
shift
case "$cmd" in
create)
cs_stack_create "$@"
;;
init)
cs_stack_init "$@"
;;
init-repo)
# internal subcommand used by cs_stack_create for parallel repo init
cs_stack_init_repo "$@"
;;
extend)
cs_stack_extend "$@"
;;
post-create)
cs_stack_post_create "$@"
;;
*)
# implied create
cs_stack_create "$cmd" "$@"
;;
esac
}
# Parse and validate create arguments
# Sets globals: branch, stack_name, create_mode, base_branch
cs_stack_create_parse_args() {
branch=""
stack_name="default"
base_branch=""
local mode_flag=""
while [ $# -gt 0 ]; do
case "$1" in
-s|--stack)
shift
stack_name="${1:-}"
test -n "$stack_name" || {
>&2 echo "err: -s requires a stack name"
exit 1
}
;;
-b|--base)
shift
base_branch="${1:-}"
test -n "$base_branch" || {
>&2 echo "err: -b requires a base branch"
exit 1
}
;;
--clone)
mode_flag="clone"
;;
--worktree)
mode_flag="worktree"
;;
-*)
>&2 echo "err: unknown option: $1"
exit 1
;;
*)
if [ -z "$branch" ]; then
branch="$1"
else
>&2 echo "err: unexpected argument: $1"
exit 1
fi
;;
esac
shift
done
test -n "$branch" || {
>&2 echo "err: <branch> is required"
>&2 echo "usage: codespace stack [create] <branch> [-s stack_name]"
exit 1
}
# determine creation mode (priority: flag > env > default)
if [ -n "$mode_flag" ]; then
create_mode="$mode_flag"
elif [ -n "${CS_STACK_DEFAULT_CREATE_MODE:-}" ]; then
create_mode="$CS_STACK_DEFAULT_CREATE_MODE"
else
create_mode="worktree"
fi
}
# Setup stack directory structure
# Uses globals: branch, org_dir
# Sets globals: stack_dir, logs_dir
cs_stack_create_setup_dirs() {
stack_dir="$org_dir/stack_$(cs_cleanpath "$branch")"
if [ -d "$stack_dir" ]; then
>&2 echo "err: stack directory already exists: $stack_dir"
exit 1
fi
mkdir -p "$stack_dir"
logs_dir="$stack_dir/.stack-init-logs"
mkdir -p "$logs_dir"
}
# Setup tmux session if available
# Uses globals: branch, logs_dir
# Sets globals: use_tmux, tmux_session
cs_stack_create_setup_tmux() {
use_tmux=""
tmux_session=""
if [ -z "${CS_NO_INTERACTIVE:-}" ] && command -v tmux >/dev/null 2>&1; then
use_tmux=1
tmux_session="stack-init-$(cs_cleanpath "$branch")"
# kill existing session if present (e.g. from previous extend that was interrupted)
tmux kill-session -t "$tmux_session" 2>/dev/null || true
# create session with placeholder window (will be killed after repo windows are created)
tmux new-session -d -s "$tmux_session" -n "_placeholder"
# increase scrollback buffer so user can see all init logs
tmux set-option -t "$tmux_session" history-limit 50000
# enable mouse mode for terminal scrolling
tmux set-option -t "$tmux_session" mouse on
fi
}
# Pre-parse all repos into arrays
# Args: $1 = repos (newline-separated JSON entries)
# Uses globals: stack_dir, org_dir
# Sets globals: repo_names[], repo_clone_urls[], repo_dests[], repo_base_repos[]
cs_stack_create_prepare_repos() {
local repos="$1"
repo_names=()
repo_clone_urls=()
repo_dests=()
repo_base_repos=()
while IFS= read -r repo_entry; do
test -n "$repo_entry" || continue
cs_stack_parse_repo_entry "$repo_entry"
# repo_name and clone_url are set by cs_stack_parse_repo_entry
local local_repo_name="$repo_name"
local local_clone_url="$clone_url"
local repo_dest="$stack_dir/$local_repo_name"
local base_repo="$org_dir/$local_repo_name"
# Resolve clone URL if needed (repo doesn't exist locally and no URL provided)
if [ -z "$local_clone_url" ] && [ ! -d "$base_repo/.git" ]; then
local_clone_url="$(cs_infer_clone_url "$local_repo_name" "$org_dir")" || {
>&2 echo "err: repo '$local_repo_name' not found locally and couldn't infer clone URL"
>&2 echo "options:"
>&2 echo " a) clone the repo: git clone <url> $base_repo"
>&2 echo " b) add clone URL to stacks.json"
exit 1
}
fi
repo_names+=("$local_repo_name")
repo_clone_urls+=("$local_clone_url")
repo_dests+=("$repo_dest")
repo_base_repos+=("$base_repo")
done <<< "$repos"
}
# Spawn parallel repo init jobs
# Uses globals: create_mode, repo_names[], repo_clone_urls[], repo_dests[],
# repo_base_repos[], logs_dir, use_tmux, tmux_session, branch, base_branch
# Sets globals: repo_pids[]
cs_stack_create_spawn_jobs() {
>&2 echo "==> initializing repos (${#repo_names[@]} repos, mode: $create_mode)"
>&2 echo " logs: \"$logs_dir\""
if [ -n "$use_tmux" ]; then
>&2 echo " preview with:"
echo " tmux attach -t $tmux_session"
fi
repo_pids=()
for i in "${!repo_names[@]}"; do
local r_name="${repo_names[$i]}"
local r_clone_url="${repo_clone_urls[$i]}"
local r_dest="${repo_dests[$i]}"
local r_base="${repo_base_repos[$i]}"
local log_file="$logs_dir/$r_name.log"
if [ -n "$use_tmux" ]; then
# tmux mode: create window with shell, then send init command
# after init, open log in less with mouse support for scrolling (strip ^M with col -b)
tmux new-window -t "$tmux_session" -n "$r_name"
tmux send-keys -t "$tmux_session:$r_name" \
"'$DIRNAME/codespace-stack' init-repo '$create_mode' '$r_name' '$r_clone_url' '$r_dest' '$r_base' '$branch' '$base_branch' 2>&1 | tee '$log_file' && { echo '=== [$r_name] Init complete. Press q to exit, or scroll with mouse ==='; col -b < '$log_file' | less -R --mouse +G; }" Enter
# kill placeholder window after first real window is created
tmux kill-window -t "$tmux_session:_placeholder" 2>/dev/null || true
else
# no tmux: background job with file logging
(
"$DIRNAME/codespace-stack" init-repo "$create_mode" "$r_name" "$r_clone_url" "$r_dest" "$r_base" "$branch" "$base_branch"
) >"$log_file" 2>&1 &
repo_pids+=($!)
fi
done
}
# Spawn stack-level post-create script (runs in parallel with repo init jobs)
# Uses globals: stacks_json, stack_name, branch, stack_dir, create_mode, org_dir,
# repo_names[], logs_dir, use_tmux, tmux_session
# Sets globals: stack_post_create_spawned, stack_post_create_pid
cs_stack_spawn_stack_post_create() {
stack_post_create_spawned=""
stack_post_create_pid=""
# skip if stacks_json is not set (e.g. extend without config)
if [ -z "${stacks_json:-}" ]; then
return 0
fi
local script_path
script_path="$(cs_stack_get_post_create_config "$stack_name" "$stacks_json")"
[ -n "$script_path" ] || return 0
[ -f "$script_path" ] || { >&2 echo " note: stack post-create script not found: $script_path"; return 0; }
[ -x "$script_path" ] || { >&2 echo " warn: stack post-create not executable: $script_path"; return 0; }
local repos_csv config_dir log_file
repos_csv="$(IFS=,; echo "${repo_names[*]}")"
config_dir="$(dirname "$stacks_json")"
log_file="$logs_dir/stack-post-create.log"
>&2 echo "==> running stack post-create script"
>&2 echo " script: $script_path"
if [ -n "$use_tmux" ]; then
tmux new-window -t "$tmux_session" -n "stack-post-create"
tmux send-keys -t "$tmux_session:stack-post-create" \
"{ STACK_NAME='$stack_name' STACK_BRANCH='$branch' STACK_ROOT='$stack_dir' STACK_CONFIG_ROOT='$config_dir' STACK_REPOS='$repos_csv' STACK_CREATE_MODE='$create_mode' STACK_ORG_DIR='$org_dir' '$script_path' && echo '=== [stack-post-create] Done ===' || echo 'STACK_POST_CREATE_FAILED'; } 2>&1 | tee '$log_file'; echo 'Press q to exit, or scroll with mouse'; col -b < '$log_file' | less -R --mouse +G" Enter
else
(
STACK_NAME="$stack_name" STACK_BRANCH="$branch" STACK_ROOT="$stack_dir" \
STACK_CONFIG_ROOT="$config_dir" STACK_REPOS="$repos_csv" \
STACK_CREATE_MODE="$create_mode" STACK_ORG_DIR="$org_dir" \
"$script_path" && echo "=== [stack-post-create] Done ===" || echo "STACK_POST_CREATE_FAILED"
) >"$log_file" 2>&1 &
stack_post_create_pid=$!
fi
stack_post_create_spawned=1
}
# Wait for jobs and print summary
# Args: $1 = mode ("create" or "extend")
# Uses globals: use_tmux, tmux_session, repo_names[], logs_dir, repo_pids[], stack_dir,
# stack_post_create_spawned, stack_post_create_pid
cs_stack_create_wait_and_summarize() {
local mode="${1:-create}"
local failures=()
local stack_post_create_failed=""
if [ -n "$use_tmux" ]; then
# build list of jobs to wait for
local wait_jobs=("${repo_names[@]}")
if [ -n "${stack_post_create_spawned:-}" ]; then
wait_jobs+=("stack-post-create")
fi
cs_stack_tmux_wait "$logs_dir" "${wait_jobs[@]}"
# check logs for failures
for i in "${!repo_names[@]}"; do
local r_name="${repo_names[$i]}"
local log_file="$logs_dir/$r_name.log"
if [ -f "$log_file" ] && grep -qE "CLONE_FAILED|POST_CREATE_FAILED" "$log_file"; then
failures+=("$r_name")
fi
done
# check stack post-create log
if [ -n "${stack_post_create_spawned:-}" ]; then
local stack_log="$logs_dir/stack-post-create.log"
if [ -f "$stack_log" ] && grep -q "STACK_POST_CREATE_FAILED" "$stack_log"; then
stack_post_create_failed=1
fi
fi
else
for i in "${!repo_pids[@]}"; do
local pid="${repo_pids[$i]}"
local r_name="${repo_names[$i]}"
if ! wait "$pid"; then
failures+=("$r_name")
fi
done
# wait for stack post-create job
if [ -n "${stack_post_create_spawned:-}" ] && [ -n "${stack_post_create_pid:-}" ]; then
if ! wait "$stack_post_create_pid"; then
stack_post_create_failed=1
fi
fi
fi
local verb
if [ "$mode" = "extend" ]; then
verb="extended"
else
verb="created"
fi
echo
if [ ${#failures[@]} -gt 0 ] || [ -n "$stack_post_create_failed" ]; then
>&2 echo "==> stack $verb with errors:"
if [ ${#failures[@]} -gt 0 ]; then
>&2 echo " failed repos: ${failures[*]}"
fi
if [ -n "$stack_post_create_failed" ]; then
>&2 echo " stack post-create script failed"
fi
>&2 echo " logs: $logs_dir"
if [ -n "$use_tmux" ]; then
>&2 echo " tmux session: tmux attach -t $tmux_session"
fi
else
>&2 echo "==> stack $verb successfully"
# clean up logs on success (unless DEBUG is set)
if [ -z "${DEBUG:-}" ]; then
rm -rf "$logs_dir"
else
>&2 echo " logs: $logs_dir"
fi
# tmux session is kept alive for user to attach and inspect
fi
# print summary
if [ "$mode" = "extend" ]; then
echo " $stack_dir"
for r_name in "${repo_names[@]}"; do
echo " + $r_name"
done
else
echo " $stack_dir"
fi
echo ""
}
# Open editor for the stack
# Uses globals: stack_dir
cs_stack_create_open_editor() {
>&2 echo "==> opening editor"
GUI_EDITOR="${GUI_EDITOR:-$EDITOR}"
if [ -n "$GUI_EDITOR" ]; then
(unset GIT_DIR GIT_WORK_TREE; "$GUI_EDITOR" --new-window "$stack_dir") &
else
>&2 echo "warn: neither \$GUI_EDITOR nor \$EDITOR env var set, cannot open stack with editor"
fi
}
cs_stack_create() {
cs_stack_create_parse_args "$@"
# find stacks.json config and org directory
# cs_stack_find_config outputs two lines: config_path and org_dir
config_output="$(cs_stack_find_config)"
stacks_json="$(echo "$config_output" | head -n1)"
org_dir="$(echo "$config_output" | tail -n1)"
# get repos for the stack
repos="$(cs_stack_get_repos "$stack_name" "$stacks_json")"
test -n "$repos" || {
>&2 echo "err: stack '$stack_name' not found or empty in $stacks_json"
exit 1
}
cs_stack_create_setup_dirs
cs_stack_create_setup_tmux
cs_stack_create_prepare_repos "$repos"
cs_stack_create_open_editor
cs_stack_create_spawn_jobs
cs_stack_spawn_stack_post_create
cs_stack_create_wait_and_summarize "create"
}
# extend an existing stack with repos from other stack configurations or repo names
cs_stack_extend() {
# parse arguments
local extend_args="${1:-}"
test -n "$extend_args" || {
>&2 echo "err: <stack_name|repo_name> is required"
>&2 echo "usage: codespace stack extend <stack_name>[,<stack_name2>]..."
>&2 echo " accepts stack config names from stacks.json, or repo names from org directory"
exit 1
}
# detect current stack
local detect_output
detect_output="$(cs_stack_detect_current)" || {
>&2 echo "err: not inside a stack directory"
>&2 echo "hint: run this command from within a stack_* directory or a repo inside one"
exit 1
}
stack_dir="$(echo "$detect_output" | sed -n '1p')"
branch="$(echo "$detect_output" | sed -n '2p')"
org_dir="$(echo "$detect_output" | sed -n '3p')"
>&2 echo "==> extending stack: $stack_dir"
>&2 echo " branch: $branch"
# detect creation mode from existing repos
create_mode="$(cs_stack_detect_mode "$stack_dir")"
>&2 echo " mode: $create_mode (auto-detected from existing repos)"
# base_branch not used for extend (uses default branch of each repo)
base_branch=""
# find stacks.json config (optional for repo-name mode)
# note: stacks_json is global so cs_stack_spawn_stack_post_create can access it
stacks_json=""
if [ -n "${CODESPACE_CONFIG_ROOT:-}" ]; then
local rel_path
rel_path="$(realpath "$org_dir" --relative-to="$HOME" 2>/dev/null)" || rel_path="$org_dir"
stacks_json="$CODESPACE_CONFIG_ROOT/$rel_path/stacks.json"
test -f "$stacks_json" || stacks_json=""
fi
# stack_name for extend uses a special marker (only global post-create settings apply)
stack_name="_extend"
# get list of existing repos/directories in the stack
local existing_repos=()
for repo_dir in "$stack_dir"/*; do
test -d "$repo_dir" || continue
existing_repos+=("$(basename "$repo_dir")")
done
# collect repos from all specified stack configs or repo names
local all_new_repos=""
IFS=',' read -ra arg_names <<< "$extend_args"
for arg_name in "${arg_names[@]}"; do
# trim whitespace
arg_name="$(echo "$arg_name" | xargs)"
test -n "$arg_name" || continue
local repos=""
local source_type=""
# first, try as stack config name
if [ -n "$stacks_json" ]; then
repos="$(cs_stack_get_repos "$arg_name" "$stacks_json" 2>/dev/null)" && source_type="stack"
fi
# if not found as stack config, try as repo name in org directory
if [ -z "$source_type" ]; then
if [ -d "$org_dir/$arg_name/.git" ]; then
# repo exists locally - create a simple repo entry
repos="\"$arg_name\""
source_type="repo"
else
# check if we can infer clone URL
local inferred_url
if inferred_url="$(cs_infer_clone_url "$arg_name" "$org_dir" 2>/dev/null)"; then
repos="{ \"name\": \"$arg_name\", \"cloneURL\": \"$inferred_url\" }"
source_type="repo (inferred)"
else
>&2 echo "err: '$arg_name' not found as stack config or repo in $org_dir"
exit 1
fi
fi
fi
>&2 echo " adding from $source_type: $arg_name"
# filter out repos that already exist
while IFS= read -r repo_entry; do
test -n "$repo_entry" || continue
cs_stack_parse_repo_entry "$repo_entry"
# repo_name is set by cs_stack_parse_repo_entry
# check if already exists
local exists=""
for existing in "${existing_repos[@]}"; do
if [ "$existing" = "$repo_name" ]; then
exists=1
break
fi
done
if [ -n "$exists" ]; then
>&2 echo " skipping '$repo_name' (already in stack)"
else
if [ -z "$all_new_repos" ]; then
all_new_repos="$repo_entry"
else
all_new_repos="$all_new_repos"$'\n'"$repo_entry"
fi
# add to existing list to avoid duplicates across configs
existing_repos+=("$repo_name")
fi
done <<< "$repos"
done
# check if there are any new repos to add
test -n "$all_new_repos" || {
>&2 echo "==> no new repos to add (all already in stack)"
exit 0
}
# setup logs directory
logs_dir="$stack_dir/.stack-extend-logs"
mkdir -p "$logs_dir"
# setup tmux if available
cs_stack_create_setup_tmux
# prepare and spawn jobs for new repos
cs_stack_create_prepare_repos "$all_new_repos"
cs_stack_create_spawn_jobs
cs_stack_spawn_stack_post_create
cs_stack_create_wait_and_summarize "extend"
}
# run stack post-create script in an existing stack
# Uses cs_stack_spawn_stack_post_create internally to avoid code duplication
cs_stack_post_create() {
stack_name="default"
while [ $# -gt 0 ]; do
case "$1" in
-s|--stack) shift; stack_name="${1:?err: -s requires a stack name}" ;;
-*) >&2 echo "err: unknown option: $1"; exit 1 ;;
*) >&2 echo "err: unexpected argument: $1"; exit 1 ;;
esac
shift
done
# detect current stack
local detect_output
detect_output="$(cs_stack_detect_current)" || {
>&2 echo "err: not inside a stack directory"
exit 1
}
stack_dir="$(echo "$detect_output" | sed -n '1p')"
branch="$(echo "$detect_output" | sed -n '2p')"
org_dir="$(echo "$detect_output" | sed -n '3p')"
# find stacks.json
stacks_json=""
if [ -n "${CODESPACE_CONFIG_ROOT:-}" ]; then
local rel_path
rel_path="$(realpath "$org_dir" --relative-to="$HOME" 2>/dev/null)" || rel_path="$org_dir"
stacks_json="$CODESPACE_CONFIG_ROOT/$rel_path/stacks.json"
[ -f "$stacks_json" ] || stacks_json=""
fi
[ -n "$stacks_json" ] || { >&2 echo "err: stacks.json not found"; exit 1; }
# set up globals for cs_stack_spawn_stack_post_create
repo_names=()
for d in "$stack_dir"/*; do [ -d "$d/.git" ] && repo_names+=("$(basename "$d")"); done
create_mode="$(cs_stack_detect_mode "$stack_dir")"
logs_dir="$(mktemp -d -t stack-post-create.XXXXXX)"
# set up tmux and run
cs_stack_create_setup_tmux
cs_stack_spawn_stack_post_create
# wait for completion
if [ -n "${stack_post_create_spawned:-}" ]; then
if [ -n "$use_tmux" ]; then
cs_stack_tmux_wait "$logs_dir" "stack-post-create"
# check result
if grep -q "STACK_POST_CREATE_FAILED" "$logs_dir/stack-post-create.log" 2>/dev/null; then
>&2 echo "==> stack post-create failed"
>&2 echo " logs: $logs_dir"
exit 1
fi
else
if ! wait "$stack_post_create_pid"; then
>&2 echo "==> stack post-create failed"
>&2 echo " logs: $logs_dir"
exit 1
fi
fi
>&2 echo "==> stack post-create completed"
[ -z "${DEBUG:-}" ] && rm -rf "$logs_dir"
fi
}
# Wait for tmux repo init jobs to complete by polling log files
# Args: logs_dir, repo_names...
cs_stack_tmux_wait() {
local wait_logs_dir="$1"
shift
# poll until all jobs have completed (check for Done or failure markers in logs)
while true; do
remaining=0
for r_name in "$@"; do
local log_file="$wait_logs_dir/$r_name.log"
# check if log file exists and contains completion marker
# (pattern is lenient to handle buffering/whitespace variations)
if [ -f "$log_file" ]; then
if grep -qE "\[$r_name\] Done ===|CLONE_FAILED|POST_CREATE_FAILED|STACK_POST_CREATE_FAILED" "$log_file"; then
continue # this job is done
fi
fi
remaining=$((remaining + 1))
done
[ "$remaining" -eq 0 ] && break
sleep 1
done
}
# Find stacks.json config by walking up from org_dir
# Outputs two lines:
# Line 1: config file path
# Line 2: org directory where config was found (use this for stack creation)
cs_stack_find_config() {
test -n "${CODESPACE_CONFIG_ROOT:-}" || {
>&2 echo "err: env var '\$CODESPACE_CONFIG_ROOT' not set"
exit 1
}
start_dir="$(cs_stack_get_org_dir)"
current_dir="$start_dir"
levels_up=0
while true; do
rel_path="$(realpath "$current_dir" --relative-to="$HOME" 2>/dev/null)" || rel_path="$current_dir"
config_path="$CODESPACE_CONFIG_ROOT/$rel_path/stacks.json"
if [ -f "$config_path" ]; then
# if we went more than 1 level up, ask for confirmation
if [ "$levels_up" -gt 1 ]; then
if [ -n "${CS_NO_INTERACTIVE:-}" ]; then
>&2 echo "found stacks.json $levels_up levels up, stack will be created in: $current_dir (auto-accepting, CS_NO_INTERACTIVE set)"
else
>&2 printf "found stacks.json $levels_up levels up, stack will be created in: $current_dir. use? [y/n] "
read -r answer </dev/tty
case "$answer" in
y|Y|yes|Yes) ;;
*)
>&2 echo "aborted"
exit 1
;;
esac
fi
fi
# output config path and the matched org directory
echo "$config_path"
echo "$current_dir"
return 0
fi
# check if we've reached home or root
if [ "$current_dir" = "$HOME" ] || [ "$current_dir" = "/" ]; then
break
fi
current_dir="$(dirname "$current_dir")"
levels_up=$((levels_up + 1))
done
>&2 echo "err: stacks.json not found"
>&2 echo "searched from: $start_dir"
>&2 echo "expected location: \$CODESPACE_CONFIG_ROOT/<org>/stacks.json"
>&2 echo "hint: run 'codespace stack init' to create one"
exit 1
}
cs_stack_init() {
test -n "${CODESPACE_CONFIG_ROOT:-}" || {
>&2 echo "err: env var '\$CODESPACE_CONFIG_ROOT' not set"
exit 1
}
# check if a config already exists (possibly at a higher level)
if config_output="$(cs_stack_find_config 2>/dev/null)"; then
existing_config="$(echo "$config_output" | head -n1)"
existing_org_dir="$(echo "$config_output" | tail -n1)"
>&2 echo "stacks.json already exists at:"
echo "$existing_config"
>&2 echo "(org directory: $existing_org_dir)"
exit 0
fi
# determine org directory
local org_dir=""
if [ -n "${1:-}" ]; then
# path provided as argument
org_dir="$(realpath "$1" 2>/dev/null)" || {
>&2 echo "err: invalid path: $1"
exit 1
}
if [ ! -d "$org_dir" ]; then
>&2 echo "err: not a directory: $org_dir"
exit 1
fi
elif [ -z "${CS_NO_INTERACTIVE:-}" ]; then
# interactive mode: offer choices
local current_dir parent_dir
current_dir="$(pwd)"
parent_dir="$(dirname "$current_dir")"
# check if we're inside a git repo (parent might be org level)
local in_git_repo=""
if git rev-parse --git-dir >/dev/null 2>&1; then
in_git_repo=1
fi
>&2 echo "Select directory where stacks will be held:"
>&2 echo ""
>&2 echo " 1) $current_dir"
if [ -n "$in_git_repo" ]; then
>&2 echo " 2) $parent_dir (parent - you're inside a git repo)"
else
>&2 echo " 2) $parent_dir (parent)"
fi
>&2 echo " 0) Exit (use 'codespace stack init <path>' to specify directly)"
>&2 echo ""
>&2 printf "Choice [1/2/0]: "
read -r choice </dev/tty
case "$choice" in
1) org_dir="$current_dir" ;;
2) org_dir="$parent_dir" ;;
0|"")
>&2 echo "aborted"
exit 1
;;
*)
>&2 echo "err: invalid choice: $choice"
exit 1
;;
esac
else
# non-interactive mode: use current directory
org_dir="$(pwd)"
fi
local org_name rel_path config_dir config_path
org_name="$(basename "$org_dir")"
rel_path="$(realpath "$org_dir" --relative-to="$HOME" 2>/dev/null)" || rel_path="$org_dir"
config_dir="$CODESPACE_CONFIG_ROOT/$rel_path"
config_path="$config_dir/stacks.json"
>&2 echo ""
>&2 echo "Creating configuration for org \"$org_name\""
>&2 echo " org directory: $org_dir"
>&2 echo " config path: $config_path"
# discover repos in org directory for template
repos_json="$(cs_stack_discover_repos "$org_dir")"
mkdir -p "$config_dir"
# create template stacks.json
cat > "$config_path" << EOF
{
"version": "0",
"enableGlobalPostCreateScript": true,
"stacks": {
"default": $repos_json
}
}
EOF
# create sample stack-post-create.sh script
local script_path="$config_dir/stack-post-create.sh"
cat > "$script_path" << 'EOF'
#!/bin/bash
# stack-post-create.sh - runs after stack creation (in parallel with repo post-create scripts)
#
# Available environment variables:
# STACK_NAME - stack config name (e.g., "default")
# STACK_BRANCH - branch name for the stack
# STACK_ROOT - absolute path to stack directory
# STACK_CONFIG_ROOT - config directory (where stacks.json lives)
# STACK_REPOS - comma-separated list of repo names
# STACK_CREATE_MODE - "worktree" or "clone"
# STACK_ORG_DIR - parent org directory
echo "=== Stack post-create: $STACK_NAME ==="
echo "Branch: $STACK_BRANCH"
echo "Repos: $STACK_REPOS"
echo "Dir: $STACK_ROOT"
# Example: link file from config to stack root
# ln -s "$STACK_CONFIG_ROOT/AGENTS.md" "$STACK_ROOT/AGENTS.md"
# Example: create shared .env file across repos
# for repo in ${STACK_REPOS//,/ }; do
# cp "$STACK_ORG_DIR/templates/.env.template" "$STACK_ROOT/$repo/.env"
# done
# Example: create stack-level config
# echo "STACK_BRANCH=$STACK_BRANCH" > "$STACK_ROOT/.stack-env"
EOF
chmod +x "$script_path"
info="\
edit the file to configure your stacks:
- \"default\" stack contains discovered repos from: $org_dir
- add more stacks as needed
- repo values can be:
$(echo "$REPO_VALUES_INFO" | sed 's/^/ /')
- set \"enableGlobalPostCreateScript\": false to disable stack-post-create.sh
created:
$config_path
$script_path\
"
echo "$info"
}
# Discover repos in org directory and return as JSON array
cs_stack_discover_repos() {
org_dir="$1"
repos=""
indent=" " # 12 spaces for array items
for dir in "$org_dir"/*; do
test -d "$dir/.git" || continue
repo_name="$(basename "$dir")"
clone_url="$(git -C "$dir" remote get-url origin 2>/dev/null)" || clone_url=""
if [ -n "$clone_url" ]; then
entry="{ \"name\": \"$repo_name\", \"cloneURL\": \"$clone_url\" }"
else
entry="\"$repo_name\""
fi
if [ -z "$repos" ]; then
repos="$entry"
else
repos="$repos,
$indent$entry"
fi
done
if [ -z "$repos" ]; then
echo '[
{ "name": "repo1", "cloneURL": "git@github.com:org/repo1.git" },
{ "name": "repo2", "cloneURL": "git@github.com:org/repo2.git" }
]'
else
echo "[
$indent$repos
]"
fi
}
cs_stack_get_org_dir() {
# if inside a git repo, go to parent (org level)
# otherwise use current directory
if git rev-parse --git-dir >/dev/null 2>&1; then
# use cs_abs_path_base_repo to properly resolve through worktrees
repo_root="$(cs_abs_path_base_repo)"
dirname "$repo_root"
else
pwd
fi
}
# detect if we're inside a stack directory by walking up the tree
# outputs three lines:
# line 1: stack directory path
# line 2: branch name (extracted from stack_<branch> pattern)
# line 3: org directory (parent of stack)
# returns non-zero if not inside a stack
cs_stack_detect_current() {
local current_dir
current_dir="$(pwd)"
while true; do
local dir_name
dir_name="$(basename "$current_dir")"
# check if directory name matches stack_* pattern
if [[ "$dir_name" == stack_* ]]; then
local stack_dir="$current_dir"
local org_dir
org_dir="$(dirname "$current_dir")"
# extract branch name by removing "stack_" prefix
local branch="${dir_name#stack_}"
echo "$stack_dir"
echo "$branch"
echo "$org_dir"