@revotools/cli 0.2.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/README.md +198 -0
- package/dist/revo +3263 -0
- package/package.json +34 -0
package/dist/revo
ADDED
|
@@ -0,0 +1,3263 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Revo CLI - Claude-first multi-repo workspace manager
|
|
3
|
+
# https://github.com/marcus.salinas/revo
|
|
4
|
+
# This is a bundled distribution - do not edit
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
# Exit cleanly on SIGPIPE (e.g., revo clone | grep, revo status | head)
|
|
9
|
+
trap 'exit 0' PIPE
|
|
10
|
+
|
|
11
|
+
REVO_VERSION="0.2.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# === lib/ui.sh ===
|
|
15
|
+
# Revo CLI - UI Components (Clack-style)
|
|
16
|
+
# Provides terminal UI primitives with Unicode/ASCII fallback
|
|
17
|
+
|
|
18
|
+
# Detect color support
|
|
19
|
+
if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
|
|
20
|
+
UI_COLOR=1
|
|
21
|
+
else
|
|
22
|
+
UI_COLOR=0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Detect Unicode support
|
|
26
|
+
if [[ "${LANG:-}" == *UTF-8* ]] || [[ "${LC_ALL:-}" == *UTF-8* ]]; then
|
|
27
|
+
UI_UNICODE=1
|
|
28
|
+
else
|
|
29
|
+
UI_UNICODE=0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# --- Symbols ---
|
|
33
|
+
if [[ "$UI_UNICODE" -eq 1 ]]; then
|
|
34
|
+
S_STEP_ACTIVE="◆"
|
|
35
|
+
S_STEP_DONE="◇"
|
|
36
|
+
S_STEP_ERROR="▲"
|
|
37
|
+
S_STEP_CANCEL="■"
|
|
38
|
+
S_BAR="│"
|
|
39
|
+
S_BAR_START="┌"
|
|
40
|
+
S_BAR_END="└"
|
|
41
|
+
S_SPINNER_FRAMES=("◒" "◐" "◓" "◑")
|
|
42
|
+
S_CHECKBOX_ON="◼"
|
|
43
|
+
S_CHECKBOX_OFF="◻"
|
|
44
|
+
S_RADIO_ON="●"
|
|
45
|
+
S_RADIO_OFF="○"
|
|
46
|
+
else
|
|
47
|
+
S_STEP_ACTIVE="*"
|
|
48
|
+
S_STEP_DONE="+"
|
|
49
|
+
S_STEP_ERROR="!"
|
|
50
|
+
S_STEP_CANCEL="x"
|
|
51
|
+
S_BAR="|"
|
|
52
|
+
S_BAR_START="/"
|
|
53
|
+
S_BAR_END="\\"
|
|
54
|
+
S_SPINNER_FRAMES=("-" "\\" "|" "/")
|
|
55
|
+
S_CHECKBOX_ON="[x]"
|
|
56
|
+
S_CHECKBOX_OFF="[ ]"
|
|
57
|
+
S_RADIO_ON="(*)"
|
|
58
|
+
S_RADIO_OFF="( )"
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# --- Colors ---
|
|
62
|
+
ui_reset() {
|
|
63
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[0m' || true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ui_cyan() {
|
|
67
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[36m%s\033[0m' "$1" || printf '%s' "$1"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ui_green() {
|
|
71
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ui_yellow() {
|
|
75
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[33m%s\033[0m' "$1" || printf '%s' "$1"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ui_red() {
|
|
79
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ui_dim() {
|
|
83
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ui_bold() {
|
|
87
|
+
[[ "$UI_COLOR" -eq 1 ]] && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# --- Bar Components ---
|
|
91
|
+
ui_bar() {
|
|
92
|
+
ui_dim "$S_BAR"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ui_bar_start() {
|
|
96
|
+
ui_dim "$S_BAR_START"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ui_bar_end() {
|
|
100
|
+
ui_dim "$S_BAR_END"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# --- Layout Components ---
|
|
104
|
+
|
|
105
|
+
# Intro: Start of a section
|
|
106
|
+
# Usage: ui_intro "Title"
|
|
107
|
+
ui_intro() {
|
|
108
|
+
local title="$1"
|
|
109
|
+
printf '%s %s\n' "$(ui_bar_start)" "$(ui_cyan "$title")"
|
|
110
|
+
printf '%s\n' "$(ui_bar)"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Outro: End of a section
|
|
114
|
+
# Usage: ui_outro "Message"
|
|
115
|
+
ui_outro() {
|
|
116
|
+
local message="$1"
|
|
117
|
+
printf '%s\n' "$(ui_bar)"
|
|
118
|
+
printf '%s %s\n' "$(ui_bar_end)" "$(ui_green "$message")"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Outro with cancel
|
|
122
|
+
ui_outro_cancel() {
|
|
123
|
+
local message="$1"
|
|
124
|
+
printf '%s\n' "$(ui_bar)"
|
|
125
|
+
printf '%s %s\n' "$(ui_bar_end)" "$(ui_red "$message")"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# Step: Active prompt
|
|
129
|
+
# Usage: ui_step "Label"
|
|
130
|
+
ui_step() {
|
|
131
|
+
local label="$1"
|
|
132
|
+
printf '%s %s\n' "$(ui_cyan "$S_STEP_ACTIVE")" "$label"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Step done: Completed step
|
|
136
|
+
# Usage: ui_step_done "Label" "value"
|
|
137
|
+
ui_step_done() {
|
|
138
|
+
local label="$1"
|
|
139
|
+
local value="${2:-}"
|
|
140
|
+
if [[ -n "$value" ]]; then
|
|
141
|
+
printf '%s %s %s\n' "$(ui_green "$S_STEP_DONE")" "$label" "$(ui_dim "$value")"
|
|
142
|
+
else
|
|
143
|
+
printf '%s %s\n' "$(ui_green "$S_STEP_DONE")" "$label"
|
|
144
|
+
fi
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Step error
|
|
148
|
+
ui_step_error() {
|
|
149
|
+
local message="$1"
|
|
150
|
+
printf '%s %s\n' "$(ui_yellow "$S_STEP_ERROR")" "$(ui_yellow "$message")"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Step cancel
|
|
154
|
+
ui_step_cancel() {
|
|
155
|
+
local message="$1"
|
|
156
|
+
printf '%s %s\n' "$(ui_red "$S_STEP_CANCEL")" "$(ui_red "$message")"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Info line (continuation)
|
|
160
|
+
ui_info() {
|
|
161
|
+
local message="$1"
|
|
162
|
+
printf '%s %s\n' "$(ui_bar)" "$message"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Empty bar line
|
|
166
|
+
ui_bar_line() {
|
|
167
|
+
printf '%s\n' "$(ui_bar)"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# --- Interactive Components ---
|
|
171
|
+
|
|
172
|
+
# Text input
|
|
173
|
+
# Usage: result=$(ui_text "Prompt" "default")
|
|
174
|
+
# Returns: user input or default
|
|
175
|
+
ui_text() {
|
|
176
|
+
local prompt="$1"
|
|
177
|
+
local default="${2:-}"
|
|
178
|
+
local input
|
|
179
|
+
|
|
180
|
+
ui_step "$prompt"
|
|
181
|
+
if [[ -n "$default" ]]; then
|
|
182
|
+
printf '%s ' "$(ui_bar)"
|
|
183
|
+
read -r -p "" -e -i "$default" input
|
|
184
|
+
else
|
|
185
|
+
printf '%s ' "$(ui_bar)"
|
|
186
|
+
read -r input
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
# Return default if empty
|
|
190
|
+
if [[ -z "$input" ]]; then
|
|
191
|
+
input="$default"
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
printf '%s' "$input"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Confirm (y/n)
|
|
198
|
+
# Usage: if ui_confirm "Question?"; then ... fi
|
|
199
|
+
ui_confirm() {
|
|
200
|
+
local prompt="$1"
|
|
201
|
+
local default="${2:-y}"
|
|
202
|
+
local hint
|
|
203
|
+
local response
|
|
204
|
+
|
|
205
|
+
if [[ "$default" == "y" ]]; then
|
|
206
|
+
hint="Y/n"
|
|
207
|
+
else
|
|
208
|
+
hint="y/N"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
ui_step "$prompt ($hint)"
|
|
212
|
+
printf '%s ' "$(ui_bar)"
|
|
213
|
+
read -r -n 1 response
|
|
214
|
+
printf '\n'
|
|
215
|
+
|
|
216
|
+
# Handle empty (use default)
|
|
217
|
+
if [[ -z "$response" ]]; then
|
|
218
|
+
response="$default"
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
[[ "$response" =~ ^[Yy]$ ]]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Select from options
|
|
225
|
+
# Usage: result=$(ui_select "Prompt" "opt1" "opt2" "opt3")
|
|
226
|
+
# Returns: selected option
|
|
227
|
+
ui_select() {
|
|
228
|
+
local prompt="$1"
|
|
229
|
+
shift
|
|
230
|
+
local options=("$@")
|
|
231
|
+
local selected=0
|
|
232
|
+
local count=${#options[@]}
|
|
233
|
+
local key
|
|
234
|
+
|
|
235
|
+
ui_step "$prompt"
|
|
236
|
+
|
|
237
|
+
# Save cursor position and hide cursor
|
|
238
|
+
printf '\033[?25l'
|
|
239
|
+
|
|
240
|
+
while true; do
|
|
241
|
+
# Draw options
|
|
242
|
+
for i in "${!options[@]}"; do
|
|
243
|
+
if [[ $i -eq $selected ]]; then
|
|
244
|
+
printf '%s %s %s\n' "$(ui_bar)" "$(ui_cyan "$S_RADIO_ON")" "${options[$i]}"
|
|
245
|
+
else
|
|
246
|
+
printf '%s %s %s\n' "$(ui_bar)" "$(ui_dim "$S_RADIO_OFF")" "$(ui_dim "${options[$i]}")"
|
|
247
|
+
fi
|
|
248
|
+
done
|
|
249
|
+
|
|
250
|
+
# Read key
|
|
251
|
+
read -rsn1 key
|
|
252
|
+
|
|
253
|
+
# Handle arrow keys (escape sequences)
|
|
254
|
+
if [[ "$key" == $'\033' ]]; then
|
|
255
|
+
read -rsn2 key
|
|
256
|
+
case "$key" in
|
|
257
|
+
'[A') # Up
|
|
258
|
+
((selected > 0)) && ((selected--))
|
|
259
|
+
;;
|
|
260
|
+
'[B') # Down
|
|
261
|
+
((selected < count - 1)) && selected=$((selected + 1))
|
|
262
|
+
;;
|
|
263
|
+
esac
|
|
264
|
+
elif [[ "$key" == "" ]]; then
|
|
265
|
+
# Enter pressed
|
|
266
|
+
break
|
|
267
|
+
elif [[ "$key" == "j" ]]; then
|
|
268
|
+
((selected < count - 1)) && selected=$((selected + 1))
|
|
269
|
+
elif [[ "$key" == "k" ]]; then
|
|
270
|
+
((selected > 0)) && ((selected--))
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
# Move cursor up to redraw
|
|
274
|
+
printf '\033[%dA' "$count"
|
|
275
|
+
done
|
|
276
|
+
|
|
277
|
+
# Show cursor
|
|
278
|
+
printf '\033[?25h'
|
|
279
|
+
|
|
280
|
+
printf '%s' "${options[$selected]}"
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# --- Spinner ---
|
|
284
|
+
# Usage:
|
|
285
|
+
# ui_spinner_start "Loading..."
|
|
286
|
+
# ... do work ...
|
|
287
|
+
# ui_spinner_stop "Done!" # or ui_spinner_stop
|
|
288
|
+
_SPINNER_PID=""
|
|
289
|
+
_SPINNER_MSG=""
|
|
290
|
+
|
|
291
|
+
ui_spinner_start() {
|
|
292
|
+
local message="$1"
|
|
293
|
+
_SPINNER_MSG="$message"
|
|
294
|
+
|
|
295
|
+
# Skip spinner animation when stdout is not a terminal (piped/redirected)
|
|
296
|
+
if [[ ! -t 1 ]]; then
|
|
297
|
+
return
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
(
|
|
301
|
+
local i=0
|
|
302
|
+
local frame_count=${#S_SPINNER_FRAMES[@]}
|
|
303
|
+
|
|
304
|
+
while true; do
|
|
305
|
+
printf '\r%s %s %s' "$(ui_bar)" "$(ui_cyan "${S_SPINNER_FRAMES[$i]}")" "$message"
|
|
306
|
+
((i = (i + 1) % frame_count))
|
|
307
|
+
sleep 0.1
|
|
308
|
+
done
|
|
309
|
+
) &
|
|
310
|
+
_SPINNER_PID=$!
|
|
311
|
+
|
|
312
|
+
# Ensure cleanup on script exit
|
|
313
|
+
trap 'ui_spinner_stop 2>/dev/null' EXIT
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
ui_spinner_stop() {
|
|
317
|
+
local final_message="${1:-}"
|
|
318
|
+
|
|
319
|
+
if [[ -n "$_SPINNER_PID" ]] && kill -0 "$_SPINNER_PID" 2>/dev/null; then
|
|
320
|
+
kill "$_SPINNER_PID" 2>/dev/null
|
|
321
|
+
wait "$_SPINNER_PID" 2>/dev/null || true
|
|
322
|
+
fi
|
|
323
|
+
_SPINNER_PID=""
|
|
324
|
+
|
|
325
|
+
# Clear spinner line only when connected to a terminal
|
|
326
|
+
if [[ -t 1 ]]; then
|
|
327
|
+
printf '\r\033[K'
|
|
328
|
+
fi
|
|
329
|
+
|
|
330
|
+
# Show final message if provided
|
|
331
|
+
if [[ -n "$final_message" ]]; then
|
|
332
|
+
ui_step_done "$final_message"
|
|
333
|
+
fi
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
ui_spinner_error() {
|
|
337
|
+
local message="$1"
|
|
338
|
+
|
|
339
|
+
if [[ -n "$_SPINNER_PID" ]] && kill -0 "$_SPINNER_PID" 2>/dev/null; then
|
|
340
|
+
kill "$_SPINNER_PID" 2>/dev/null
|
|
341
|
+
wait "$_SPINNER_PID" 2>/dev/null || true
|
|
342
|
+
fi
|
|
343
|
+
_SPINNER_PID=""
|
|
344
|
+
|
|
345
|
+
if [[ -t 1 ]]; then
|
|
346
|
+
printf '\r\033[K'
|
|
347
|
+
fi
|
|
348
|
+
ui_step_error "$message"
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# --- Progress ---
|
|
352
|
+
# Usage: ui_progress "message" current total
|
|
353
|
+
ui_progress() {
|
|
354
|
+
local message="$1"
|
|
355
|
+
local current="$2"
|
|
356
|
+
local total="$3"
|
|
357
|
+
local percent=$((current * 100 / total))
|
|
358
|
+
local bar_width=20
|
|
359
|
+
local filled=$((percent * bar_width / 100))
|
|
360
|
+
local empty=$((bar_width - filled))
|
|
361
|
+
|
|
362
|
+
printf '\r%s ' "$(ui_bar)"
|
|
363
|
+
printf '%s [' "$message"
|
|
364
|
+
printf '%*s' "$filled" '' | tr ' ' '='
|
|
365
|
+
printf '%*s' "$empty" '' | tr ' ' ' '
|
|
366
|
+
printf '] %d%%' "$percent"
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
# --- ANSI-aware padding ---
|
|
370
|
+
|
|
371
|
+
_ui_visible_len() {
|
|
372
|
+
local str="$1"
|
|
373
|
+
local stripped
|
|
374
|
+
stripped=$(printf '%s' "$str" | sed $'s/\033\\[[0-9;]*m//g')
|
|
375
|
+
printf '%d' "${#stripped}"
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_ui_pad() {
|
|
379
|
+
local str="$1"
|
|
380
|
+
local target_width="$2"
|
|
381
|
+
local visible_len
|
|
382
|
+
visible_len=$(_ui_visible_len "$str")
|
|
383
|
+
local pad=$((target_width - visible_len))
|
|
384
|
+
[[ $pad -lt 0 ]] && pad=0
|
|
385
|
+
printf '%s%*s' "$str" "$pad" ""
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# --- Table ---
|
|
389
|
+
# Usage: ui_table_widths 24 20 12 14
|
|
390
|
+
# ui_table_header "Col1" "Col2" "Col3" "Col4"
|
|
391
|
+
# ui_table_row "val1" "val2" "val3" "val4"
|
|
392
|
+
_TABLE_COL_WIDTHS=()
|
|
393
|
+
|
|
394
|
+
ui_table_widths() {
|
|
395
|
+
_TABLE_COL_WIDTHS=("$@")
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
ui_table_header() {
|
|
399
|
+
local cols=("$@")
|
|
400
|
+
local i=0
|
|
401
|
+
|
|
402
|
+
printf '%s ' "$(ui_bar)"
|
|
403
|
+
for col in "${cols[@]}"; do
|
|
404
|
+
local w="${_TABLE_COL_WIDTHS[$i]:-20}"
|
|
405
|
+
local padded
|
|
406
|
+
padded=$(printf "%-${w}s" "$col")
|
|
407
|
+
printf '%s' "$(ui_bold "$padded")"
|
|
408
|
+
i=$((i + 1))
|
|
409
|
+
done
|
|
410
|
+
printf '\n'
|
|
411
|
+
|
|
412
|
+
# Separator
|
|
413
|
+
printf '%s ' "$(ui_bar)"
|
|
414
|
+
i=0
|
|
415
|
+
for _ in "${cols[@]}"; do
|
|
416
|
+
local w="${_TABLE_COL_WIDTHS[$i]:-20}"
|
|
417
|
+
local dashes=""
|
|
418
|
+
local j=0
|
|
419
|
+
while [[ $j -lt $w ]]; do
|
|
420
|
+
dashes+="─"
|
|
421
|
+
j=$((j + 1))
|
|
422
|
+
done
|
|
423
|
+
printf '%s' "$(ui_dim "$dashes")"
|
|
424
|
+
i=$((i + 1))
|
|
425
|
+
done
|
|
426
|
+
printf '\n'
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
ui_table_row() {
|
|
430
|
+
local vals=("$@")
|
|
431
|
+
local i=0
|
|
432
|
+
|
|
433
|
+
printf '%s ' "$(ui_bar)"
|
|
434
|
+
for val in "${vals[@]}"; do
|
|
435
|
+
local w="${_TABLE_COL_WIDTHS[$i]:-20}"
|
|
436
|
+
_ui_pad "$val" "$w"
|
|
437
|
+
i=$((i + 1))
|
|
438
|
+
done
|
|
439
|
+
printf '\n'
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
# --- Utilities ---
|
|
443
|
+
|
|
444
|
+
# Clear line
|
|
445
|
+
ui_clear_line() {
|
|
446
|
+
printf '\r\033[K'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
# Move cursor up N lines
|
|
450
|
+
ui_cursor_up() {
|
|
451
|
+
local n="${1:-1}"
|
|
452
|
+
printf '\033[%dA' "$n"
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# Hide cursor
|
|
456
|
+
ui_cursor_hide() {
|
|
457
|
+
printf '\033[?25l'
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Show cursor
|
|
461
|
+
ui_cursor_show() {
|
|
462
|
+
printf '\033[?25h'
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Ensure cursor is shown on exit
|
|
466
|
+
trap 'ui_cursor_show' EXIT
|
|
467
|
+
|
|
468
|
+
# === lib/yaml.sh ===
|
|
469
|
+
# Revo CLI - Minimal YAML Parser
|
|
470
|
+
# Parses revo.yaml (and legacy mars.yaml) format only - not a general YAML parser
|
|
471
|
+
# Compatible with bash 3.2+ (no associative arrays)
|
|
472
|
+
|
|
473
|
+
# Global state - using parallel indexed arrays instead of associative arrays
|
|
474
|
+
YAML_WORKSPACE_NAME=""
|
|
475
|
+
YAML_DEFAULTS_BRANCH=""
|
|
476
|
+
YAML_REPO_COUNT=0
|
|
477
|
+
|
|
478
|
+
# Arrays indexed by repo number (0, 1, 2, ...)
|
|
479
|
+
# Access: ${YAML_REPO_URLS[$i]}
|
|
480
|
+
YAML_REPO_URLS=()
|
|
481
|
+
YAML_REPO_PATHS=()
|
|
482
|
+
YAML_REPO_TAGS=()
|
|
483
|
+
YAML_REPO_DEPS=()
|
|
484
|
+
|
|
485
|
+
yaml_parse() {
|
|
486
|
+
local file="$1"
|
|
487
|
+
local line
|
|
488
|
+
local in_repos=0
|
|
489
|
+
local in_defaults=0
|
|
490
|
+
local current_index=-1
|
|
491
|
+
|
|
492
|
+
# Reset state
|
|
493
|
+
YAML_WORKSPACE_NAME=""
|
|
494
|
+
YAML_DEFAULTS_BRANCH="main"
|
|
495
|
+
YAML_REPO_COUNT=0
|
|
496
|
+
YAML_REPO_URLS=()
|
|
497
|
+
YAML_REPO_PATHS=()
|
|
498
|
+
YAML_REPO_TAGS=()
|
|
499
|
+
YAML_REPO_DEPS=()
|
|
500
|
+
|
|
501
|
+
if [[ ! -f "$file" ]]; then
|
|
502
|
+
return 1
|
|
503
|
+
fi
|
|
504
|
+
|
|
505
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
506
|
+
# Skip empty lines and comments
|
|
507
|
+
[[ -z "$line" ]] && continue
|
|
508
|
+
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
509
|
+
|
|
510
|
+
# Remove leading/trailing whitespace for comparison
|
|
511
|
+
local trimmed="${line#"${line%%[![:space:]]*}"}"
|
|
512
|
+
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
|
513
|
+
|
|
514
|
+
# Check section markers
|
|
515
|
+
if [[ "$trimmed" == "repos:" ]]; then
|
|
516
|
+
in_repos=1
|
|
517
|
+
in_defaults=0
|
|
518
|
+
continue
|
|
519
|
+
elif [[ "$trimmed" == "defaults:" ]]; then
|
|
520
|
+
in_repos=0
|
|
521
|
+
in_defaults=1
|
|
522
|
+
continue
|
|
523
|
+
elif [[ "$trimmed" == "workspace:" ]]; then
|
|
524
|
+
in_repos=0
|
|
525
|
+
in_defaults=0
|
|
526
|
+
continue
|
|
527
|
+
fi
|
|
528
|
+
|
|
529
|
+
# Parse workspace name
|
|
530
|
+
if [[ "$trimmed" =~ ^name:[[:space:]]*[\"\']?([^\"\']+)[\"\']?$ ]]; then
|
|
531
|
+
YAML_WORKSPACE_NAME="${BASH_REMATCH[1]}"
|
|
532
|
+
continue
|
|
533
|
+
fi
|
|
534
|
+
|
|
535
|
+
# Parse defaults section
|
|
536
|
+
if [[ $in_defaults -eq 1 ]]; then
|
|
537
|
+
if [[ "$trimmed" =~ ^branch:[[:space:]]*(.+)$ ]]; then
|
|
538
|
+
YAML_DEFAULTS_BRANCH="${BASH_REMATCH[1]}"
|
|
539
|
+
fi
|
|
540
|
+
continue
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
# Parse repos section
|
|
544
|
+
if [[ $in_repos -eq 1 ]]; then
|
|
545
|
+
# New repo entry (starts with -)
|
|
546
|
+
if [[ "$trimmed" =~ ^-[[:space:]]*url:[[:space:]]*(.+)$ ]]; then
|
|
547
|
+
current_index=$((current_index + 1))
|
|
548
|
+
local url="${BASH_REMATCH[1]}"
|
|
549
|
+
YAML_REPO_URLS[$current_index]="$url"
|
|
550
|
+
YAML_REPO_PATHS[$current_index]=$(yaml_path_from_url "$url")
|
|
551
|
+
YAML_REPO_TAGS[$current_index]=""
|
|
552
|
+
YAML_REPO_DEPS[$current_index]=""
|
|
553
|
+
YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
|
|
554
|
+
continue
|
|
555
|
+
fi
|
|
556
|
+
|
|
557
|
+
# Continuation of current repo
|
|
558
|
+
if [[ $current_index -ge 0 ]]; then
|
|
559
|
+
if [[ "$trimmed" =~ ^path:[[:space:]]*(.+)$ ]]; then
|
|
560
|
+
YAML_REPO_PATHS[$current_index]="${BASH_REMATCH[1]}"
|
|
561
|
+
elif [[ "$trimmed" =~ ^tags:[[:space:]]*\[([^\]]*)\]$ ]]; then
|
|
562
|
+
# Parse inline array: [tag1, tag2]
|
|
563
|
+
local tags_str="${BASH_REMATCH[1]}"
|
|
564
|
+
# Remove spaces and quotes
|
|
565
|
+
tags_str="${tags_str//[[:space:]]/}"
|
|
566
|
+
tags_str="${tags_str//\"/}"
|
|
567
|
+
tags_str="${tags_str//\'/}"
|
|
568
|
+
YAML_REPO_TAGS[$current_index]="$tags_str"
|
|
569
|
+
elif [[ "$trimmed" =~ ^depends_on:[[:space:]]*\[([^\]]*)\]$ ]]; then
|
|
570
|
+
# Parse inline array: [name1, name2]
|
|
571
|
+
local deps_str="${BASH_REMATCH[1]}"
|
|
572
|
+
deps_str="${deps_str//[[:space:]]/}"
|
|
573
|
+
deps_str="${deps_str//\"/}"
|
|
574
|
+
deps_str="${deps_str//\'/}"
|
|
575
|
+
YAML_REPO_DEPS[$current_index]="$deps_str"
|
|
576
|
+
fi
|
|
577
|
+
fi
|
|
578
|
+
fi
|
|
579
|
+
done < "$file"
|
|
580
|
+
|
|
581
|
+
return 0
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# Extract repo name from URL
|
|
585
|
+
# Usage: yaml_path_from_url "git@github.com:org/repo.git"
|
|
586
|
+
# Returns: "repo"
|
|
587
|
+
yaml_path_from_url() {
|
|
588
|
+
local url="$1"
|
|
589
|
+
local name
|
|
590
|
+
|
|
591
|
+
# Handle SSH URLs: git@github.com:org/repo.git
|
|
592
|
+
if [[ "$url" =~ ([^/:]+)\.git$ ]]; then
|
|
593
|
+
name="${BASH_REMATCH[1]}"
|
|
594
|
+
# Handle HTTPS URLs: https://github.com/org/repo.git
|
|
595
|
+
elif [[ "$url" =~ /([^/]+)\.git$ ]]; then
|
|
596
|
+
name="${BASH_REMATCH[1]}"
|
|
597
|
+
# Handle URLs without .git
|
|
598
|
+
elif [[ "$url" =~ ([^/:]+)$ ]]; then
|
|
599
|
+
name="${BASH_REMATCH[1]}"
|
|
600
|
+
else
|
|
601
|
+
name="repo"
|
|
602
|
+
fi
|
|
603
|
+
|
|
604
|
+
printf '%s' "$name"
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
# Get list of repo indices, optionally filtered by tag
|
|
608
|
+
# Usage: yaml_get_repos [tag]
|
|
609
|
+
# Returns: newline-separated list of indices (0, 1, 2, ...)
|
|
610
|
+
yaml_get_repos() {
|
|
611
|
+
local filter_tag="${1:-}"
|
|
612
|
+
local i
|
|
613
|
+
|
|
614
|
+
for ((i = 0; i < YAML_REPO_COUNT; i++)); do
|
|
615
|
+
if [[ -z "$filter_tag" ]]; then
|
|
616
|
+
printf '%d\n' "$i"
|
|
617
|
+
else
|
|
618
|
+
local tags="${YAML_REPO_TAGS[$i]}"
|
|
619
|
+
# Check if tag is in comma-separated list
|
|
620
|
+
if [[ ",$tags," == *",$filter_tag,"* ]]; then
|
|
621
|
+
printf '%d\n' "$i"
|
|
622
|
+
fi
|
|
623
|
+
fi
|
|
624
|
+
done
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
# Get repo URL by index
|
|
628
|
+
yaml_get_url() {
|
|
629
|
+
local idx="$1"
|
|
630
|
+
printf '%s' "${YAML_REPO_URLS[$idx]:-}"
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
# Get repo path by index
|
|
634
|
+
yaml_get_path() {
|
|
635
|
+
local idx="$1"
|
|
636
|
+
printf '%s' "${YAML_REPO_PATHS[$idx]:-}"
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
# Get repo tags by index
|
|
640
|
+
yaml_get_tags() {
|
|
641
|
+
local idx="$1"
|
|
642
|
+
printf '%s' "${YAML_REPO_TAGS[$idx]:-}"
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
# Get repo depends_on list by index (comma-separated names)
|
|
646
|
+
yaml_get_deps() {
|
|
647
|
+
local idx="$1"
|
|
648
|
+
printf '%s' "${YAML_REPO_DEPS[$idx]:-}"
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
# Find repo index by name (path basename)
|
|
652
|
+
# Usage: idx=$(yaml_find_by_name "backend")
|
|
653
|
+
# Returns: index or -1 if not found
|
|
654
|
+
yaml_find_by_name() {
|
|
655
|
+
local name="$1"
|
|
656
|
+
local i
|
|
657
|
+
for ((i = 0; i < YAML_REPO_COUNT; i++)); do
|
|
658
|
+
if [[ "${YAML_REPO_PATHS[$i]}" == "$name" ]]; then
|
|
659
|
+
printf '%d' "$i"
|
|
660
|
+
return 0
|
|
661
|
+
fi
|
|
662
|
+
done
|
|
663
|
+
printf '%d' -1
|
|
664
|
+
return 1
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
# Write revo.yaml
|
|
668
|
+
# Usage: yaml_write "path/to/revo.yaml"
|
|
669
|
+
yaml_write() {
|
|
670
|
+
local file="$1"
|
|
671
|
+
local i
|
|
672
|
+
|
|
673
|
+
{
|
|
674
|
+
printf 'version: 1\n\n'
|
|
675
|
+
printf 'workspace:\n'
|
|
676
|
+
printf ' name: "%s"\n\n' "$YAML_WORKSPACE_NAME"
|
|
677
|
+
printf 'repos:\n'
|
|
678
|
+
|
|
679
|
+
for ((i = 0; i < YAML_REPO_COUNT; i++)); do
|
|
680
|
+
local url="${YAML_REPO_URLS[$i]}"
|
|
681
|
+
local path="${YAML_REPO_PATHS[$i]}"
|
|
682
|
+
local tags="${YAML_REPO_TAGS[$i]}"
|
|
683
|
+
local deps="${YAML_REPO_DEPS[$i]:-}"
|
|
684
|
+
|
|
685
|
+
printf ' - url: %s\n' "$url"
|
|
686
|
+
|
|
687
|
+
# Only write path if different from derived
|
|
688
|
+
local derived
|
|
689
|
+
derived=$(yaml_path_from_url "$url")
|
|
690
|
+
if [[ "$path" != "$derived" ]]; then
|
|
691
|
+
printf ' path: %s\n' "$path"
|
|
692
|
+
fi
|
|
693
|
+
|
|
694
|
+
# Write tags if present
|
|
695
|
+
if [[ -n "$tags" ]]; then
|
|
696
|
+
printf ' tags: [%s]\n' "$tags"
|
|
697
|
+
fi
|
|
698
|
+
|
|
699
|
+
# Write depends_on if present
|
|
700
|
+
if [[ -n "$deps" ]]; then
|
|
701
|
+
printf ' depends_on: [%s]\n' "$deps"
|
|
702
|
+
fi
|
|
703
|
+
done
|
|
704
|
+
|
|
705
|
+
printf '\ndefaults:\n'
|
|
706
|
+
printf ' branch: %s\n' "$YAML_DEFAULTS_BRANCH"
|
|
707
|
+
} > "$file"
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
# Add a repo to the config
|
|
711
|
+
# Usage: yaml_add_repo "url" "path" "tags" "deps"
|
|
712
|
+
yaml_add_repo() {
|
|
713
|
+
local url="$1"
|
|
714
|
+
local path="${2:-}"
|
|
715
|
+
local tags="${3:-}"
|
|
716
|
+
local deps="${4:-}"
|
|
717
|
+
|
|
718
|
+
local idx=$YAML_REPO_COUNT
|
|
719
|
+
|
|
720
|
+
YAML_REPO_URLS[$idx]="$url"
|
|
721
|
+
|
|
722
|
+
if [[ -z "$path" ]]; then
|
|
723
|
+
path=$(yaml_path_from_url "$url")
|
|
724
|
+
fi
|
|
725
|
+
YAML_REPO_PATHS[$idx]="$path"
|
|
726
|
+
YAML_REPO_TAGS[$idx]="$tags"
|
|
727
|
+
YAML_REPO_DEPS[$idx]="$deps"
|
|
728
|
+
|
|
729
|
+
YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
# === lib/config.sh ===
|
|
733
|
+
# Revo CLI - Configuration Management
|
|
734
|
+
# Handles workspace detection and config loading/saving
|
|
735
|
+
|
|
736
|
+
REVO_WORKSPACE_ROOT=""
|
|
737
|
+
REVO_CONFIG_FILE=""
|
|
738
|
+
REVO_REPOS_DIR=""
|
|
739
|
+
|
|
740
|
+
# Find workspace root by searching upward for revo.yaml (or mars.yaml as fallback)
|
|
741
|
+
# Usage: config_find_root [start_dir]
|
|
742
|
+
# Returns: 0 if found (sets REVO_WORKSPACE_ROOT), 1 if not found
|
|
743
|
+
config_find_root() {
|
|
744
|
+
local start_dir="${1:-$PWD}"
|
|
745
|
+
local current="$start_dir"
|
|
746
|
+
|
|
747
|
+
while [[ "$current" != "/" ]]; do
|
|
748
|
+
if [[ -f "$current/revo.yaml" ]]; then
|
|
749
|
+
REVO_WORKSPACE_ROOT="$current"
|
|
750
|
+
REVO_CONFIG_FILE="$current/revo.yaml"
|
|
751
|
+
REVO_REPOS_DIR="$current/repos"
|
|
752
|
+
return 0
|
|
753
|
+
fi
|
|
754
|
+
# Fallback: support mars.yaml for migration from Mars
|
|
755
|
+
if [[ -f "$current/mars.yaml" ]]; then
|
|
756
|
+
REVO_WORKSPACE_ROOT="$current"
|
|
757
|
+
REVO_CONFIG_FILE="$current/mars.yaml"
|
|
758
|
+
REVO_REPOS_DIR="$current/repos"
|
|
759
|
+
return 0
|
|
760
|
+
fi
|
|
761
|
+
current="$(dirname "$current")"
|
|
762
|
+
done
|
|
763
|
+
|
|
764
|
+
return 1
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
# Initialize workspace in current directory
|
|
768
|
+
# Usage: config_init "workspace_name"
|
|
769
|
+
# Returns: 0 on success, 1 if already initialized
|
|
770
|
+
config_init() {
|
|
771
|
+
local workspace_name="$1"
|
|
772
|
+
local dir="${2:-$PWD}"
|
|
773
|
+
|
|
774
|
+
if [[ -f "$dir/revo.yaml" ]] || [[ -f "$dir/mars.yaml" ]]; then
|
|
775
|
+
return 1
|
|
776
|
+
fi
|
|
777
|
+
|
|
778
|
+
REVO_WORKSPACE_ROOT="$dir"
|
|
779
|
+
REVO_CONFIG_FILE="$dir/revo.yaml"
|
|
780
|
+
REVO_REPOS_DIR="$dir/repos"
|
|
781
|
+
|
|
782
|
+
# Set workspace name for yaml module
|
|
783
|
+
YAML_WORKSPACE_NAME="$workspace_name"
|
|
784
|
+
YAML_DEFAULTS_BRANCH="main"
|
|
785
|
+
YAML_REPO_COUNT=0
|
|
786
|
+
YAML_REPO_URLS=()
|
|
787
|
+
YAML_REPO_PATHS=()
|
|
788
|
+
YAML_REPO_TAGS=()
|
|
789
|
+
YAML_REPO_DEPS=()
|
|
790
|
+
|
|
791
|
+
# Create directory structure
|
|
792
|
+
mkdir -p "$REVO_REPOS_DIR"
|
|
793
|
+
|
|
794
|
+
# Write config
|
|
795
|
+
yaml_write "$REVO_CONFIG_FILE"
|
|
796
|
+
|
|
797
|
+
# Create .gitignore
|
|
798
|
+
printf 'repos/\n.revo/\n' > "$dir/.gitignore"
|
|
799
|
+
|
|
800
|
+
return 0
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
# Load configuration
|
|
804
|
+
# Usage: config_load
|
|
805
|
+
# Returns: 0 on success, 1 on failure
|
|
806
|
+
config_load() {
|
|
807
|
+
if [[ -z "$REVO_CONFIG_FILE" ]] || [[ ! -f "$REVO_CONFIG_FILE" ]]; then
|
|
808
|
+
return 1
|
|
809
|
+
fi
|
|
810
|
+
|
|
811
|
+
yaml_parse "$REVO_CONFIG_FILE"
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# Save configuration
|
|
815
|
+
# Usage: config_save
|
|
816
|
+
config_save() {
|
|
817
|
+
if [[ -z "$REVO_CONFIG_FILE" ]]; then
|
|
818
|
+
return 1
|
|
819
|
+
fi
|
|
820
|
+
|
|
821
|
+
yaml_write "$REVO_CONFIG_FILE"
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
# Get repos (optionally filtered by tag)
|
|
825
|
+
# Usage: repos=$(config_get_repos [tag])
|
|
826
|
+
config_get_repos() {
|
|
827
|
+
local tag="${1:-}"
|
|
828
|
+
yaml_get_repos "$tag"
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
# Get repo count
|
|
832
|
+
# Usage: count=$(config_repo_count [tag])
|
|
833
|
+
config_repo_count() {
|
|
834
|
+
local tag="${1:-}"
|
|
835
|
+
local count=0
|
|
836
|
+
local repos
|
|
837
|
+
repos=$(config_get_repos "$tag")
|
|
838
|
+
|
|
839
|
+
while IFS= read -r repo; do
|
|
840
|
+
[[ -n "$repo" ]] && count=$((count + 1))
|
|
841
|
+
done <<< "$repos"
|
|
842
|
+
|
|
843
|
+
printf '%d' "$count"
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
# Check if repo directory exists
|
|
847
|
+
# Usage: if config_repo_exists "repo_index"; then ...
|
|
848
|
+
config_repo_exists() {
|
|
849
|
+
local idx="$1"
|
|
850
|
+
local path
|
|
851
|
+
path=$(yaml_get_path "$idx")
|
|
852
|
+
[[ -d "$REVO_REPOS_DIR/$path" ]]
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
# Get full path to repo
|
|
856
|
+
# Usage: full_path=$(config_repo_full_path "repo_index")
|
|
857
|
+
config_repo_full_path() {
|
|
858
|
+
local idx="$1"
|
|
859
|
+
local path
|
|
860
|
+
path=$(yaml_get_path "$idx")
|
|
861
|
+
printf '%s/%s' "$REVO_REPOS_DIR" "$path"
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
# Require workspace context
|
|
865
|
+
# Usage: config_require_workspace || return 1
|
|
866
|
+
# Prints error and returns 1 if not in workspace
|
|
867
|
+
config_require_workspace() {
|
|
868
|
+
if ! config_find_root; then
|
|
869
|
+
printf 'Error: Not in a Revo workspace. Run "revo init" first.\n' >&2
|
|
870
|
+
return 1
|
|
871
|
+
fi
|
|
872
|
+
config_load
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
# Check if path is inside workspace
|
|
876
|
+
# Usage: if config_is_in_workspace "/some/path"; then ...
|
|
877
|
+
config_is_in_workspace() {
|
|
878
|
+
local path="$1"
|
|
879
|
+
[[ "$path" == "$REVO_WORKSPACE_ROOT"* ]]
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
# Get workspace name
|
|
883
|
+
config_workspace_name() {
|
|
884
|
+
printf '%s' "$YAML_WORKSPACE_NAME"
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
# Get default branch
|
|
888
|
+
config_default_branch() {
|
|
889
|
+
printf '%s' "$YAML_DEFAULTS_BRANCH"
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
# === lib/git.sh ===
|
|
893
|
+
# Revo CLI - Git Operations
|
|
894
|
+
# Wrapper functions for git commands with consistent error handling
|
|
895
|
+
|
|
896
|
+
# Result pattern: functions return 0 on success, non-zero on failure
|
|
897
|
+
# Output is captured in global variables to avoid subshell issues
|
|
898
|
+
|
|
899
|
+
GIT_OUTPUT=""
|
|
900
|
+
GIT_ERROR=""
|
|
901
|
+
|
|
902
|
+
# Clone a repository
|
|
903
|
+
# Usage: git_clone "url" "target_dir"
|
|
904
|
+
# Returns: 0 on success, 1 on failure
|
|
905
|
+
git_clone() {
|
|
906
|
+
local url="$1"
|
|
907
|
+
local target="$2"
|
|
908
|
+
|
|
909
|
+
GIT_OUTPUT=""
|
|
910
|
+
GIT_ERROR=""
|
|
911
|
+
|
|
912
|
+
if [[ -d "$target" ]]; then
|
|
913
|
+
GIT_ERROR="Directory already exists: $target"
|
|
914
|
+
return 1
|
|
915
|
+
fi
|
|
916
|
+
|
|
917
|
+
if GIT_OUTPUT=$(git clone --progress "$url" "$target" 2>&1); then
|
|
918
|
+
return 0
|
|
919
|
+
else
|
|
920
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
921
|
+
return 1
|
|
922
|
+
fi
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
# Get repository status
|
|
926
|
+
# Usage: git_status "repo_dir"
|
|
927
|
+
# Sets: GIT_OUTPUT with status info
|
|
928
|
+
# Returns: 0 on success
|
|
929
|
+
git_status() {
|
|
930
|
+
local repo_dir="$1"
|
|
931
|
+
|
|
932
|
+
GIT_OUTPUT=""
|
|
933
|
+
GIT_ERROR=""
|
|
934
|
+
|
|
935
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
936
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
937
|
+
return 1
|
|
938
|
+
fi
|
|
939
|
+
|
|
940
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" status --porcelain 2>&1); then
|
|
941
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
942
|
+
return 1
|
|
943
|
+
fi
|
|
944
|
+
|
|
945
|
+
return 0
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
# Check if repo has uncommitted changes
|
|
949
|
+
# Usage: if git_is_dirty "repo_dir"; then ...
|
|
950
|
+
git_is_dirty() {
|
|
951
|
+
local repo_dir="$1"
|
|
952
|
+
|
|
953
|
+
git_status "$repo_dir" || return 1
|
|
954
|
+
[[ -n "$GIT_OUTPUT" ]]
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
# Get current branch name
|
|
958
|
+
# Usage: branch=$(git_current_branch "repo_dir")
|
|
959
|
+
git_current_branch() {
|
|
960
|
+
local repo_dir="$1"
|
|
961
|
+
|
|
962
|
+
git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
# Create a new branch
|
|
966
|
+
# Usage: git_branch "repo_dir" "branch_name"
|
|
967
|
+
# Returns: 0 on success
|
|
968
|
+
git_branch() {
|
|
969
|
+
local repo_dir="$1"
|
|
970
|
+
local branch_name="$2"
|
|
971
|
+
|
|
972
|
+
GIT_OUTPUT=""
|
|
973
|
+
GIT_ERROR=""
|
|
974
|
+
|
|
975
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
976
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
977
|
+
return 1
|
|
978
|
+
fi
|
|
979
|
+
|
|
980
|
+
# Check if branch already exists
|
|
981
|
+
if git -C "$repo_dir" rev-parse --verify "$branch_name" >/dev/null 2>&1; then
|
|
982
|
+
GIT_ERROR="Branch already exists: $branch_name"
|
|
983
|
+
return 1
|
|
984
|
+
fi
|
|
985
|
+
|
|
986
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" checkout -b "$branch_name" 2>&1); then
|
|
987
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
988
|
+
return 1
|
|
989
|
+
fi
|
|
990
|
+
|
|
991
|
+
return 0
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
# Checkout existing branch
|
|
995
|
+
# Usage: git_checkout "repo_dir" "branch_name"
|
|
996
|
+
# Returns: 0 on success
|
|
997
|
+
git_checkout() {
|
|
998
|
+
local repo_dir="$1"
|
|
999
|
+
local branch_name="$2"
|
|
1000
|
+
|
|
1001
|
+
GIT_OUTPUT=""
|
|
1002
|
+
GIT_ERROR=""
|
|
1003
|
+
|
|
1004
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
1005
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
1006
|
+
return 1
|
|
1007
|
+
fi
|
|
1008
|
+
|
|
1009
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" checkout "$branch_name" 2>&1); then
|
|
1010
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1011
|
+
return 1
|
|
1012
|
+
fi
|
|
1013
|
+
|
|
1014
|
+
return 0
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
# Pull latest changes
|
|
1018
|
+
# Usage: git_pull "repo_dir" [--rebase]
|
|
1019
|
+
# Returns: 0 on success
|
|
1020
|
+
git_pull() {
|
|
1021
|
+
local repo_dir="$1"
|
|
1022
|
+
local rebase="${2:-}"
|
|
1023
|
+
|
|
1024
|
+
GIT_OUTPUT=""
|
|
1025
|
+
GIT_ERROR=""
|
|
1026
|
+
|
|
1027
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
1028
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
1029
|
+
return 1
|
|
1030
|
+
fi
|
|
1031
|
+
|
|
1032
|
+
local args=()
|
|
1033
|
+
[[ "$rebase" == "--rebase" ]] && args+=("--rebase")
|
|
1034
|
+
|
|
1035
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" pull "${args[@]}" 2>&1); then
|
|
1036
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1037
|
+
return 1
|
|
1038
|
+
fi
|
|
1039
|
+
|
|
1040
|
+
return 0
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
# Fetch from remote
|
|
1044
|
+
# Usage: git_fetch "repo_dir"
|
|
1045
|
+
git_fetch() {
|
|
1046
|
+
local repo_dir="$1"
|
|
1047
|
+
|
|
1048
|
+
GIT_OUTPUT=""
|
|
1049
|
+
GIT_ERROR=""
|
|
1050
|
+
|
|
1051
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
1052
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
1053
|
+
return 1
|
|
1054
|
+
fi
|
|
1055
|
+
|
|
1056
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" fetch 2>&1); then
|
|
1057
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1058
|
+
return 1
|
|
1059
|
+
fi
|
|
1060
|
+
|
|
1061
|
+
return 0
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
# Get ahead/behind counts relative to upstream
|
|
1065
|
+
# Usage: git_ahead_behind "repo_dir"
|
|
1066
|
+
# Sets: GIT_AHEAD, GIT_BEHIND
|
|
1067
|
+
GIT_AHEAD=0
|
|
1068
|
+
GIT_BEHIND=0
|
|
1069
|
+
|
|
1070
|
+
git_ahead_behind() {
|
|
1071
|
+
local repo_dir="$1"
|
|
1072
|
+
|
|
1073
|
+
GIT_AHEAD=0
|
|
1074
|
+
GIT_BEHIND=0
|
|
1075
|
+
|
|
1076
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
1077
|
+
return 1
|
|
1078
|
+
fi
|
|
1079
|
+
|
|
1080
|
+
local upstream
|
|
1081
|
+
upstream=$(git -C "$repo_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || return 0
|
|
1082
|
+
|
|
1083
|
+
local counts
|
|
1084
|
+
counts=$(git -C "$repo_dir" rev-list --left-right --count "$upstream...HEAD" 2>/dev/null) || return 0
|
|
1085
|
+
|
|
1086
|
+
GIT_BEHIND=$(echo "$counts" | cut -f1)
|
|
1087
|
+
GIT_AHEAD=$(echo "$counts" | cut -f2)
|
|
1088
|
+
|
|
1089
|
+
return 0
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
# Get remote URL
|
|
1093
|
+
# Usage: url=$(git_remote_url "repo_dir")
|
|
1094
|
+
git_remote_url() {
|
|
1095
|
+
local repo_dir="$1"
|
|
1096
|
+
|
|
1097
|
+
git -C "$repo_dir" remote get-url origin 2>/dev/null
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
# Check if branch exists (local or remote)
|
|
1101
|
+
# Usage: if git_branch_exists "repo_dir" "branch_name"; then ...
|
|
1102
|
+
git_branch_exists() {
|
|
1103
|
+
local repo_dir="$1"
|
|
1104
|
+
local branch_name="$2"
|
|
1105
|
+
|
|
1106
|
+
git -C "$repo_dir" rev-parse --verify "$branch_name" >/dev/null 2>&1 ||
|
|
1107
|
+
git -C "$repo_dir" rev-parse --verify "origin/$branch_name" >/dev/null 2>&1
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
# Stash changes
|
|
1111
|
+
# Usage: git_stash "repo_dir"
|
|
1112
|
+
git_stash() {
|
|
1113
|
+
local repo_dir="$1"
|
|
1114
|
+
|
|
1115
|
+
GIT_OUTPUT=""
|
|
1116
|
+
GIT_ERROR=""
|
|
1117
|
+
|
|
1118
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" stash 2>&1); then
|
|
1119
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1120
|
+
return 1
|
|
1121
|
+
fi
|
|
1122
|
+
|
|
1123
|
+
return 0
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
# Pop stash
|
|
1127
|
+
# Usage: git_stash_pop "repo_dir"
|
|
1128
|
+
git_stash_pop() {
|
|
1129
|
+
local repo_dir="$1"
|
|
1130
|
+
|
|
1131
|
+
GIT_OUTPUT=""
|
|
1132
|
+
GIT_ERROR=""
|
|
1133
|
+
|
|
1134
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" stash pop 2>&1); then
|
|
1135
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1136
|
+
return 1
|
|
1137
|
+
fi
|
|
1138
|
+
|
|
1139
|
+
return 0
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
# Get short commit hash
|
|
1143
|
+
# Usage: hash=$(git_short_hash "repo_dir")
|
|
1144
|
+
git_short_hash() {
|
|
1145
|
+
local repo_dir="$1"
|
|
1146
|
+
|
|
1147
|
+
git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
# Run arbitrary git command
|
|
1151
|
+
# Usage: git_exec "repo_dir" "command" "args..."
|
|
1152
|
+
git_exec() {
|
|
1153
|
+
local repo_dir="$1"
|
|
1154
|
+
shift
|
|
1155
|
+
local cmd=("$@")
|
|
1156
|
+
|
|
1157
|
+
GIT_OUTPUT=""
|
|
1158
|
+
GIT_ERROR=""
|
|
1159
|
+
|
|
1160
|
+
if [[ ! -d "$repo_dir/.git" ]]; then
|
|
1161
|
+
GIT_ERROR="Not a git repository: $repo_dir"
|
|
1162
|
+
return 1
|
|
1163
|
+
fi
|
|
1164
|
+
|
|
1165
|
+
if ! GIT_OUTPUT=$(git -C "$repo_dir" "${cmd[@]}" 2>&1); then
|
|
1166
|
+
GIT_ERROR="$GIT_OUTPUT"
|
|
1167
|
+
return 1
|
|
1168
|
+
fi
|
|
1169
|
+
|
|
1170
|
+
return 0
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
# === lib/scan.sh ===
|
|
1174
|
+
# Revo CLI - Repo Scanner
|
|
1175
|
+
# Detects language, framework, routes, and metadata per repo.
|
|
1176
|
+
# Uses globals to avoid subshells (bash 3.2 compatibility).
|
|
1177
|
+
|
|
1178
|
+
SCAN_NAME=""
|
|
1179
|
+
SCAN_LANG=""
|
|
1180
|
+
SCAN_FRAMEWORK=""
|
|
1181
|
+
SCAN_ROUTES=""
|
|
1182
|
+
SCAN_DESCRIPTION=""
|
|
1183
|
+
SCAN_HAS_CLAUDE_MD=0
|
|
1184
|
+
SCAN_HAS_DOCKER=0
|
|
1185
|
+
|
|
1186
|
+
# Reset all SCAN_* globals
|
|
1187
|
+
scan_reset() {
|
|
1188
|
+
SCAN_NAME=""
|
|
1189
|
+
SCAN_LANG=""
|
|
1190
|
+
SCAN_FRAMEWORK=""
|
|
1191
|
+
SCAN_ROUTES=""
|
|
1192
|
+
SCAN_DESCRIPTION=""
|
|
1193
|
+
SCAN_HAS_CLAUDE_MD=0
|
|
1194
|
+
SCAN_HAS_DOCKER=0
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
# Extract a top-level JSON string value from a file.
|
|
1198
|
+
# Usage: val=$(_scan_json_string "path/to/file.json" "name")
|
|
1199
|
+
# Handles simple cases only; no nested objects.
|
|
1200
|
+
_scan_json_string() {
|
|
1201
|
+
local file="$1"
|
|
1202
|
+
local key="$2"
|
|
1203
|
+
local line
|
|
1204
|
+
# Match: "key": "value" (value may contain escaped chars but no quotes)
|
|
1205
|
+
line=$(grep -m1 -E "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null || true)
|
|
1206
|
+
[[ -z "$line" ]] && return 1
|
|
1207
|
+
# Strip to the value
|
|
1208
|
+
line="${line#*\"$key\"}"
|
|
1209
|
+
line="${line#*:}"
|
|
1210
|
+
line="${line#*\"}"
|
|
1211
|
+
line="${line%%\"*}"
|
|
1212
|
+
printf '%s' "$line"
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
# Returns 0 if the package.json has "dep_name" anywhere in a dependencies block
|
|
1216
|
+
_scan_pkg_has_dep() {
|
|
1217
|
+
local file="$1"
|
|
1218
|
+
local dep="$2"
|
|
1219
|
+
grep -q "\"$dep\"[[:space:]]*:" "$file" 2>/dev/null
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
# Detect framework from package.json dependencies
|
|
1223
|
+
_scan_node_framework() {
|
|
1224
|
+
local file="$1"
|
|
1225
|
+
if _scan_pkg_has_dep "$file" "next"; then
|
|
1226
|
+
printf 'Next.js'
|
|
1227
|
+
elif _scan_pkg_has_dep "$file" "nuxt"; then
|
|
1228
|
+
printf 'Nuxt'
|
|
1229
|
+
elif _scan_pkg_has_dep "$file" "@remix-run/react"; then
|
|
1230
|
+
printf 'Remix'
|
|
1231
|
+
elif _scan_pkg_has_dep "$file" "@sveltejs/kit"; then
|
|
1232
|
+
printf 'SvelteKit'
|
|
1233
|
+
elif _scan_pkg_has_dep "$file" "astro"; then
|
|
1234
|
+
printf 'Astro'
|
|
1235
|
+
elif _scan_pkg_has_dep "$file" "vite"; then
|
|
1236
|
+
printf 'Vite'
|
|
1237
|
+
elif _scan_pkg_has_dep "$file" "nestjs" || _scan_pkg_has_dep "$file" "@nestjs/core"; then
|
|
1238
|
+
printf 'NestJS'
|
|
1239
|
+
elif _scan_pkg_has_dep "$file" "fastify"; then
|
|
1240
|
+
printf 'Fastify'
|
|
1241
|
+
elif _scan_pkg_has_dep "$file" "express"; then
|
|
1242
|
+
printf 'Express'
|
|
1243
|
+
elif _scan_pkg_has_dep "$file" "hono"; then
|
|
1244
|
+
printf 'Hono'
|
|
1245
|
+
elif _scan_pkg_has_dep "$file" "react"; then
|
|
1246
|
+
printf 'React'
|
|
1247
|
+
elif _scan_pkg_has_dep "$file" "vue"; then
|
|
1248
|
+
printf 'Vue'
|
|
1249
|
+
elif _scan_pkg_has_dep "$file" "svelte"; then
|
|
1250
|
+
printf 'Svelte'
|
|
1251
|
+
fi
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
# Detect framework from Python dependency files
|
|
1255
|
+
_scan_python_framework() {
|
|
1256
|
+
local repo_dir="$1"
|
|
1257
|
+
local files=()
|
|
1258
|
+
[[ -f "$repo_dir/requirements.txt" ]] && files+=("$repo_dir/requirements.txt")
|
|
1259
|
+
[[ -f "$repo_dir/pyproject.toml" ]] && files+=("$repo_dir/pyproject.toml")
|
|
1260
|
+
[[ -f "$repo_dir/Pipfile" ]] && files+=("$repo_dir/Pipfile")
|
|
1261
|
+
|
|
1262
|
+
[[ ${#files[@]} -eq 0 ]] && return
|
|
1263
|
+
|
|
1264
|
+
if grep -qiE '(^|[^a-z])django([^a-z]|$)' "${files[@]}" 2>/dev/null; then
|
|
1265
|
+
printf 'Django'
|
|
1266
|
+
elif grep -qiE '(^|[^a-z])fastapi([^a-z]|$)' "${files[@]}" 2>/dev/null; then
|
|
1267
|
+
printf 'FastAPI'
|
|
1268
|
+
elif grep -qiE '(^|[^a-z])flask([^a-z]|$)' "${files[@]}" 2>/dev/null; then
|
|
1269
|
+
printf 'Flask'
|
|
1270
|
+
elif grep -qiE '(^|[^a-z])starlette([^a-z]|$)' "${files[@]}" 2>/dev/null; then
|
|
1271
|
+
printf 'Starlette'
|
|
1272
|
+
fi
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
# List route files in common route directories.
|
|
1276
|
+
# Returns comma-separated list (max 6 files, basename only).
|
|
1277
|
+
_scan_list_routes() {
|
|
1278
|
+
local repo_dir="$1"
|
|
1279
|
+
local dirs=(
|
|
1280
|
+
"src/routes"
|
|
1281
|
+
"src/api"
|
|
1282
|
+
"src/controllers"
|
|
1283
|
+
"app/api"
|
|
1284
|
+
"app/routes"
|
|
1285
|
+
"routes"
|
|
1286
|
+
"api"
|
|
1287
|
+
"pages/api"
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
local found=()
|
|
1291
|
+
local dir
|
|
1292
|
+
for dir in "${dirs[@]}"; do
|
|
1293
|
+
local full="$repo_dir/$dir"
|
|
1294
|
+
[[ ! -d "$full" ]] && continue
|
|
1295
|
+
|
|
1296
|
+
# Find code files (not tests), max depth 2
|
|
1297
|
+
local file
|
|
1298
|
+
while IFS= read -r file; do
|
|
1299
|
+
[[ -z "$file" ]] && continue
|
|
1300
|
+
[[ "$file" == *".test."* ]] && continue
|
|
1301
|
+
[[ "$file" == *".spec."* ]] && continue
|
|
1302
|
+
found+=("$(basename "$file")")
|
|
1303
|
+
# Cap the number of results
|
|
1304
|
+
[[ ${#found[@]} -ge 6 ]] && break
|
|
1305
|
+
done < <(find "$full" -maxdepth 2 -type f \( -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" -o -name "*.py" -o -name "*.go" -o -name "*.rs" \) 2>/dev/null | sort)
|
|
1306
|
+
|
|
1307
|
+
[[ ${#found[@]} -ge 6 ]] && break
|
|
1308
|
+
done
|
|
1309
|
+
|
|
1310
|
+
# Join as comma-separated
|
|
1311
|
+
local result=""
|
|
1312
|
+
local f
|
|
1313
|
+
for f in "${found[@]}"; do
|
|
1314
|
+
if [[ -z "$result" ]]; then
|
|
1315
|
+
result="$f"
|
|
1316
|
+
else
|
|
1317
|
+
result="$result, $f"
|
|
1318
|
+
fi
|
|
1319
|
+
done
|
|
1320
|
+
printf '%s' "$result"
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
# First non-empty, non-heading line of README.md (trimmed, first 100 chars)
|
|
1324
|
+
_scan_readme_description() {
|
|
1325
|
+
local readme="$1"
|
|
1326
|
+
[[ ! -f "$readme" ]] && return
|
|
1327
|
+
|
|
1328
|
+
local line
|
|
1329
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
1330
|
+
# Skip empty/whitespace
|
|
1331
|
+
[[ -z "${line// }" ]] && continue
|
|
1332
|
+
# Skip heading lines
|
|
1333
|
+
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
1334
|
+
# Skip HTML tags (often used for logos/badges at the top)
|
|
1335
|
+
[[ "$line" =~ ^[[:space:]]*\< ]] && continue
|
|
1336
|
+
# Skip badge lines
|
|
1337
|
+
[[ "$line" =~ ^[[:space:]]*\[!\[ ]] && continue
|
|
1338
|
+
|
|
1339
|
+
# Trim
|
|
1340
|
+
line="${line#"${line%%[![:space:]]*}"}"
|
|
1341
|
+
line="${line%"${line##*[![:space:]]}"}"
|
|
1342
|
+
|
|
1343
|
+
# Cap length
|
|
1344
|
+
if [[ ${#line} -gt 100 ]]; then
|
|
1345
|
+
line="${line:0:100}..."
|
|
1346
|
+
fi
|
|
1347
|
+
printf '%s' "$line"
|
|
1348
|
+
return 0
|
|
1349
|
+
done < "$readme"
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
# Scan a repository directory and populate SCAN_* globals.
|
|
1353
|
+
# Usage: scan_repo "/path/to/repo"
|
|
1354
|
+
scan_repo() {
|
|
1355
|
+
local repo_dir="$1"
|
|
1356
|
+
scan_reset
|
|
1357
|
+
|
|
1358
|
+
[[ ! -d "$repo_dir" ]] && return 1
|
|
1359
|
+
|
|
1360
|
+
# Node.js
|
|
1361
|
+
if [[ -f "$repo_dir/package.json" ]]; then
|
|
1362
|
+
SCAN_LANG="Node.js"
|
|
1363
|
+
SCAN_NAME=$(_scan_json_string "$repo_dir/package.json" "name" || true)
|
|
1364
|
+
SCAN_FRAMEWORK=$(_scan_node_framework "$repo_dir/package.json")
|
|
1365
|
+
fi
|
|
1366
|
+
|
|
1367
|
+
# Python
|
|
1368
|
+
if [[ -z "$SCAN_LANG" ]] && { [[ -f "$repo_dir/pyproject.toml" ]] || [[ -f "$repo_dir/requirements.txt" ]] || [[ -f "$repo_dir/Pipfile" ]]; }; then
|
|
1369
|
+
SCAN_LANG="Python"
|
|
1370
|
+
if [[ -f "$repo_dir/pyproject.toml" ]]; then
|
|
1371
|
+
local pyname
|
|
1372
|
+
pyname=$(grep -m1 -E '^[[:space:]]*name[[:space:]]*=' "$repo_dir/pyproject.toml" 2>/dev/null | sed -E 's/.*=[[:space:]]*"([^"]*)".*/\1/' || true)
|
|
1373
|
+
[[ -n "$pyname" ]] && SCAN_NAME="$pyname"
|
|
1374
|
+
fi
|
|
1375
|
+
SCAN_FRAMEWORK=$(_scan_python_framework "$repo_dir")
|
|
1376
|
+
fi
|
|
1377
|
+
|
|
1378
|
+
# Go
|
|
1379
|
+
if [[ -z "$SCAN_LANG" ]] && [[ -f "$repo_dir/go.mod" ]]; then
|
|
1380
|
+
SCAN_LANG="Go"
|
|
1381
|
+
SCAN_NAME=$(grep -m1 '^module ' "$repo_dir/go.mod" 2>/dev/null | awk '{print $2}' || true)
|
|
1382
|
+
fi
|
|
1383
|
+
|
|
1384
|
+
# Rust
|
|
1385
|
+
if [[ -z "$SCAN_LANG" ]] && [[ -f "$repo_dir/Cargo.toml" ]]; then
|
|
1386
|
+
SCAN_LANG="Rust"
|
|
1387
|
+
SCAN_NAME=$(grep -m1 -E '^[[:space:]]*name[[:space:]]*=' "$repo_dir/Cargo.toml" 2>/dev/null | sed -E 's/.*=[[:space:]]*"([^"]*)".*/\1/' || true)
|
|
1388
|
+
fi
|
|
1389
|
+
|
|
1390
|
+
# Routes
|
|
1391
|
+
SCAN_ROUTES=$(_scan_list_routes "$repo_dir")
|
|
1392
|
+
|
|
1393
|
+
# README description
|
|
1394
|
+
if [[ -f "$repo_dir/README.md" ]]; then
|
|
1395
|
+
SCAN_DESCRIPTION=$(_scan_readme_description "$repo_dir/README.md")
|
|
1396
|
+
elif [[ -f "$repo_dir/readme.md" ]]; then
|
|
1397
|
+
SCAN_DESCRIPTION=$(_scan_readme_description "$repo_dir/readme.md")
|
|
1398
|
+
fi
|
|
1399
|
+
|
|
1400
|
+
# CLAUDE.md
|
|
1401
|
+
if [[ -f "$repo_dir/CLAUDE.md" ]]; then
|
|
1402
|
+
SCAN_HAS_CLAUDE_MD=1
|
|
1403
|
+
fi
|
|
1404
|
+
|
|
1405
|
+
# Docker
|
|
1406
|
+
if [[ -f "$repo_dir/Dockerfile" ]] || [[ -f "$repo_dir/docker-compose.yml" ]] || [[ -f "$repo_dir/docker-compose.yaml" ]] || [[ -f "$repo_dir/compose.yml" ]] || [[ -f "$repo_dir/compose.yaml" ]]; then
|
|
1407
|
+
SCAN_HAS_DOCKER=1
|
|
1408
|
+
fi
|
|
1409
|
+
|
|
1410
|
+
return 0
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
# === lib/commands/init.sh ===
|
|
1414
|
+
# Revo CLI - init command
|
|
1415
|
+
# Interactive workspace initialization
|
|
1416
|
+
|
|
1417
|
+
cmd_init() {
|
|
1418
|
+
local workspace_name=""
|
|
1419
|
+
|
|
1420
|
+
# Check if already initialized
|
|
1421
|
+
if [[ -f "revo.yaml" ]] || [[ -f "mars.yaml" ]]; then
|
|
1422
|
+
ui_step_error "Workspace already initialized in this directory"
|
|
1423
|
+
return 1
|
|
1424
|
+
fi
|
|
1425
|
+
|
|
1426
|
+
ui_intro "Revo - Claude-first Multi-Repo Workspace"
|
|
1427
|
+
|
|
1428
|
+
# Get workspace name
|
|
1429
|
+
ui_step "Workspace name?"
|
|
1430
|
+
printf '%s ' "$(ui_bar)"
|
|
1431
|
+
read -r workspace_name
|
|
1432
|
+
|
|
1433
|
+
if [[ -z "$workspace_name" ]]; then
|
|
1434
|
+
ui_outro_cancel "Cancelled - workspace name is required"
|
|
1435
|
+
return 1
|
|
1436
|
+
fi
|
|
1437
|
+
|
|
1438
|
+
ui_step_done "Workspace:" "$workspace_name"
|
|
1439
|
+
ui_bar_line
|
|
1440
|
+
|
|
1441
|
+
# Initialize workspace
|
|
1442
|
+
if ! config_init "$workspace_name"; then
|
|
1443
|
+
ui_step_error "Failed to initialize workspace"
|
|
1444
|
+
return 1
|
|
1445
|
+
fi
|
|
1446
|
+
|
|
1447
|
+
ui_step_done "Created revo.yaml"
|
|
1448
|
+
ui_step_done "Created .gitignore"
|
|
1449
|
+
ui_step_done "Created repos/ directory"
|
|
1450
|
+
|
|
1451
|
+
ui_outro "Workspace initialized! Run 'revo add <url>' to add repositories."
|
|
1452
|
+
|
|
1453
|
+
return 0
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
# === lib/commands/clone.sh ===
|
|
1457
|
+
# Revo CLI - clone command
|
|
1458
|
+
# Clone configured repositories with per-repo progress
|
|
1459
|
+
|
|
1460
|
+
cmd_clone() {
|
|
1461
|
+
local tag=""
|
|
1462
|
+
local force=0
|
|
1463
|
+
|
|
1464
|
+
# Parse arguments
|
|
1465
|
+
while [[ $# -gt 0 ]]; do
|
|
1466
|
+
case "$1" in
|
|
1467
|
+
--tag)
|
|
1468
|
+
tag="$2"
|
|
1469
|
+
shift 2
|
|
1470
|
+
;;
|
|
1471
|
+
--force|-f)
|
|
1472
|
+
force=1
|
|
1473
|
+
shift
|
|
1474
|
+
;;
|
|
1475
|
+
*)
|
|
1476
|
+
ui_step_error "Unknown option: $1"
|
|
1477
|
+
return 1
|
|
1478
|
+
;;
|
|
1479
|
+
esac
|
|
1480
|
+
done
|
|
1481
|
+
|
|
1482
|
+
config_require_workspace || return 1
|
|
1483
|
+
|
|
1484
|
+
ui_intro "Revo - Clone Repositories"
|
|
1485
|
+
|
|
1486
|
+
local repos
|
|
1487
|
+
repos=$(config_get_repos "$tag")
|
|
1488
|
+
|
|
1489
|
+
if [[ -z "$repos" ]]; then
|
|
1490
|
+
if [[ -n "$tag" ]]; then
|
|
1491
|
+
ui_step_error "No repositories found with tag: $tag"
|
|
1492
|
+
else
|
|
1493
|
+
ui_step_error "No repositories configured. Run 'revo add <url>' first."
|
|
1494
|
+
fi
|
|
1495
|
+
ui_outro_cancel "Nothing to clone"
|
|
1496
|
+
return 1
|
|
1497
|
+
fi
|
|
1498
|
+
|
|
1499
|
+
# Count total repos
|
|
1500
|
+
local total=0
|
|
1501
|
+
while IFS= read -r repo; do
|
|
1502
|
+
[[ -z "$repo" ]] && continue
|
|
1503
|
+
total=$((total + 1))
|
|
1504
|
+
done <<< "$repos"
|
|
1505
|
+
|
|
1506
|
+
local current=0
|
|
1507
|
+
local success_count=0
|
|
1508
|
+
local skip_count=0
|
|
1509
|
+
local fail_count=0
|
|
1510
|
+
|
|
1511
|
+
# Clone each repo with spinner feedback
|
|
1512
|
+
while IFS= read -r repo; do
|
|
1513
|
+
[[ -z "$repo" ]] && continue
|
|
1514
|
+
current=$((current + 1))
|
|
1515
|
+
|
|
1516
|
+
local url
|
|
1517
|
+
url=$(yaml_get_url "$repo")
|
|
1518
|
+
local path
|
|
1519
|
+
path=$(yaml_get_path "$repo")
|
|
1520
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
1521
|
+
|
|
1522
|
+
# Already cloned?
|
|
1523
|
+
if [[ -d "$full_path" ]] && [[ $force -eq 0 ]]; then
|
|
1524
|
+
ui_step_done "Already cloned:" "$path"
|
|
1525
|
+
skip_count=$((skip_count + 1))
|
|
1526
|
+
continue
|
|
1527
|
+
fi
|
|
1528
|
+
|
|
1529
|
+
# Remove existing directory if force
|
|
1530
|
+
if [[ -d "$full_path" ]] && [[ $force -eq 1 ]]; then
|
|
1531
|
+
rm -rf "$full_path"
|
|
1532
|
+
fi
|
|
1533
|
+
|
|
1534
|
+
# Show spinner while cloning
|
|
1535
|
+
ui_spinner_start "Cloning $path... ($current/$total)"
|
|
1536
|
+
|
|
1537
|
+
local clone_err
|
|
1538
|
+
if clone_err=$(git clone --quiet "$url" "$full_path" 2>&1); then
|
|
1539
|
+
ui_spinner_stop
|
|
1540
|
+
ui_step_done "Cloned:" "$path"
|
|
1541
|
+
success_count=$((success_count + 1))
|
|
1542
|
+
else
|
|
1543
|
+
ui_spinner_error "Failed to clone: $path"
|
|
1544
|
+
if [[ -n "$clone_err" ]]; then
|
|
1545
|
+
ui_info "$(ui_dim "$clone_err")"
|
|
1546
|
+
fi
|
|
1547
|
+
fail_count=$((fail_count + 1))
|
|
1548
|
+
fi
|
|
1549
|
+
done <<< "$repos"
|
|
1550
|
+
|
|
1551
|
+
# Auto-generate workspace CLAUDE.md on first successful clone
|
|
1552
|
+
if [[ $fail_count -eq 0 ]] && [[ $success_count -gt 0 ]]; then
|
|
1553
|
+
context_autogenerate_if_missing
|
|
1554
|
+
fi
|
|
1555
|
+
|
|
1556
|
+
# Summary
|
|
1557
|
+
ui_bar_line
|
|
1558
|
+
|
|
1559
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
1560
|
+
local msg="Cloned $success_count repositories successfully"
|
|
1561
|
+
if [[ $skip_count -gt 0 ]]; then
|
|
1562
|
+
msg="$msg, $skip_count already cloned"
|
|
1563
|
+
fi
|
|
1564
|
+
ui_outro "$msg"
|
|
1565
|
+
else
|
|
1566
|
+
ui_outro_cancel "Cloned $success_count, failed $fail_count"
|
|
1567
|
+
return 1
|
|
1568
|
+
fi
|
|
1569
|
+
|
|
1570
|
+
return 0
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
# === lib/commands/status.sh ===
|
|
1574
|
+
# Revo CLI - status command
|
|
1575
|
+
# Show git status across all repositories
|
|
1576
|
+
|
|
1577
|
+
cmd_status() {
|
|
1578
|
+
local tag=""
|
|
1579
|
+
|
|
1580
|
+
# Parse arguments
|
|
1581
|
+
while [[ $# -gt 0 ]]; do
|
|
1582
|
+
case "$1" in
|
|
1583
|
+
--tag)
|
|
1584
|
+
tag="$2"
|
|
1585
|
+
shift 2
|
|
1586
|
+
;;
|
|
1587
|
+
*)
|
|
1588
|
+
ui_step_error "Unknown option: $1"
|
|
1589
|
+
return 1
|
|
1590
|
+
;;
|
|
1591
|
+
esac
|
|
1592
|
+
done
|
|
1593
|
+
|
|
1594
|
+
config_require_workspace || return 1
|
|
1595
|
+
|
|
1596
|
+
ui_intro "Revo - Repository Status"
|
|
1597
|
+
|
|
1598
|
+
local repos
|
|
1599
|
+
repos=$(config_get_repos "$tag")
|
|
1600
|
+
|
|
1601
|
+
if [[ -z "$repos" ]]; then
|
|
1602
|
+
ui_step_error "No repositories configured"
|
|
1603
|
+
ui_outro_cancel "Nothing to show"
|
|
1604
|
+
return 1
|
|
1605
|
+
fi
|
|
1606
|
+
|
|
1607
|
+
# Table header
|
|
1608
|
+
ui_table_widths 24 20 12 14
|
|
1609
|
+
ui_table_header "Repository" "Branch" "Status" "Sync"
|
|
1610
|
+
|
|
1611
|
+
local not_cloned=0
|
|
1612
|
+
|
|
1613
|
+
while IFS= read -r repo; do
|
|
1614
|
+
[[ -z "$repo" ]] && continue
|
|
1615
|
+
|
|
1616
|
+
local path
|
|
1617
|
+
path=$(yaml_get_path "$repo")
|
|
1618
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
1619
|
+
|
|
1620
|
+
if [[ ! -d "$full_path" ]]; then
|
|
1621
|
+
ui_table_row "$path" "$(ui_dim "not cloned")" "-" "-"
|
|
1622
|
+
not_cloned=$((not_cloned + 1))
|
|
1623
|
+
continue
|
|
1624
|
+
fi
|
|
1625
|
+
|
|
1626
|
+
# Get branch
|
|
1627
|
+
local branch
|
|
1628
|
+
branch=$(git_current_branch "$full_path")
|
|
1629
|
+
|
|
1630
|
+
# Get dirty status
|
|
1631
|
+
local status_text
|
|
1632
|
+
if git_is_dirty "$full_path"; then
|
|
1633
|
+
status_text="$(ui_yellow "dirty")"
|
|
1634
|
+
else
|
|
1635
|
+
status_text="$(ui_green "clean")"
|
|
1636
|
+
fi
|
|
1637
|
+
|
|
1638
|
+
# Get ahead/behind
|
|
1639
|
+
git_ahead_behind "$full_path"
|
|
1640
|
+
local sync_text=""
|
|
1641
|
+
|
|
1642
|
+
if [[ $GIT_AHEAD -gt 0 ]] && [[ $GIT_BEHIND -gt 0 ]]; then
|
|
1643
|
+
sync_text="$(ui_yellow "↑$GIT_AHEAD ↓$GIT_BEHIND")"
|
|
1644
|
+
elif [[ $GIT_AHEAD -gt 0 ]]; then
|
|
1645
|
+
sync_text="$(ui_cyan "↑$GIT_AHEAD")"
|
|
1646
|
+
elif [[ $GIT_BEHIND -gt 0 ]]; then
|
|
1647
|
+
sync_text="$(ui_yellow "↓$GIT_BEHIND")"
|
|
1648
|
+
else
|
|
1649
|
+
sync_text="$(ui_green "synced")"
|
|
1650
|
+
fi
|
|
1651
|
+
|
|
1652
|
+
ui_table_row "$path" "$branch" "$status_text" "$sync_text"
|
|
1653
|
+
done <<< "$repos"
|
|
1654
|
+
|
|
1655
|
+
ui_bar_line
|
|
1656
|
+
|
|
1657
|
+
if [[ $not_cloned -gt 0 ]]; then
|
|
1658
|
+
ui_info "$(ui_dim "$not_cloned repository(ies) not cloned. Run 'revo clone' to clone them.")"
|
|
1659
|
+
fi
|
|
1660
|
+
|
|
1661
|
+
ui_outro "Status complete"
|
|
1662
|
+
|
|
1663
|
+
return 0
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
# === lib/commands/branch.sh ===
|
|
1667
|
+
# Revo CLI - branch command
|
|
1668
|
+
# Create a new branch across repositories
|
|
1669
|
+
|
|
1670
|
+
cmd_branch() {
|
|
1671
|
+
local branch_name=""
|
|
1672
|
+
local tag=""
|
|
1673
|
+
|
|
1674
|
+
# Parse arguments
|
|
1675
|
+
while [[ $# -gt 0 ]]; do
|
|
1676
|
+
case "$1" in
|
|
1677
|
+
--tag)
|
|
1678
|
+
tag="$2"
|
|
1679
|
+
shift 2
|
|
1680
|
+
;;
|
|
1681
|
+
-*)
|
|
1682
|
+
ui_step_error "Unknown option: $1"
|
|
1683
|
+
return 1
|
|
1684
|
+
;;
|
|
1685
|
+
*)
|
|
1686
|
+
if [[ -z "$branch_name" ]]; then
|
|
1687
|
+
branch_name="$1"
|
|
1688
|
+
else
|
|
1689
|
+
ui_step_error "Unexpected argument: $1"
|
|
1690
|
+
return 1
|
|
1691
|
+
fi
|
|
1692
|
+
shift
|
|
1693
|
+
;;
|
|
1694
|
+
esac
|
|
1695
|
+
done
|
|
1696
|
+
|
|
1697
|
+
if [[ -z "$branch_name" ]]; then
|
|
1698
|
+
ui_step_error "Usage: revo branch <branch-name> [--tag TAG]"
|
|
1699
|
+
return 1
|
|
1700
|
+
fi
|
|
1701
|
+
|
|
1702
|
+
config_require_workspace || return 1
|
|
1703
|
+
|
|
1704
|
+
ui_intro "Revo - Create Branch: $branch_name"
|
|
1705
|
+
|
|
1706
|
+
local repos
|
|
1707
|
+
repos=$(config_get_repos "$tag")
|
|
1708
|
+
|
|
1709
|
+
if [[ -z "$repos" ]]; then
|
|
1710
|
+
ui_step_error "No repositories configured"
|
|
1711
|
+
ui_outro_cancel "Nothing to do"
|
|
1712
|
+
return 1
|
|
1713
|
+
fi
|
|
1714
|
+
|
|
1715
|
+
local success_count=0
|
|
1716
|
+
local skip_count=0
|
|
1717
|
+
local fail_count=0
|
|
1718
|
+
|
|
1719
|
+
while IFS= read -r repo; do
|
|
1720
|
+
[[ -z "$repo" ]] && continue
|
|
1721
|
+
|
|
1722
|
+
local path
|
|
1723
|
+
path=$(yaml_get_path "$repo")
|
|
1724
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
1725
|
+
|
|
1726
|
+
if [[ ! -d "$full_path" ]]; then
|
|
1727
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
1728
|
+
skip_count=$((skip_count + 1))
|
|
1729
|
+
continue
|
|
1730
|
+
fi
|
|
1731
|
+
|
|
1732
|
+
# Check if branch already exists
|
|
1733
|
+
if git_branch_exists "$full_path" "$branch_name"; then
|
|
1734
|
+
# Try to checkout instead
|
|
1735
|
+
if git_checkout "$full_path" "$branch_name"; then
|
|
1736
|
+
ui_step_done "Checked out existing:" "$path → $branch_name"
|
|
1737
|
+
success_count=$((success_count + 1))
|
|
1738
|
+
else
|
|
1739
|
+
ui_step_error "Failed to checkout existing branch: $path"
|
|
1740
|
+
fail_count=$((fail_count + 1))
|
|
1741
|
+
fi
|
|
1742
|
+
continue
|
|
1743
|
+
fi
|
|
1744
|
+
|
|
1745
|
+
# Create new branch
|
|
1746
|
+
if git_branch "$full_path" "$branch_name"; then
|
|
1747
|
+
ui_step_done "Created:" "$path → $branch_name"
|
|
1748
|
+
success_count=$((success_count + 1))
|
|
1749
|
+
else
|
|
1750
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
1751
|
+
fail_count=$((fail_count + 1))
|
|
1752
|
+
fi
|
|
1753
|
+
done <<< "$repos"
|
|
1754
|
+
|
|
1755
|
+
ui_bar_line
|
|
1756
|
+
|
|
1757
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
1758
|
+
local msg="Branch '$branch_name' created on $success_count repo(s)"
|
|
1759
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
1760
|
+
ui_outro "$msg"
|
|
1761
|
+
else
|
|
1762
|
+
ui_outro_cancel "$success_count succeeded, $fail_count failed"
|
|
1763
|
+
return 1
|
|
1764
|
+
fi
|
|
1765
|
+
|
|
1766
|
+
return 0
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
# === lib/commands/checkout.sh ===
|
|
1770
|
+
# Revo CLI - checkout command
|
|
1771
|
+
# Checkout a branch across repositories
|
|
1772
|
+
|
|
1773
|
+
cmd_checkout() {
|
|
1774
|
+
local branch_name=""
|
|
1775
|
+
local tag=""
|
|
1776
|
+
local force=0
|
|
1777
|
+
|
|
1778
|
+
# Parse arguments
|
|
1779
|
+
while [[ $# -gt 0 ]]; do
|
|
1780
|
+
case "$1" in
|
|
1781
|
+
--tag)
|
|
1782
|
+
tag="$2"
|
|
1783
|
+
shift 2
|
|
1784
|
+
;;
|
|
1785
|
+
--force|-f)
|
|
1786
|
+
force=1
|
|
1787
|
+
shift
|
|
1788
|
+
;;
|
|
1789
|
+
-*)
|
|
1790
|
+
ui_step_error "Unknown option: $1"
|
|
1791
|
+
return 1
|
|
1792
|
+
;;
|
|
1793
|
+
*)
|
|
1794
|
+
if [[ -z "$branch_name" ]]; then
|
|
1795
|
+
branch_name="$1"
|
|
1796
|
+
else
|
|
1797
|
+
ui_step_error "Unexpected argument: $1"
|
|
1798
|
+
return 1
|
|
1799
|
+
fi
|
|
1800
|
+
shift
|
|
1801
|
+
;;
|
|
1802
|
+
esac
|
|
1803
|
+
done
|
|
1804
|
+
|
|
1805
|
+
if [[ -z "$branch_name" ]]; then
|
|
1806
|
+
ui_step_error "Usage: revo checkout <branch-name> [--tag TAG] [--force]"
|
|
1807
|
+
return 1
|
|
1808
|
+
fi
|
|
1809
|
+
|
|
1810
|
+
config_require_workspace || return 1
|
|
1811
|
+
|
|
1812
|
+
ui_intro "Revo - Checkout Branch: $branch_name"
|
|
1813
|
+
|
|
1814
|
+
local repos
|
|
1815
|
+
repos=$(config_get_repos "$tag")
|
|
1816
|
+
|
|
1817
|
+
if [[ -z "$repos" ]]; then
|
|
1818
|
+
ui_step_error "No repositories configured"
|
|
1819
|
+
ui_outro_cancel "Nothing to do"
|
|
1820
|
+
return 1
|
|
1821
|
+
fi
|
|
1822
|
+
|
|
1823
|
+
local success_count=0
|
|
1824
|
+
local skip_count=0
|
|
1825
|
+
local fail_count=0
|
|
1826
|
+
local dirty_repos=()
|
|
1827
|
+
|
|
1828
|
+
while IFS= read -r repo; do
|
|
1829
|
+
[[ -z "$repo" ]] && continue
|
|
1830
|
+
|
|
1831
|
+
local path
|
|
1832
|
+
path=$(yaml_get_path "$repo")
|
|
1833
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
1834
|
+
|
|
1835
|
+
if [[ ! -d "$full_path" ]]; then
|
|
1836
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
1837
|
+
skip_count=$((skip_count + 1))
|
|
1838
|
+
continue
|
|
1839
|
+
fi
|
|
1840
|
+
|
|
1841
|
+
# Check for uncommitted changes
|
|
1842
|
+
if git_is_dirty "$full_path" && [[ $force -eq 0 ]]; then
|
|
1843
|
+
ui_step_error "Uncommitted changes: $path"
|
|
1844
|
+
dirty_repos+=("$path")
|
|
1845
|
+
fail_count=$((fail_count + 1))
|
|
1846
|
+
continue
|
|
1847
|
+
fi
|
|
1848
|
+
|
|
1849
|
+
# Check if branch exists
|
|
1850
|
+
if ! git_branch_exists "$full_path" "$branch_name"; then
|
|
1851
|
+
ui_step_error "Branch not found: $path"
|
|
1852
|
+
fail_count=$((fail_count + 1))
|
|
1853
|
+
continue
|
|
1854
|
+
fi
|
|
1855
|
+
|
|
1856
|
+
# Checkout
|
|
1857
|
+
if git_checkout "$full_path" "$branch_name"; then
|
|
1858
|
+
ui_step_done "Checked out:" "$path → $branch_name"
|
|
1859
|
+
success_count=$((success_count + 1))
|
|
1860
|
+
else
|
|
1861
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
1862
|
+
fail_count=$((fail_count + 1))
|
|
1863
|
+
fi
|
|
1864
|
+
done <<< "$repos"
|
|
1865
|
+
|
|
1866
|
+
ui_bar_line
|
|
1867
|
+
|
|
1868
|
+
if [[ ${#dirty_repos[@]} -gt 0 ]]; then
|
|
1869
|
+
ui_info "$(ui_yellow "Hint: Use --force to checkout despite uncommitted changes")"
|
|
1870
|
+
fi
|
|
1871
|
+
|
|
1872
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
1873
|
+
local msg="Checked out '$branch_name' on $success_count repo(s)"
|
|
1874
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
1875
|
+
ui_outro "$msg"
|
|
1876
|
+
else
|
|
1877
|
+
ui_outro_cancel "$success_count succeeded, $fail_count failed"
|
|
1878
|
+
return 1
|
|
1879
|
+
fi
|
|
1880
|
+
|
|
1881
|
+
return 0
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
# === lib/commands/sync.sh ===
|
|
1885
|
+
# Revo CLI - sync command
|
|
1886
|
+
# Pull latest changes across repositories
|
|
1887
|
+
|
|
1888
|
+
cmd_sync() {
|
|
1889
|
+
local tag=""
|
|
1890
|
+
local rebase=0
|
|
1891
|
+
|
|
1892
|
+
# Parse arguments
|
|
1893
|
+
while [[ $# -gt 0 ]]; do
|
|
1894
|
+
case "$1" in
|
|
1895
|
+
--tag)
|
|
1896
|
+
tag="$2"
|
|
1897
|
+
shift 2
|
|
1898
|
+
;;
|
|
1899
|
+
--rebase|-r)
|
|
1900
|
+
rebase=1
|
|
1901
|
+
shift
|
|
1902
|
+
;;
|
|
1903
|
+
*)
|
|
1904
|
+
ui_step_error "Unknown option: $1"
|
|
1905
|
+
return 1
|
|
1906
|
+
;;
|
|
1907
|
+
esac
|
|
1908
|
+
done
|
|
1909
|
+
|
|
1910
|
+
config_require_workspace || return 1
|
|
1911
|
+
|
|
1912
|
+
ui_intro "Revo - Sync Repositories"
|
|
1913
|
+
|
|
1914
|
+
local repos
|
|
1915
|
+
repos=$(config_get_repos "$tag")
|
|
1916
|
+
|
|
1917
|
+
if [[ -z "$repos" ]]; then
|
|
1918
|
+
ui_step_error "No repositories configured"
|
|
1919
|
+
ui_outro_cancel "Nothing to sync"
|
|
1920
|
+
return 1
|
|
1921
|
+
fi
|
|
1922
|
+
|
|
1923
|
+
local success_count=0
|
|
1924
|
+
local skip_count=0
|
|
1925
|
+
local fail_count=0
|
|
1926
|
+
local conflict_repos=()
|
|
1927
|
+
|
|
1928
|
+
while IFS= read -r repo; do
|
|
1929
|
+
[[ -z "$repo" ]] && continue
|
|
1930
|
+
|
|
1931
|
+
local path
|
|
1932
|
+
path=$(yaml_get_path "$repo")
|
|
1933
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
1934
|
+
|
|
1935
|
+
if [[ ! -d "$full_path" ]]; then
|
|
1936
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
1937
|
+
skip_count=$((skip_count + 1))
|
|
1938
|
+
continue
|
|
1939
|
+
fi
|
|
1940
|
+
|
|
1941
|
+
# Fetch first
|
|
1942
|
+
git_fetch "$full_path"
|
|
1943
|
+
|
|
1944
|
+
# Pull
|
|
1945
|
+
local pull_args=""
|
|
1946
|
+
[[ $rebase -eq 1 ]] && pull_args="--rebase"
|
|
1947
|
+
|
|
1948
|
+
if git_pull "$full_path" $pull_args; then
|
|
1949
|
+
# Check what happened
|
|
1950
|
+
if [[ "$GIT_OUTPUT" == *"Already up to date"* ]]; then
|
|
1951
|
+
ui_step_done "Up to date:" "$path"
|
|
1952
|
+
else
|
|
1953
|
+
ui_step_done "Updated:" "$path"
|
|
1954
|
+
fi
|
|
1955
|
+
success_count=$((success_count + 1))
|
|
1956
|
+
else
|
|
1957
|
+
# Check for conflicts
|
|
1958
|
+
if [[ "$GIT_ERROR" == *"conflict"* ]] || [[ "$GIT_ERROR" == *"CONFLICT"* ]]; then
|
|
1959
|
+
ui_step_error "Conflict: $path"
|
|
1960
|
+
conflict_repos+=("$path")
|
|
1961
|
+
else
|
|
1962
|
+
ui_step_error "Failed: $path"
|
|
1963
|
+
fi
|
|
1964
|
+
fail_count=$((fail_count + 1))
|
|
1965
|
+
fi
|
|
1966
|
+
done <<< "$repos"
|
|
1967
|
+
|
|
1968
|
+
ui_bar_line
|
|
1969
|
+
|
|
1970
|
+
if [[ ${#conflict_repos[@]} -gt 0 ]]; then
|
|
1971
|
+
ui_info "$(ui_yellow "Repositories with conflicts:")"
|
|
1972
|
+
for r in "${conflict_repos[@]}"; do
|
|
1973
|
+
ui_info " $(ui_yellow "$r")"
|
|
1974
|
+
done
|
|
1975
|
+
fi
|
|
1976
|
+
|
|
1977
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
1978
|
+
local msg="Synced $success_count repo(s)"
|
|
1979
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
1980
|
+
ui_outro "$msg"
|
|
1981
|
+
else
|
|
1982
|
+
ui_outro_cancel "$success_count synced, $fail_count failed"
|
|
1983
|
+
return 1
|
|
1984
|
+
fi
|
|
1985
|
+
|
|
1986
|
+
return 0
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
# === lib/commands/exec.sh ===
|
|
1990
|
+
# Revo CLI - exec command
|
|
1991
|
+
# Run command in each repository
|
|
1992
|
+
|
|
1993
|
+
cmd_exec() {
|
|
1994
|
+
local command=""
|
|
1995
|
+
local tag=""
|
|
1996
|
+
local quiet=0
|
|
1997
|
+
|
|
1998
|
+
# Parse arguments
|
|
1999
|
+
while [[ $# -gt 0 ]]; do
|
|
2000
|
+
case "$1" in
|
|
2001
|
+
--tag)
|
|
2002
|
+
tag="$2"
|
|
2003
|
+
shift 2
|
|
2004
|
+
;;
|
|
2005
|
+
--quiet|-q)
|
|
2006
|
+
quiet=1
|
|
2007
|
+
shift
|
|
2008
|
+
;;
|
|
2009
|
+
--)
|
|
2010
|
+
shift
|
|
2011
|
+
command="$*"
|
|
2012
|
+
break
|
|
2013
|
+
;;
|
|
2014
|
+
-*)
|
|
2015
|
+
ui_step_error "Unknown option: $1"
|
|
2016
|
+
return 1
|
|
2017
|
+
;;
|
|
2018
|
+
*)
|
|
2019
|
+
if [[ -z "$command" ]]; then
|
|
2020
|
+
command="$1"
|
|
2021
|
+
else
|
|
2022
|
+
ui_step_error "Unexpected argument: $1"
|
|
2023
|
+
return 1
|
|
2024
|
+
fi
|
|
2025
|
+
shift
|
|
2026
|
+
;;
|
|
2027
|
+
esac
|
|
2028
|
+
done
|
|
2029
|
+
|
|
2030
|
+
if [[ -z "$command" ]]; then
|
|
2031
|
+
ui_step_error "Usage: revo exec \"<command>\" [--tag TAG]"
|
|
2032
|
+
return 1
|
|
2033
|
+
fi
|
|
2034
|
+
|
|
2035
|
+
config_require_workspace || return 1
|
|
2036
|
+
|
|
2037
|
+
ui_intro "Revo - Execute: $command"
|
|
2038
|
+
|
|
2039
|
+
local repos
|
|
2040
|
+
repos=$(config_get_repos "$tag")
|
|
2041
|
+
|
|
2042
|
+
if [[ -z "$repos" ]]; then
|
|
2043
|
+
ui_step_error "No repositories configured"
|
|
2044
|
+
ui_outro_cancel "Nothing to do"
|
|
2045
|
+
return 1
|
|
2046
|
+
fi
|
|
2047
|
+
|
|
2048
|
+
local success_count=0
|
|
2049
|
+
local skip_count=0
|
|
2050
|
+
local fail_count=0
|
|
2051
|
+
|
|
2052
|
+
while IFS= read -r repo; do
|
|
2053
|
+
[[ -z "$repo" ]] && continue
|
|
2054
|
+
|
|
2055
|
+
local path
|
|
2056
|
+
path=$(yaml_get_path "$repo")
|
|
2057
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
2058
|
+
|
|
2059
|
+
if [[ ! -d "$full_path" ]]; then
|
|
2060
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
2061
|
+
skip_count=$((skip_count + 1))
|
|
2062
|
+
continue
|
|
2063
|
+
fi
|
|
2064
|
+
|
|
2065
|
+
ui_step "Running in: $path"
|
|
2066
|
+
ui_bar_line
|
|
2067
|
+
|
|
2068
|
+
# Execute command in repo directory
|
|
2069
|
+
local output
|
|
2070
|
+
local exit_code
|
|
2071
|
+
|
|
2072
|
+
if output=$(cd "$full_path" && eval "$command" 2>&1); then
|
|
2073
|
+
exit_code=0
|
|
2074
|
+
else
|
|
2075
|
+
exit_code=$?
|
|
2076
|
+
fi
|
|
2077
|
+
|
|
2078
|
+
# Show output if not quiet
|
|
2079
|
+
if [[ $quiet -eq 0 ]] && [[ -n "$output" ]]; then
|
|
2080
|
+
while IFS= read -r line; do
|
|
2081
|
+
printf '%s %s\n' "$(ui_bar)" "$(ui_dim "$line")"
|
|
2082
|
+
done <<< "$output"
|
|
2083
|
+
fi
|
|
2084
|
+
|
|
2085
|
+
if [[ $exit_code -eq 0 ]]; then
|
|
2086
|
+
ui_step_done "Success:" "$path"
|
|
2087
|
+
success_count=$((success_count + 1))
|
|
2088
|
+
else
|
|
2089
|
+
ui_step_error "Failed (exit $exit_code): $path"
|
|
2090
|
+
fail_count=$((fail_count + 1))
|
|
2091
|
+
fi
|
|
2092
|
+
|
|
2093
|
+
ui_bar_line
|
|
2094
|
+
done <<< "$repos"
|
|
2095
|
+
|
|
2096
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
2097
|
+
local msg="Executed on $success_count repo(s)"
|
|
2098
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
2099
|
+
ui_outro "$msg"
|
|
2100
|
+
else
|
|
2101
|
+
ui_outro_cancel "$success_count succeeded, $fail_count failed"
|
|
2102
|
+
return 1
|
|
2103
|
+
fi
|
|
2104
|
+
|
|
2105
|
+
return 0
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
# === lib/commands/add.sh ===
|
|
2109
|
+
# Revo CLI - add command
|
|
2110
|
+
# Add a repository to the workspace configuration
|
|
2111
|
+
|
|
2112
|
+
cmd_add() {
|
|
2113
|
+
local url=""
|
|
2114
|
+
local path=""
|
|
2115
|
+
local tags=""
|
|
2116
|
+
local deps=""
|
|
2117
|
+
|
|
2118
|
+
# Parse arguments
|
|
2119
|
+
while [[ $# -gt 0 ]]; do
|
|
2120
|
+
case "$1" in
|
|
2121
|
+
--tags)
|
|
2122
|
+
tags="$2"
|
|
2123
|
+
shift 2
|
|
2124
|
+
;;
|
|
2125
|
+
--path)
|
|
2126
|
+
path="$2"
|
|
2127
|
+
shift 2
|
|
2128
|
+
;;
|
|
2129
|
+
--depends-on)
|
|
2130
|
+
deps="$2"
|
|
2131
|
+
shift 2
|
|
2132
|
+
;;
|
|
2133
|
+
-*)
|
|
2134
|
+
ui_step_error "Unknown option: $1"
|
|
2135
|
+
return 1
|
|
2136
|
+
;;
|
|
2137
|
+
*)
|
|
2138
|
+
if [[ -z "$url" ]]; then
|
|
2139
|
+
url="$1"
|
|
2140
|
+
else
|
|
2141
|
+
ui_step_error "Unexpected argument: $1"
|
|
2142
|
+
return 1
|
|
2143
|
+
fi
|
|
2144
|
+
shift
|
|
2145
|
+
;;
|
|
2146
|
+
esac
|
|
2147
|
+
done
|
|
2148
|
+
|
|
2149
|
+
if [[ -z "$url" ]]; then
|
|
2150
|
+
ui_step_error "Usage: revo add <url> [--tags tag1,tag2] [--path custom-path] [--depends-on repo1,repo2]"
|
|
2151
|
+
return 1
|
|
2152
|
+
fi
|
|
2153
|
+
|
|
2154
|
+
config_require_workspace || return 1
|
|
2155
|
+
|
|
2156
|
+
# Derive path from URL if not provided
|
|
2157
|
+
if [[ -z "$path" ]]; then
|
|
2158
|
+
path=$(yaml_path_from_url "$url")
|
|
2159
|
+
fi
|
|
2160
|
+
|
|
2161
|
+
# Check if repo already exists
|
|
2162
|
+
local i
|
|
2163
|
+
for ((i = 0; i < YAML_REPO_COUNT; i++)); do
|
|
2164
|
+
local existing_url
|
|
2165
|
+
existing_url=$(yaml_get_url "$i")
|
|
2166
|
+
if [[ "$existing_url" == "$url" ]]; then
|
|
2167
|
+
ui_step_error "Repository already configured: $url"
|
|
2168
|
+
return 1
|
|
2169
|
+
fi
|
|
2170
|
+
|
|
2171
|
+
local existing_path
|
|
2172
|
+
existing_path=$(yaml_get_path "$i")
|
|
2173
|
+
if [[ "$existing_path" == "$path" ]]; then
|
|
2174
|
+
ui_step_error "Path already in use: $path"
|
|
2175
|
+
return 1
|
|
2176
|
+
fi
|
|
2177
|
+
done
|
|
2178
|
+
|
|
2179
|
+
ui_intro "Revo - Add Repository"
|
|
2180
|
+
|
|
2181
|
+
# Add to config
|
|
2182
|
+
yaml_add_repo "$url" "$path" "$tags" "$deps"
|
|
2183
|
+
|
|
2184
|
+
# Save config
|
|
2185
|
+
config_save
|
|
2186
|
+
|
|
2187
|
+
ui_step_done "Added repository:" "$path"
|
|
2188
|
+
|
|
2189
|
+
if [[ -n "$tags" ]]; then
|
|
2190
|
+
ui_info "Tags: $tags"
|
|
2191
|
+
fi
|
|
2192
|
+
|
|
2193
|
+
if [[ -n "$deps" ]]; then
|
|
2194
|
+
ui_info "Depends on: $deps"
|
|
2195
|
+
fi
|
|
2196
|
+
|
|
2197
|
+
ui_bar_line
|
|
2198
|
+
ui_info "$(ui_dim "Run 'revo clone' to clone the repository")"
|
|
2199
|
+
|
|
2200
|
+
ui_outro "Repository added to workspace"
|
|
2201
|
+
|
|
2202
|
+
return 0
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
# === lib/commands/list.sh ===
|
|
2206
|
+
# Revo CLI - list command
|
|
2207
|
+
# List configured repositories
|
|
2208
|
+
|
|
2209
|
+
cmd_list() {
|
|
2210
|
+
local tag=""
|
|
2211
|
+
|
|
2212
|
+
# Parse arguments
|
|
2213
|
+
while [[ $# -gt 0 ]]; do
|
|
2214
|
+
case "$1" in
|
|
2215
|
+
--tag)
|
|
2216
|
+
tag="$2"
|
|
2217
|
+
shift 2
|
|
2218
|
+
;;
|
|
2219
|
+
*)
|
|
2220
|
+
ui_step_error "Unknown option: $1"
|
|
2221
|
+
return 1
|
|
2222
|
+
;;
|
|
2223
|
+
esac
|
|
2224
|
+
done
|
|
2225
|
+
|
|
2226
|
+
config_require_workspace || return 1
|
|
2227
|
+
|
|
2228
|
+
ui_intro "Revo - Configured Repositories"
|
|
2229
|
+
|
|
2230
|
+
local repos
|
|
2231
|
+
repos=$(config_get_repos "$tag")
|
|
2232
|
+
|
|
2233
|
+
if [[ -z "$repos" ]]; then
|
|
2234
|
+
if [[ -n "$tag" ]]; then
|
|
2235
|
+
ui_info "No repositories found with tag: $tag"
|
|
2236
|
+
else
|
|
2237
|
+
ui_info "No repositories configured"
|
|
2238
|
+
ui_bar_line
|
|
2239
|
+
ui_info "$(ui_dim "Run 'revo add <url>' to add a repository")"
|
|
2240
|
+
fi
|
|
2241
|
+
ui_outro "List complete"
|
|
2242
|
+
return 0
|
|
2243
|
+
fi
|
|
2244
|
+
|
|
2245
|
+
# Table header
|
|
2246
|
+
ui_table_widths 24 24 10
|
|
2247
|
+
ui_table_header "Path" "Tags" "Cloned"
|
|
2248
|
+
|
|
2249
|
+
local total=0
|
|
2250
|
+
local cloned=0
|
|
2251
|
+
|
|
2252
|
+
while IFS= read -r repo; do
|
|
2253
|
+
[[ -z "$repo" ]] && continue
|
|
2254
|
+
total=$((total + 1))
|
|
2255
|
+
|
|
2256
|
+
local path
|
|
2257
|
+
path=$(yaml_get_path "$repo")
|
|
2258
|
+
local tags
|
|
2259
|
+
tags=$(yaml_get_tags "$repo")
|
|
2260
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
2261
|
+
|
|
2262
|
+
local cloned_text
|
|
2263
|
+
if [[ -d "$full_path" ]]; then
|
|
2264
|
+
cloned_text="$(ui_green "yes")"
|
|
2265
|
+
cloned=$((cloned + 1))
|
|
2266
|
+
else
|
|
2267
|
+
cloned_text="$(ui_dim "no")"
|
|
2268
|
+
fi
|
|
2269
|
+
|
|
2270
|
+
local tags_text
|
|
2271
|
+
if [[ -n "$tags" ]]; then
|
|
2272
|
+
tags_text="$tags"
|
|
2273
|
+
else
|
|
2274
|
+
tags_text="$(ui_dim "-")"
|
|
2275
|
+
fi
|
|
2276
|
+
|
|
2277
|
+
ui_table_row "$path" "$tags_text" "$cloned_text"
|
|
2278
|
+
done <<< "$repos"
|
|
2279
|
+
|
|
2280
|
+
ui_bar_line
|
|
2281
|
+
ui_info "Total: $total repositories, $cloned cloned"
|
|
2282
|
+
|
|
2283
|
+
ui_outro "List complete"
|
|
2284
|
+
|
|
2285
|
+
return 0
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
# === lib/commands/context.sh ===
|
|
2289
|
+
# Revo CLI - context command
|
|
2290
|
+
# Scans all cloned repos and writes a workspace-level CLAUDE.md.
|
|
2291
|
+
|
|
2292
|
+
# Topological sort over YAML_REPO_DEPS.
|
|
2293
|
+
# Sets CONTEXT_ORDER to newline-separated repo indices (roots first).
|
|
2294
|
+
# Sets CONTEXT_CYCLE=1 if a cycle was detected (remaining nodes are appended in input order).
|
|
2295
|
+
CONTEXT_ORDER=""
|
|
2296
|
+
CONTEXT_CYCLE=0
|
|
2297
|
+
|
|
2298
|
+
_context_topo_sort() {
|
|
2299
|
+
CONTEXT_ORDER=""
|
|
2300
|
+
CONTEXT_CYCLE=0
|
|
2301
|
+
|
|
2302
|
+
local n="$YAML_REPO_COUNT"
|
|
2303
|
+
[[ $n -eq 0 ]] && return 0
|
|
2304
|
+
|
|
2305
|
+
# placed[i]=1 once emitted
|
|
2306
|
+
local placed=()
|
|
2307
|
+
local i
|
|
2308
|
+
for ((i = 0; i < n; i++)); do
|
|
2309
|
+
placed[$i]=0
|
|
2310
|
+
done
|
|
2311
|
+
|
|
2312
|
+
local remaining=$n
|
|
2313
|
+
local order=""
|
|
2314
|
+
|
|
2315
|
+
# Repeat: find a node whose deps are all placed (or missing)
|
|
2316
|
+
while [[ $remaining -gt 0 ]]; do
|
|
2317
|
+
local made_progress=0
|
|
2318
|
+
for ((i = 0; i < n; i++)); do
|
|
2319
|
+
[[ ${placed[$i]} -eq 1 ]] && continue
|
|
2320
|
+
|
|
2321
|
+
local deps="${YAML_REPO_DEPS[$i]:-}"
|
|
2322
|
+
local all_ready=1
|
|
2323
|
+
|
|
2324
|
+
if [[ -n "$deps" ]]; then
|
|
2325
|
+
# Iterate over comma-separated dep names
|
|
2326
|
+
local dep
|
|
2327
|
+
local old_ifs="$IFS"
|
|
2328
|
+
IFS=','
|
|
2329
|
+
for dep in $deps; do
|
|
2330
|
+
[[ -z "$dep" ]] && continue
|
|
2331
|
+
local dep_idx
|
|
2332
|
+
dep_idx=$(yaml_find_by_name "$dep")
|
|
2333
|
+
# If the dep isn't in our config, ignore it
|
|
2334
|
+
if [[ "$dep_idx" -lt 0 ]]; then
|
|
2335
|
+
continue
|
|
2336
|
+
fi
|
|
2337
|
+
if [[ ${placed[$dep_idx]} -eq 0 ]]; then
|
|
2338
|
+
all_ready=0
|
|
2339
|
+
break
|
|
2340
|
+
fi
|
|
2341
|
+
done
|
|
2342
|
+
IFS="$old_ifs"
|
|
2343
|
+
fi
|
|
2344
|
+
|
|
2345
|
+
if [[ $all_ready -eq 1 ]]; then
|
|
2346
|
+
if [[ -z "$order" ]]; then
|
|
2347
|
+
order="$i"
|
|
2348
|
+
else
|
|
2349
|
+
order="$order"$'\n'"$i"
|
|
2350
|
+
fi
|
|
2351
|
+
placed[$i]=1
|
|
2352
|
+
remaining=$((remaining - 1))
|
|
2353
|
+
made_progress=1
|
|
2354
|
+
fi
|
|
2355
|
+
done
|
|
2356
|
+
|
|
2357
|
+
if [[ $made_progress -eq 0 ]]; then
|
|
2358
|
+
# Cycle or unresolvable: append remaining in input order and stop
|
|
2359
|
+
CONTEXT_CYCLE=1
|
|
2360
|
+
for ((i = 0; i < n; i++)); do
|
|
2361
|
+
if [[ ${placed[$i]} -eq 0 ]]; then
|
|
2362
|
+
if [[ -z "$order" ]]; then
|
|
2363
|
+
order="$i"
|
|
2364
|
+
else
|
|
2365
|
+
order="$order"$'\n'"$i"
|
|
2366
|
+
fi
|
|
2367
|
+
placed[$i]=1
|
|
2368
|
+
fi
|
|
2369
|
+
done
|
|
2370
|
+
remaining=0
|
|
2371
|
+
fi
|
|
2372
|
+
done
|
|
2373
|
+
|
|
2374
|
+
CONTEXT_ORDER="$order"
|
|
2375
|
+
return 0
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
# Write the workspace CLAUDE.md to the given path.
|
|
2379
|
+
# Usage: _context_write_file "/path/to/CLAUDE.md"
|
|
2380
|
+
_context_write_file() {
|
|
2381
|
+
local output="$1"
|
|
2382
|
+
local workspace
|
|
2383
|
+
workspace=$(config_workspace_name)
|
|
2384
|
+
|
|
2385
|
+
# Start file
|
|
2386
|
+
{
|
|
2387
|
+
printf '# Workspace Context (auto-generated by revo)\n'
|
|
2388
|
+
printf '\n'
|
|
2389
|
+
printf 'This workspace contains multiple repositories managed by revo.\n'
|
|
2390
|
+
if [[ -n "$workspace" ]]; then
|
|
2391
|
+
printf 'Workspace: **%s**\n' "$workspace"
|
|
2392
|
+
fi
|
|
2393
|
+
printf '\n'
|
|
2394
|
+
printf '> This file is regenerated by `revo context`. Manual edits below the\n'
|
|
2395
|
+
printf '> `## Agent Instructions` section will be preserved across regeneration.\n'
|
|
2396
|
+
printf '\n'
|
|
2397
|
+
printf '## Repos\n'
|
|
2398
|
+
printf '\n'
|
|
2399
|
+
} > "$output"
|
|
2400
|
+
|
|
2401
|
+
# Per-repo details
|
|
2402
|
+
local i
|
|
2403
|
+
for ((i = 0; i < YAML_REPO_COUNT; i++)); do
|
|
2404
|
+
local path tags deps url full_path
|
|
2405
|
+
path=$(yaml_get_path "$i")
|
|
2406
|
+
tags=$(yaml_get_tags "$i")
|
|
2407
|
+
deps=$(yaml_get_deps "$i")
|
|
2408
|
+
url=$(yaml_get_url "$i")
|
|
2409
|
+
full_path="$REVO_REPOS_DIR/$path"
|
|
2410
|
+
|
|
2411
|
+
{
|
|
2412
|
+
printf '### %s\n' "$path"
|
|
2413
|
+
|
|
2414
|
+
if [[ -n "$tags" ]]; then
|
|
2415
|
+
printf -- '- **Tags:** %s\n' "$tags"
|
|
2416
|
+
fi
|
|
2417
|
+
printf -- '- **Path:** repos/%s\n' "$path"
|
|
2418
|
+
if [[ -n "$deps" ]]; then
|
|
2419
|
+
printf -- '- **Depends on:** %s\n' "$deps"
|
|
2420
|
+
fi
|
|
2421
|
+
} >> "$output"
|
|
2422
|
+
|
|
2423
|
+
if [[ -d "$full_path" ]]; then
|
|
2424
|
+
scan_repo "$full_path"
|
|
2425
|
+
|
|
2426
|
+
if [[ -n "$SCAN_LANG" ]]; then
|
|
2427
|
+
if [[ -n "$SCAN_NAME" ]]; then
|
|
2428
|
+
printf -- '- **Package:** %s (%s)\n' "$SCAN_NAME" "$SCAN_LANG" >> "$output"
|
|
2429
|
+
else
|
|
2430
|
+
printf -- '- **Language:** %s\n' "$SCAN_LANG" >> "$output"
|
|
2431
|
+
fi
|
|
2432
|
+
fi
|
|
2433
|
+
|
|
2434
|
+
if [[ -n "$SCAN_FRAMEWORK" ]]; then
|
|
2435
|
+
printf -- '- **Framework:** %s\n' "$SCAN_FRAMEWORK" >> "$output"
|
|
2436
|
+
fi
|
|
2437
|
+
|
|
2438
|
+
if [[ -n "$SCAN_ROUTES" ]]; then
|
|
2439
|
+
printf -- '- **API routes:** %s\n' "$SCAN_ROUTES" >> "$output"
|
|
2440
|
+
fi
|
|
2441
|
+
|
|
2442
|
+
if [[ -n "$SCAN_DESCRIPTION" ]]; then
|
|
2443
|
+
printf -- '- **Description:** %s\n' "$SCAN_DESCRIPTION" >> "$output"
|
|
2444
|
+
fi
|
|
2445
|
+
|
|
2446
|
+
if [[ $SCAN_HAS_CLAUDE_MD -eq 1 ]]; then
|
|
2447
|
+
printf -- '- **Has CLAUDE.md:** yes (repo-level context available)\n' >> "$output"
|
|
2448
|
+
fi
|
|
2449
|
+
|
|
2450
|
+
if [[ $SCAN_HAS_DOCKER -eq 1 ]]; then
|
|
2451
|
+
printf -- '- **Containerized:** yes\n' >> "$output"
|
|
2452
|
+
fi
|
|
2453
|
+
else
|
|
2454
|
+
printf -- '- **Status:** not cloned (run `revo clone`)\n' >> "$output"
|
|
2455
|
+
if [[ -n "$url" ]]; then
|
|
2456
|
+
printf -- '- **URL:** %s\n' "$url" >> "$output"
|
|
2457
|
+
fi
|
|
2458
|
+
fi
|
|
2459
|
+
|
|
2460
|
+
printf '\n' >> "$output"
|
|
2461
|
+
done
|
|
2462
|
+
|
|
2463
|
+
# Dependency order
|
|
2464
|
+
{
|
|
2465
|
+
printf '## Dependency Order\n'
|
|
2466
|
+
printf '\n'
|
|
2467
|
+
printf 'When making cross-repo changes, follow this order:\n'
|
|
2468
|
+
printf '\n'
|
|
2469
|
+
} >> "$output"
|
|
2470
|
+
|
|
2471
|
+
_context_topo_sort
|
|
2472
|
+
|
|
2473
|
+
local step=1
|
|
2474
|
+
local idx
|
|
2475
|
+
while IFS= read -r idx; do
|
|
2476
|
+
[[ -z "$idx" ]] && continue
|
|
2477
|
+
local path deps
|
|
2478
|
+
path=$(yaml_get_path "$idx")
|
|
2479
|
+
deps=$(yaml_get_deps "$idx")
|
|
2480
|
+
if [[ -n "$deps" ]]; then
|
|
2481
|
+
printf '%d. **%s** (after: %s)\n' "$step" "$path" "$deps" >> "$output"
|
|
2482
|
+
else
|
|
2483
|
+
printf '%d. **%s**\n' "$step" "$path" >> "$output"
|
|
2484
|
+
fi
|
|
2485
|
+
step=$((step + 1))
|
|
2486
|
+
done <<< "$CONTEXT_ORDER"
|
|
2487
|
+
|
|
2488
|
+
if [[ $CONTEXT_CYCLE -eq 1 ]]; then
|
|
2489
|
+
printf '\n> Warning: a dependency cycle was detected. Listed in best-effort order.\n' >> "$output"
|
|
2490
|
+
fi
|
|
2491
|
+
|
|
2492
|
+
# Agent instructions
|
|
2493
|
+
{
|
|
2494
|
+
printf '\n'
|
|
2495
|
+
printf '## Agent Instructions\n'
|
|
2496
|
+
printf '\n'
|
|
2497
|
+
printf 'When working in this workspace:\n'
|
|
2498
|
+
printf '\n'
|
|
2499
|
+
printf '1. Read this file first to understand the repo structure\n'
|
|
2500
|
+
printf '2. Check `.revo/features/` for active feature contexts\n'
|
|
2501
|
+
printf '3. Follow the dependency order above when making cross-repo changes\n'
|
|
2502
|
+
printf '4. Each repo may have its own CLAUDE.md with repo-specific instructions\n'
|
|
2503
|
+
printf '5. Use `revo status` to check state across all repos\n'
|
|
2504
|
+
printf '6. Use `revo commit "msg"` to commit across all repos at once\n'
|
|
2505
|
+
printf '7. Use `revo feature <name>` to start a coordinated feature workspace\n'
|
|
2506
|
+
printf '8. Use `revo pr "title"` to open coordinated pull requests\n'
|
|
2507
|
+
} >> "$output"
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
cmd_context() {
|
|
2511
|
+
# Parse arguments
|
|
2512
|
+
while [[ $# -gt 0 ]]; do
|
|
2513
|
+
case "$1" in
|
|
2514
|
+
--help|-h)
|
|
2515
|
+
printf 'Usage: revo context\n\n'
|
|
2516
|
+
printf 'Scan repos and regenerate workspace CLAUDE.md with framework,\n'
|
|
2517
|
+
printf 'routes, and dependency order for Claude Code to consume.\n'
|
|
2518
|
+
return 0
|
|
2519
|
+
;;
|
|
2520
|
+
*)
|
|
2521
|
+
ui_step_error "Unknown option: $1"
|
|
2522
|
+
return 1
|
|
2523
|
+
;;
|
|
2524
|
+
esac
|
|
2525
|
+
done
|
|
2526
|
+
|
|
2527
|
+
config_require_workspace || return 1
|
|
2528
|
+
|
|
2529
|
+
ui_intro "Revo - Generate Workspace CLAUDE.md"
|
|
2530
|
+
|
|
2531
|
+
if [[ $YAML_REPO_COUNT -eq 0 ]]; then
|
|
2532
|
+
ui_step_error "No repositories configured"
|
|
2533
|
+
ui_outro_cancel "Nothing to scan"
|
|
2534
|
+
return 1
|
|
2535
|
+
fi
|
|
2536
|
+
|
|
2537
|
+
local output="$REVO_WORKSPACE_ROOT/CLAUDE.md"
|
|
2538
|
+
|
|
2539
|
+
ui_spinner_start "Scanning $YAML_REPO_COUNT repositories..."
|
|
2540
|
+
_context_write_file "$output"
|
|
2541
|
+
ui_spinner_stop
|
|
2542
|
+
ui_step_done "Scanned:" "$YAML_REPO_COUNT repositories"
|
|
2543
|
+
ui_step_done "Wrote:" "CLAUDE.md"
|
|
2544
|
+
|
|
2545
|
+
if [[ $CONTEXT_CYCLE -eq 1 ]]; then
|
|
2546
|
+
ui_step_error "Dependency cycle detected - see CLAUDE.md for order"
|
|
2547
|
+
fi
|
|
2548
|
+
|
|
2549
|
+
ui_outro "Context generated. Point Claude Code at this workspace."
|
|
2550
|
+
|
|
2551
|
+
return 0
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
# Called automatically from cmd_clone if CLAUDE.md does not yet exist.
|
|
2555
|
+
context_autogenerate_if_missing() {
|
|
2556
|
+
[[ -z "$REVO_WORKSPACE_ROOT" ]] && return 0
|
|
2557
|
+
local output="$REVO_WORKSPACE_ROOT/CLAUDE.md"
|
|
2558
|
+
[[ -f "$output" ]] && return 0
|
|
2559
|
+
[[ $YAML_REPO_COUNT -eq 0 ]] && return 0
|
|
2560
|
+
|
|
2561
|
+
_context_write_file "$output"
|
|
2562
|
+
ui_info "$(ui_dim "Auto-generated CLAUDE.md for Claude Code")"
|
|
2563
|
+
return 0
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
# === lib/commands/feature.sh ===
|
|
2567
|
+
# Revo CLI - feature command
|
|
2568
|
+
# Creates a coordinated feature branch and writes a feature context file.
|
|
2569
|
+
|
|
2570
|
+
cmd_feature() {
|
|
2571
|
+
local name=""
|
|
2572
|
+
local tag=""
|
|
2573
|
+
|
|
2574
|
+
while [[ $# -gt 0 ]]; do
|
|
2575
|
+
case "$1" in
|
|
2576
|
+
--tag)
|
|
2577
|
+
tag="$2"
|
|
2578
|
+
shift 2
|
|
2579
|
+
;;
|
|
2580
|
+
--help|-h)
|
|
2581
|
+
printf 'Usage: revo feature <name> [--tag TAG]\n\n'
|
|
2582
|
+
printf 'Creates feature/<name> on matching repos and writes\n'
|
|
2583
|
+
printf '.revo/features/<name>.md as a shared context file.\n'
|
|
2584
|
+
return 0
|
|
2585
|
+
;;
|
|
2586
|
+
-*)
|
|
2587
|
+
ui_step_error "Unknown option: $1"
|
|
2588
|
+
return 1
|
|
2589
|
+
;;
|
|
2590
|
+
*)
|
|
2591
|
+
if [[ -z "$name" ]]; then
|
|
2592
|
+
name="$1"
|
|
2593
|
+
else
|
|
2594
|
+
ui_step_error "Unexpected argument: $1"
|
|
2595
|
+
return 1
|
|
2596
|
+
fi
|
|
2597
|
+
shift
|
|
2598
|
+
;;
|
|
2599
|
+
esac
|
|
2600
|
+
done
|
|
2601
|
+
|
|
2602
|
+
if [[ -z "$name" ]]; then
|
|
2603
|
+
ui_step_error "Usage: revo feature <name> [--tag TAG]"
|
|
2604
|
+
return 1
|
|
2605
|
+
fi
|
|
2606
|
+
|
|
2607
|
+
config_require_workspace || return 1
|
|
2608
|
+
|
|
2609
|
+
local branch="feature/$name"
|
|
2610
|
+
ui_intro "Revo - Feature: $name"
|
|
2611
|
+
|
|
2612
|
+
local repos
|
|
2613
|
+
repos=$(config_get_repos "$tag")
|
|
2614
|
+
|
|
2615
|
+
if [[ -z "$repos" ]]; then
|
|
2616
|
+
ui_step_error "No repositories match"
|
|
2617
|
+
ui_outro_cancel "Nothing to do"
|
|
2618
|
+
return 1
|
|
2619
|
+
fi
|
|
2620
|
+
|
|
2621
|
+
local success_count=0
|
|
2622
|
+
local skip_count=0
|
|
2623
|
+
local fail_count=0
|
|
2624
|
+
local involved_indices=()
|
|
2625
|
+
|
|
2626
|
+
while IFS= read -r repo; do
|
|
2627
|
+
[[ -z "$repo" ]] && continue
|
|
2628
|
+
|
|
2629
|
+
local path
|
|
2630
|
+
path=$(yaml_get_path "$repo")
|
|
2631
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
2632
|
+
|
|
2633
|
+
if [[ ! -d "$full_path" ]]; then
|
|
2634
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
2635
|
+
skip_count=$((skip_count + 1))
|
|
2636
|
+
continue
|
|
2637
|
+
fi
|
|
2638
|
+
|
|
2639
|
+
involved_indices+=("$repo")
|
|
2640
|
+
|
|
2641
|
+
if git_branch_exists "$full_path" "$branch"; then
|
|
2642
|
+
if git_checkout "$full_path" "$branch"; then
|
|
2643
|
+
ui_step_done "Checked out existing:" "$path → $branch"
|
|
2644
|
+
success_count=$((success_count + 1))
|
|
2645
|
+
else
|
|
2646
|
+
ui_step_error "Failed to checkout existing branch: $path"
|
|
2647
|
+
fail_count=$((fail_count + 1))
|
|
2648
|
+
fi
|
|
2649
|
+
continue
|
|
2650
|
+
fi
|
|
2651
|
+
|
|
2652
|
+
if git_branch "$full_path" "$branch"; then
|
|
2653
|
+
ui_step_done "Created:" "$path → $branch"
|
|
2654
|
+
success_count=$((success_count + 1))
|
|
2655
|
+
else
|
|
2656
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
2657
|
+
fail_count=$((fail_count + 1))
|
|
2658
|
+
fi
|
|
2659
|
+
done <<< "$repos"
|
|
2660
|
+
|
|
2661
|
+
# Write feature context file
|
|
2662
|
+
local feature_dir="$REVO_WORKSPACE_ROOT/.revo/features"
|
|
2663
|
+
mkdir -p "$feature_dir"
|
|
2664
|
+
local feature_file="$feature_dir/$name.md"
|
|
2665
|
+
|
|
2666
|
+
if [[ -f "$feature_file" ]]; then
|
|
2667
|
+
ui_step_done "Feature file exists:" ".revo/features/$name.md"
|
|
2668
|
+
else
|
|
2669
|
+
local timestamp
|
|
2670
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
2671
|
+
|
|
2672
|
+
{
|
|
2673
|
+
printf '# Feature: %s\n' "$name"
|
|
2674
|
+
printf '\n'
|
|
2675
|
+
printf '## Status\n'
|
|
2676
|
+
printf '- Created: %s\n' "$timestamp"
|
|
2677
|
+
printf '- Branch: %s\n' "$branch"
|
|
2678
|
+
if [[ -n "$tag" ]]; then
|
|
2679
|
+
printf '- Tag filter: %s\n' "$tag"
|
|
2680
|
+
fi
|
|
2681
|
+
printf '\n'
|
|
2682
|
+
printf '## Repos\n'
|
|
2683
|
+
|
|
2684
|
+
local idx
|
|
2685
|
+
for idx in "${involved_indices[@]}"; do
|
|
2686
|
+
local rpath rtags
|
|
2687
|
+
rpath=$(yaml_get_path "$idx")
|
|
2688
|
+
rtags=$(yaml_get_tags "$idx")
|
|
2689
|
+
if [[ -n "$rtags" ]]; then
|
|
2690
|
+
printf -- '- %s (tags: %s)\n' "$rpath" "$rtags"
|
|
2691
|
+
else
|
|
2692
|
+
printf -- '- %s\n' "$rpath"
|
|
2693
|
+
fi
|
|
2694
|
+
done
|
|
2695
|
+
|
|
2696
|
+
printf '\n'
|
|
2697
|
+
printf '## Plan\n'
|
|
2698
|
+
printf '<!-- Describe what this feature does across repos -->\n'
|
|
2699
|
+
printf '<!-- The agent will read this to understand the scope -->\n'
|
|
2700
|
+
printf '\n'
|
|
2701
|
+
printf '## Changes\n'
|
|
2702
|
+
printf '<!-- Track what has been done in each repo -->\n'
|
|
2703
|
+
printf '\n'
|
|
2704
|
+
printf '## Dependencies\n'
|
|
2705
|
+
printf '<!-- Note cross-repo dependencies -->\n'
|
|
2706
|
+
} > "$feature_file"
|
|
2707
|
+
|
|
2708
|
+
ui_step_done "Wrote:" ".revo/features/$name.md"
|
|
2709
|
+
fi
|
|
2710
|
+
|
|
2711
|
+
ui_bar_line
|
|
2712
|
+
|
|
2713
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
2714
|
+
local msg="Feature '$name' ready on $success_count repo(s)"
|
|
2715
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
2716
|
+
ui_info "$(ui_dim "Next: edit .revo/features/$name.md with the plan,")"
|
|
2717
|
+
ui_info "$(ui_dim "then ask Claude Code to work in $REVO_WORKSPACE_ROOT")"
|
|
2718
|
+
ui_outro "$msg"
|
|
2719
|
+
else
|
|
2720
|
+
ui_outro_cancel "$success_count succeeded, $fail_count failed"
|
|
2721
|
+
return 1
|
|
2722
|
+
fi
|
|
2723
|
+
|
|
2724
|
+
return 0
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
# === lib/commands/commit.sh ===
|
|
2728
|
+
# Revo CLI - commit command
|
|
2729
|
+
# Commit across dirty repos with the same message.
|
|
2730
|
+
|
|
2731
|
+
cmd_commit() {
|
|
2732
|
+
local message=""
|
|
2733
|
+
local tag=""
|
|
2734
|
+
|
|
2735
|
+
while [[ $# -gt 0 ]]; do
|
|
2736
|
+
case "$1" in
|
|
2737
|
+
--tag)
|
|
2738
|
+
tag="$2"
|
|
2739
|
+
shift 2
|
|
2740
|
+
;;
|
|
2741
|
+
--help|-h)
|
|
2742
|
+
printf 'Usage: revo commit <message> [--tag TAG]\n\n'
|
|
2743
|
+
printf 'Stages and commits changes across dirty repos with the same message.\n'
|
|
2744
|
+
return 0
|
|
2745
|
+
;;
|
|
2746
|
+
-*)
|
|
2747
|
+
ui_step_error "Unknown option: $1"
|
|
2748
|
+
return 1
|
|
2749
|
+
;;
|
|
2750
|
+
*)
|
|
2751
|
+
if [[ -z "$message" ]]; then
|
|
2752
|
+
message="$1"
|
|
2753
|
+
else
|
|
2754
|
+
ui_step_error "Unexpected argument: $1"
|
|
2755
|
+
return 1
|
|
2756
|
+
fi
|
|
2757
|
+
shift
|
|
2758
|
+
;;
|
|
2759
|
+
esac
|
|
2760
|
+
done
|
|
2761
|
+
|
|
2762
|
+
if [[ -z "$message" ]]; then
|
|
2763
|
+
ui_step_error "Usage: revo commit <message> [--tag TAG]"
|
|
2764
|
+
return 1
|
|
2765
|
+
fi
|
|
2766
|
+
|
|
2767
|
+
config_require_workspace || return 1
|
|
2768
|
+
|
|
2769
|
+
ui_intro "Revo - Commit: $message"
|
|
2770
|
+
|
|
2771
|
+
local repos
|
|
2772
|
+
repos=$(config_get_repos "$tag")
|
|
2773
|
+
|
|
2774
|
+
if [[ -z "$repos" ]]; then
|
|
2775
|
+
ui_step_error "No repositories configured"
|
|
2776
|
+
ui_outro_cancel "Nothing to commit"
|
|
2777
|
+
return 1
|
|
2778
|
+
fi
|
|
2779
|
+
|
|
2780
|
+
local success_count=0
|
|
2781
|
+
local skip_count=0
|
|
2782
|
+
local fail_count=0
|
|
2783
|
+
|
|
2784
|
+
while IFS= read -r repo; do
|
|
2785
|
+
[[ -z "$repo" ]] && continue
|
|
2786
|
+
|
|
2787
|
+
local path
|
|
2788
|
+
path=$(yaml_get_path "$repo")
|
|
2789
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
2790
|
+
|
|
2791
|
+
if [[ ! -d "$full_path" ]]; then
|
|
2792
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
2793
|
+
skip_count=$((skip_count + 1))
|
|
2794
|
+
continue
|
|
2795
|
+
fi
|
|
2796
|
+
|
|
2797
|
+
if ! git_is_dirty "$full_path"; then
|
|
2798
|
+
ui_step_done "Clean (skipped):" "$path"
|
|
2799
|
+
skip_count=$((skip_count + 1))
|
|
2800
|
+
continue
|
|
2801
|
+
fi
|
|
2802
|
+
|
|
2803
|
+
# Stage
|
|
2804
|
+
if ! git_exec "$full_path" add -A; then
|
|
2805
|
+
ui_step_error "Failed to stage: $path - $GIT_ERROR"
|
|
2806
|
+
fail_count=$((fail_count + 1))
|
|
2807
|
+
continue
|
|
2808
|
+
fi
|
|
2809
|
+
|
|
2810
|
+
# Commit
|
|
2811
|
+
if git_exec "$full_path" commit -m "$message"; then
|
|
2812
|
+
ui_step_done "Committed:" "$path"
|
|
2813
|
+
success_count=$((success_count + 1))
|
|
2814
|
+
else
|
|
2815
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
2816
|
+
fail_count=$((fail_count + 1))
|
|
2817
|
+
fi
|
|
2818
|
+
done <<< "$repos"
|
|
2819
|
+
|
|
2820
|
+
ui_bar_line
|
|
2821
|
+
|
|
2822
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
2823
|
+
local msg="Committed on $success_count repo(s)"
|
|
2824
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
2825
|
+
ui_outro "$msg"
|
|
2826
|
+
else
|
|
2827
|
+
ui_outro_cancel "$success_count committed, $fail_count failed"
|
|
2828
|
+
return 1
|
|
2829
|
+
fi
|
|
2830
|
+
|
|
2831
|
+
return 0
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
# === lib/commands/push.sh ===
|
|
2835
|
+
# Revo CLI - push command
|
|
2836
|
+
# Pushes current branch across repositories.
|
|
2837
|
+
|
|
2838
|
+
cmd_push() {
|
|
2839
|
+
local tag=""
|
|
2840
|
+
local set_upstream=1
|
|
2841
|
+
|
|
2842
|
+
while [[ $# -gt 0 ]]; do
|
|
2843
|
+
case "$1" in
|
|
2844
|
+
--tag)
|
|
2845
|
+
tag="$2"
|
|
2846
|
+
shift 2
|
|
2847
|
+
;;
|
|
2848
|
+
--no-upstream)
|
|
2849
|
+
set_upstream=0
|
|
2850
|
+
shift
|
|
2851
|
+
;;
|
|
2852
|
+
--help|-h)
|
|
2853
|
+
printf 'Usage: revo push [--tag TAG]\n\n'
|
|
2854
|
+
printf 'Pushes current branch of each repo to origin.\n'
|
|
2855
|
+
printf 'Sets upstream automatically the first time.\n'
|
|
2856
|
+
return 0
|
|
2857
|
+
;;
|
|
2858
|
+
-*)
|
|
2859
|
+
ui_step_error "Unknown option: $1"
|
|
2860
|
+
return 1
|
|
2861
|
+
;;
|
|
2862
|
+
*)
|
|
2863
|
+
ui_step_error "Unexpected argument: $1"
|
|
2864
|
+
return 1
|
|
2865
|
+
;;
|
|
2866
|
+
esac
|
|
2867
|
+
done
|
|
2868
|
+
|
|
2869
|
+
config_require_workspace || return 1
|
|
2870
|
+
|
|
2871
|
+
ui_intro "Revo - Push Repositories"
|
|
2872
|
+
|
|
2873
|
+
local repos
|
|
2874
|
+
repos=$(config_get_repos "$tag")
|
|
2875
|
+
|
|
2876
|
+
if [[ -z "$repos" ]]; then
|
|
2877
|
+
ui_step_error "No repositories configured"
|
|
2878
|
+
ui_outro_cancel "Nothing to push"
|
|
2879
|
+
return 1
|
|
2880
|
+
fi
|
|
2881
|
+
|
|
2882
|
+
local success_count=0
|
|
2883
|
+
local skip_count=0
|
|
2884
|
+
local fail_count=0
|
|
2885
|
+
|
|
2886
|
+
while IFS= read -r repo; do
|
|
2887
|
+
[[ -z "$repo" ]] && continue
|
|
2888
|
+
|
|
2889
|
+
local path
|
|
2890
|
+
path=$(yaml_get_path "$repo")
|
|
2891
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
2892
|
+
|
|
2893
|
+
if [[ ! -d "$full_path" ]]; then
|
|
2894
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
2895
|
+
skip_count=$((skip_count + 1))
|
|
2896
|
+
continue
|
|
2897
|
+
fi
|
|
2898
|
+
|
|
2899
|
+
local branch
|
|
2900
|
+
branch=$(git_current_branch "$full_path")
|
|
2901
|
+
if [[ -z "$branch" ]] || [[ "$branch" == "HEAD" ]]; then
|
|
2902
|
+
ui_step_error "Detached HEAD: $path"
|
|
2903
|
+
fail_count=$((fail_count + 1))
|
|
2904
|
+
continue
|
|
2905
|
+
fi
|
|
2906
|
+
|
|
2907
|
+
# Check if upstream already exists
|
|
2908
|
+
local has_upstream=0
|
|
2909
|
+
if git -C "$full_path" rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
|
|
2910
|
+
has_upstream=1
|
|
2911
|
+
fi
|
|
2912
|
+
|
|
2913
|
+
if [[ $has_upstream -eq 1 ]]; then
|
|
2914
|
+
if git_exec "$full_path" push; then
|
|
2915
|
+
ui_step_done "Pushed:" "$path → $branch"
|
|
2916
|
+
success_count=$((success_count + 1))
|
|
2917
|
+
else
|
|
2918
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
2919
|
+
fail_count=$((fail_count + 1))
|
|
2920
|
+
fi
|
|
2921
|
+
else
|
|
2922
|
+
if [[ $set_upstream -eq 1 ]]; then
|
|
2923
|
+
if git_exec "$full_path" push -u origin "$branch"; then
|
|
2924
|
+
ui_step_done "Pushed (set upstream):" "$path → $branch"
|
|
2925
|
+
success_count=$((success_count + 1))
|
|
2926
|
+
else
|
|
2927
|
+
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
2928
|
+
fail_count=$((fail_count + 1))
|
|
2929
|
+
fi
|
|
2930
|
+
else
|
|
2931
|
+
ui_step_error "No upstream and --no-upstream set: $path"
|
|
2932
|
+
fail_count=$((fail_count + 1))
|
|
2933
|
+
fi
|
|
2934
|
+
fi
|
|
2935
|
+
done <<< "$repos"
|
|
2936
|
+
|
|
2937
|
+
ui_bar_line
|
|
2938
|
+
|
|
2939
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
2940
|
+
local msg="Pushed $success_count repo(s)"
|
|
2941
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
2942
|
+
ui_outro "$msg"
|
|
2943
|
+
else
|
|
2944
|
+
ui_outro_cancel "$success_count pushed, $fail_count failed"
|
|
2945
|
+
return 1
|
|
2946
|
+
fi
|
|
2947
|
+
|
|
2948
|
+
return 0
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
# === lib/commands/pr.sh ===
|
|
2952
|
+
# Revo CLI - pr command
|
|
2953
|
+
# Creates coordinated pull requests across repos via the gh CLI.
|
|
2954
|
+
|
|
2955
|
+
cmd_pr() {
|
|
2956
|
+
local title=""
|
|
2957
|
+
local tag=""
|
|
2958
|
+
local body=""
|
|
2959
|
+
|
|
2960
|
+
while [[ $# -gt 0 ]]; do
|
|
2961
|
+
case "$1" in
|
|
2962
|
+
--tag)
|
|
2963
|
+
tag="$2"
|
|
2964
|
+
shift 2
|
|
2965
|
+
;;
|
|
2966
|
+
--body)
|
|
2967
|
+
body="$2"
|
|
2968
|
+
shift 2
|
|
2969
|
+
;;
|
|
2970
|
+
--help|-h)
|
|
2971
|
+
printf 'Usage: revo pr <title> [--tag TAG] [--body BODY]\n\n'
|
|
2972
|
+
printf 'Creates pull requests across repos on non-main branches using gh CLI.\n'
|
|
2973
|
+
printf 'After all PRs are created, appends cross-reference links to each body.\n'
|
|
2974
|
+
return 0
|
|
2975
|
+
;;
|
|
2976
|
+
-*)
|
|
2977
|
+
ui_step_error "Unknown option: $1"
|
|
2978
|
+
return 1
|
|
2979
|
+
;;
|
|
2980
|
+
*)
|
|
2981
|
+
if [[ -z "$title" ]]; then
|
|
2982
|
+
title="$1"
|
|
2983
|
+
else
|
|
2984
|
+
ui_step_error "Unexpected argument: $1"
|
|
2985
|
+
return 1
|
|
2986
|
+
fi
|
|
2987
|
+
shift
|
|
2988
|
+
;;
|
|
2989
|
+
esac
|
|
2990
|
+
done
|
|
2991
|
+
|
|
2992
|
+
if [[ -z "$title" ]]; then
|
|
2993
|
+
ui_step_error "Usage: revo pr <title> [--tag TAG] [--body BODY]"
|
|
2994
|
+
return 1
|
|
2995
|
+
fi
|
|
2996
|
+
|
|
2997
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
2998
|
+
ui_step_error "gh CLI not found. Install from https://cli.github.com/"
|
|
2999
|
+
return 1
|
|
3000
|
+
fi
|
|
3001
|
+
|
|
3002
|
+
config_require_workspace || return 1
|
|
3003
|
+
|
|
3004
|
+
ui_intro "Revo - Create PRs: $title"
|
|
3005
|
+
|
|
3006
|
+
local repos
|
|
3007
|
+
repos=$(config_get_repos "$tag")
|
|
3008
|
+
|
|
3009
|
+
if [[ -z "$repos" ]]; then
|
|
3010
|
+
ui_step_error "No repositories configured"
|
|
3011
|
+
ui_outro_cancel "Nothing to do"
|
|
3012
|
+
return 1
|
|
3013
|
+
fi
|
|
3014
|
+
|
|
3015
|
+
# Parallel arrays to collect PR results (bash 3.2)
|
|
3016
|
+
local pr_paths=()
|
|
3017
|
+
local pr_urls=()
|
|
3018
|
+
local pr_numbers=()
|
|
3019
|
+
|
|
3020
|
+
local default_body="$body"
|
|
3021
|
+
if [[ -z "$default_body" ]]; then
|
|
3022
|
+
default_body="Coordinated PR created by \`revo pr\`."
|
|
3023
|
+
fi
|
|
3024
|
+
|
|
3025
|
+
local skip_count=0
|
|
3026
|
+
local fail_count=0
|
|
3027
|
+
|
|
3028
|
+
# --- Pass 1: create PRs ---
|
|
3029
|
+
while IFS= read -r repo; do
|
|
3030
|
+
[[ -z "$repo" ]] && continue
|
|
3031
|
+
|
|
3032
|
+
local path
|
|
3033
|
+
path=$(yaml_get_path "$repo")
|
|
3034
|
+
local full_path="$REVO_REPOS_DIR/$path"
|
|
3035
|
+
|
|
3036
|
+
if [[ ! -d "$full_path" ]]; then
|
|
3037
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
3038
|
+
skip_count=$((skip_count + 1))
|
|
3039
|
+
continue
|
|
3040
|
+
fi
|
|
3041
|
+
|
|
3042
|
+
local branch
|
|
3043
|
+
branch=$(git_current_branch "$full_path")
|
|
3044
|
+
if [[ -z "$branch" ]] || [[ "$branch" == "main" ]] || [[ "$branch" == "master" ]]; then
|
|
3045
|
+
ui_step_done "Skipped (on main/master):" "$path"
|
|
3046
|
+
skip_count=$((skip_count + 1))
|
|
3047
|
+
continue
|
|
3048
|
+
fi
|
|
3049
|
+
|
|
3050
|
+
# Skip if no commits ahead of upstream (push required first)
|
|
3051
|
+
git_ahead_behind "$full_path"
|
|
3052
|
+
local has_upstream=0
|
|
3053
|
+
if git -C "$full_path" rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
|
|
3054
|
+
has_upstream=1
|
|
3055
|
+
fi
|
|
3056
|
+
|
|
3057
|
+
if [[ $has_upstream -eq 1 ]] && [[ $GIT_AHEAD -eq 0 ]]; then
|
|
3058
|
+
ui_step_done "Skipped (no changes ahead of upstream):" "$path"
|
|
3059
|
+
skip_count=$((skip_count + 1))
|
|
3060
|
+
continue
|
|
3061
|
+
fi
|
|
3062
|
+
|
|
3063
|
+
# Create PR via gh
|
|
3064
|
+
local pr_title="[revo] $title"
|
|
3065
|
+
local output
|
|
3066
|
+
if output=$(cd "$full_path" && gh pr create --title "$pr_title" --body "$default_body" 2>&1); then
|
|
3067
|
+
local url
|
|
3068
|
+
url=$(printf '%s' "$output" | grep -Eo 'https://github\.com/[^ ]+/pull/[0-9]+' | tail -1)
|
|
3069
|
+
if [[ -z "$url" ]]; then
|
|
3070
|
+
url="$output"
|
|
3071
|
+
fi
|
|
3072
|
+
pr_paths+=("$path")
|
|
3073
|
+
pr_urls+=("$url")
|
|
3074
|
+
local number
|
|
3075
|
+
number="${url##*/}"
|
|
3076
|
+
pr_numbers+=("$number")
|
|
3077
|
+
ui_step_done "Opened PR:" "$path → $url"
|
|
3078
|
+
else
|
|
3079
|
+
# Maybe a PR already exists
|
|
3080
|
+
local existing
|
|
3081
|
+
existing=$(cd "$full_path" && gh pr view --json url -q .url 2>/dev/null || true)
|
|
3082
|
+
if [[ -n "$existing" ]]; then
|
|
3083
|
+
pr_paths+=("$path")
|
|
3084
|
+
pr_urls+=("$existing")
|
|
3085
|
+
local number
|
|
3086
|
+
number="${existing##*/}"
|
|
3087
|
+
pr_numbers+=("$number")
|
|
3088
|
+
ui_step_done "Existing PR:" "$path → $existing"
|
|
3089
|
+
else
|
|
3090
|
+
ui_step_error "Failed: $path"
|
|
3091
|
+
ui_info "$(ui_dim "$output")"
|
|
3092
|
+
fail_count=$((fail_count + 1))
|
|
3093
|
+
fi
|
|
3094
|
+
fi
|
|
3095
|
+
done <<< "$repos"
|
|
3096
|
+
|
|
3097
|
+
# --- Pass 2: append cross-references if >1 PR ---
|
|
3098
|
+
if [[ ${#pr_paths[@]} -gt 1 ]]; then
|
|
3099
|
+
ui_bar_line
|
|
3100
|
+
ui_step "Linking PRs with cross-references..."
|
|
3101
|
+
|
|
3102
|
+
# Build cross-reference block
|
|
3103
|
+
local xref
|
|
3104
|
+
xref=$'\n\n---\n**Coordinated PRs (revo):**\n'
|
|
3105
|
+
local i
|
|
3106
|
+
for ((i = 0; i < ${#pr_paths[@]}; i++)); do
|
|
3107
|
+
xref+="- ${pr_paths[$i]}: ${pr_urls[$i]}"$'\n'
|
|
3108
|
+
done
|
|
3109
|
+
|
|
3110
|
+
for ((i = 0; i < ${#pr_paths[@]}; i++)); do
|
|
3111
|
+
local repo_path="${pr_paths[$i]}"
|
|
3112
|
+
local pr_number="${pr_numbers[$i]}"
|
|
3113
|
+
local full_path="$REVO_REPOS_DIR/$repo_path"
|
|
3114
|
+
local combined_body="$default_body$xref"
|
|
3115
|
+
|
|
3116
|
+
if (cd "$full_path" && gh pr edit "$pr_number" --body "$combined_body" >/dev/null 2>&1); then
|
|
3117
|
+
ui_step_done "Linked:" "$repo_path"
|
|
3118
|
+
else
|
|
3119
|
+
ui_step_error "Failed to link: $repo_path"
|
|
3120
|
+
fi
|
|
3121
|
+
done
|
|
3122
|
+
fi
|
|
3123
|
+
|
|
3124
|
+
ui_bar_line
|
|
3125
|
+
|
|
3126
|
+
if [[ $fail_count -eq 0 ]]; then
|
|
3127
|
+
local msg="Opened ${#pr_paths[@]} PR(s)"
|
|
3128
|
+
[[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
|
|
3129
|
+
ui_outro "$msg"
|
|
3130
|
+
else
|
|
3131
|
+
ui_outro_cancel "${#pr_paths[@]} opened, $fail_count failed"
|
|
3132
|
+
return 1
|
|
3133
|
+
fi
|
|
3134
|
+
|
|
3135
|
+
return 0
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
# === Main ===
|
|
3139
|
+
# --- Help ---
|
|
3140
|
+
show_help() {
|
|
3141
|
+
cat << EOF
|
|
3142
|
+
Revo - Claude-first multi-repo workspace manager v$REVO_VERSION
|
|
3143
|
+
|
|
3144
|
+
Usage: revo <command> [options]
|
|
3145
|
+
|
|
3146
|
+
Workspace commands:
|
|
3147
|
+
init Initialize a new workspace
|
|
3148
|
+
add URL [--tags TAGS] Add a repository to the workspace
|
|
3149
|
+
clone [--tag TAG] Clone configured repositories
|
|
3150
|
+
list [--tag TAG] List configured repositories
|
|
3151
|
+
status [--tag TAG] Show status of all repositories
|
|
3152
|
+
sync [--tag TAG] Pull latest changes on repositories
|
|
3153
|
+
branch NAME [--tag TAG] Create a branch on repositories
|
|
3154
|
+
checkout BRANCH [--tag TAG] Checkout a branch on repositories
|
|
3155
|
+
exec "CMD" [--tag TAG] Run a command in each repository
|
|
3156
|
+
|
|
3157
|
+
Claude-first commands:
|
|
3158
|
+
context Scan repos and regenerate workspace CLAUDE.md
|
|
3159
|
+
feature NAME [--tag TAG] Create feature branch + context file across repos
|
|
3160
|
+
commit MSG [--tag TAG] Commit changes across dirty repos
|
|
3161
|
+
push [--tag TAG] Push branches across repositories
|
|
3162
|
+
pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
|
|
3163
|
+
|
|
3164
|
+
Options:
|
|
3165
|
+
--tag TAG Filter repositories by tag
|
|
3166
|
+
--tags TAGS Comma-separated list of tags (for add)
|
|
3167
|
+
--force, -f Force operation (clone, checkout)
|
|
3168
|
+
--rebase, -r Use rebase when syncing
|
|
3169
|
+
--quiet, -q Suppress command output (exec)
|
|
3170
|
+
--body TEXT PR body (pr)
|
|
3171
|
+
--help, -h Show this help
|
|
3172
|
+
--version, -v Show version
|
|
3173
|
+
|
|
3174
|
+
Examples:
|
|
3175
|
+
revo init
|
|
3176
|
+
revo add git@github.com:org/shared-types.git --tags shared
|
|
3177
|
+
revo add git@github.com:org/backend.git --tags backend,api
|
|
3178
|
+
revo clone
|
|
3179
|
+
revo context # regenerate CLAUDE.md
|
|
3180
|
+
revo feature clock-student --tag backend # coordinated branch
|
|
3181
|
+
revo commit "wire up clock endpoint"
|
|
3182
|
+
revo push
|
|
3183
|
+
revo pr "Clock endpoint for students" --tag backend
|
|
3184
|
+
|
|
3185
|
+
Documentation: https://github.com/marcus.salinas/revo
|
|
3186
|
+
EOF
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
show_version() {
|
|
3190
|
+
printf 'Revo v%s\n' "$REVO_VERSION"
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
# --- Main ---
|
|
3194
|
+
main() {
|
|
3195
|
+
local command="${1:-}"
|
|
3196
|
+
|
|
3197
|
+
# Handle no arguments
|
|
3198
|
+
if [[ -z "$command" ]]; then
|
|
3199
|
+
show_help
|
|
3200
|
+
return 0
|
|
3201
|
+
fi
|
|
3202
|
+
|
|
3203
|
+
# Shift off the command
|
|
3204
|
+
shift || true
|
|
3205
|
+
|
|
3206
|
+
case "$command" in
|
|
3207
|
+
init)
|
|
3208
|
+
cmd_init "$@"
|
|
3209
|
+
;;
|
|
3210
|
+
clone)
|
|
3211
|
+
cmd_clone "$@"
|
|
3212
|
+
;;
|
|
3213
|
+
status)
|
|
3214
|
+
cmd_status "$@"
|
|
3215
|
+
;;
|
|
3216
|
+
branch)
|
|
3217
|
+
cmd_branch "$@"
|
|
3218
|
+
;;
|
|
3219
|
+
checkout)
|
|
3220
|
+
cmd_checkout "$@"
|
|
3221
|
+
;;
|
|
3222
|
+
sync)
|
|
3223
|
+
cmd_sync "$@"
|
|
3224
|
+
;;
|
|
3225
|
+
exec)
|
|
3226
|
+
cmd_exec "$@"
|
|
3227
|
+
;;
|
|
3228
|
+
add)
|
|
3229
|
+
cmd_add "$@"
|
|
3230
|
+
;;
|
|
3231
|
+
list)
|
|
3232
|
+
cmd_list "$@"
|
|
3233
|
+
;;
|
|
3234
|
+
context)
|
|
3235
|
+
cmd_context "$@"
|
|
3236
|
+
;;
|
|
3237
|
+
feature)
|
|
3238
|
+
cmd_feature "$@"
|
|
3239
|
+
;;
|
|
3240
|
+
commit)
|
|
3241
|
+
cmd_commit "$@"
|
|
3242
|
+
;;
|
|
3243
|
+
push)
|
|
3244
|
+
cmd_push "$@"
|
|
3245
|
+
;;
|
|
3246
|
+
pr)
|
|
3247
|
+
cmd_pr "$@"
|
|
3248
|
+
;;
|
|
3249
|
+
--help|-h|help)
|
|
3250
|
+
show_help
|
|
3251
|
+
;;
|
|
3252
|
+
--version|-v|version)
|
|
3253
|
+
show_version
|
|
3254
|
+
;;
|
|
3255
|
+
*)
|
|
3256
|
+
printf 'Unknown command: %s\n\n' "$command" >&2
|
|
3257
|
+
show_help >&2
|
|
3258
|
+
return 1
|
|
3259
|
+
;;
|
|
3260
|
+
esac
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
main "$@"
|