@revotools/cli 0.5.0 → 0.6.1
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/dist/revo +855 -19
- package/package.json +1 -1
package/dist/revo
CHANGED
|
@@ -8,7 +8,7 @@ set -euo pipefail
|
|
|
8
8
|
# Exit cleanly on SIGPIPE (e.g., revo clone | grep, revo status | head)
|
|
9
9
|
trap 'exit 0' PIPE
|
|
10
10
|
|
|
11
|
-
REVO_VERSION="0.
|
|
11
|
+
REVO_VERSION="0.6.1"
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
# === lib/ui.sh ===
|
|
@@ -481,6 +481,7 @@ YAML_REPO_URLS=()
|
|
|
481
481
|
YAML_REPO_PATHS=()
|
|
482
482
|
YAML_REPO_TAGS=()
|
|
483
483
|
YAML_REPO_DEPS=()
|
|
484
|
+
YAML_REPO_BRANCHES=()
|
|
484
485
|
|
|
485
486
|
yaml_parse() {
|
|
486
487
|
local file="$1"
|
|
@@ -497,6 +498,7 @@ yaml_parse() {
|
|
|
497
498
|
YAML_REPO_PATHS=()
|
|
498
499
|
YAML_REPO_TAGS=()
|
|
499
500
|
YAML_REPO_DEPS=()
|
|
501
|
+
YAML_REPO_BRANCHES=()
|
|
500
502
|
|
|
501
503
|
if [[ ! -f "$file" ]]; then
|
|
502
504
|
return 1
|
|
@@ -550,6 +552,7 @@ yaml_parse() {
|
|
|
550
552
|
YAML_REPO_PATHS[$current_index]=$(yaml_path_from_url "$url")
|
|
551
553
|
YAML_REPO_TAGS[$current_index]=""
|
|
552
554
|
YAML_REPO_DEPS[$current_index]=""
|
|
555
|
+
YAML_REPO_BRANCHES[$current_index]=""
|
|
553
556
|
YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
|
|
554
557
|
continue
|
|
555
558
|
fi
|
|
@@ -573,6 +576,8 @@ yaml_parse() {
|
|
|
573
576
|
deps_str="${deps_str//\"/}"
|
|
574
577
|
deps_str="${deps_str//\'/}"
|
|
575
578
|
YAML_REPO_DEPS[$current_index]="$deps_str"
|
|
579
|
+
elif [[ "$trimmed" =~ ^branch:[[:space:]]*(.+)$ ]]; then
|
|
580
|
+
YAML_REPO_BRANCHES[$current_index]="${BASH_REMATCH[1]}"
|
|
576
581
|
fi
|
|
577
582
|
fi
|
|
578
583
|
fi
|
|
@@ -648,6 +653,12 @@ yaml_get_deps() {
|
|
|
648
653
|
printf '%s' "${YAML_REPO_DEPS[$idx]:-}"
|
|
649
654
|
}
|
|
650
655
|
|
|
656
|
+
# Get repo default branch by index (empty means use workspace default)
|
|
657
|
+
yaml_get_branch() {
|
|
658
|
+
local idx="$1"
|
|
659
|
+
printf '%s' "${YAML_REPO_BRANCHES[$idx]:-}"
|
|
660
|
+
}
|
|
661
|
+
|
|
651
662
|
# Find repo index by name (path basename)
|
|
652
663
|
# Usage: idx=$(yaml_find_by_name "backend")
|
|
653
664
|
# Returns: index or -1 if not found
|
|
@@ -681,6 +692,7 @@ yaml_write() {
|
|
|
681
692
|
local path="${YAML_REPO_PATHS[$i]}"
|
|
682
693
|
local tags="${YAML_REPO_TAGS[$i]}"
|
|
683
694
|
local deps="${YAML_REPO_DEPS[$i]:-}"
|
|
695
|
+
local branch="${YAML_REPO_BRANCHES[$i]:-}"
|
|
684
696
|
|
|
685
697
|
printf ' - url: %s\n' "$url"
|
|
686
698
|
|
|
@@ -700,6 +712,11 @@ yaml_write() {
|
|
|
700
712
|
if [[ -n "$deps" ]]; then
|
|
701
713
|
printf ' depends_on: [%s]\n' "$deps"
|
|
702
714
|
fi
|
|
715
|
+
|
|
716
|
+
# Write branch if it differs from the workspace default
|
|
717
|
+
if [[ -n "$branch" ]] && [[ "$branch" != "$YAML_DEFAULTS_BRANCH" ]]; then
|
|
718
|
+
printf ' branch: %s\n' "$branch"
|
|
719
|
+
fi
|
|
703
720
|
done
|
|
704
721
|
|
|
705
722
|
printf '\ndefaults:\n'
|
|
@@ -708,12 +725,13 @@ yaml_write() {
|
|
|
708
725
|
}
|
|
709
726
|
|
|
710
727
|
# Add a repo to the config
|
|
711
|
-
# Usage: yaml_add_repo "url" "path" "tags" "deps"
|
|
728
|
+
# Usage: yaml_add_repo "url" "path" "tags" "deps" ["branch"]
|
|
712
729
|
yaml_add_repo() {
|
|
713
730
|
local url="$1"
|
|
714
731
|
local path="${2:-}"
|
|
715
732
|
local tags="${3:-}"
|
|
716
733
|
local deps="${4:-}"
|
|
734
|
+
local branch="${5:-}"
|
|
717
735
|
|
|
718
736
|
local idx=$YAML_REPO_COUNT
|
|
719
737
|
|
|
@@ -725,6 +743,7 @@ yaml_add_repo() {
|
|
|
725
743
|
YAML_REPO_PATHS[$idx]="$path"
|
|
726
744
|
YAML_REPO_TAGS[$idx]="$tags"
|
|
727
745
|
YAML_REPO_DEPS[$idx]="$deps"
|
|
746
|
+
YAML_REPO_BRANCHES[$idx]="$branch"
|
|
728
747
|
|
|
729
748
|
YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
|
|
730
749
|
}
|
|
@@ -736,6 +755,12 @@ yaml_add_repo() {
|
|
|
736
755
|
REVO_WORKSPACE_ROOT=""
|
|
737
756
|
REVO_CONFIG_FILE=""
|
|
738
757
|
REVO_REPOS_DIR=""
|
|
758
|
+
# When invoked from inside .revo/workspaces/<name>/, REVO_REPOS_DIR is
|
|
759
|
+
# overridden to point at that workspace dir so existing commands (status,
|
|
760
|
+
# commit, push, pr, exec, ...) operate on the workspace's copies of the
|
|
761
|
+
# repos rather than the source tree under repos/. REVO_ACTIVE_WORKSPACE
|
|
762
|
+
# holds the workspace name in that case (empty otherwise).
|
|
763
|
+
REVO_ACTIVE_WORKSPACE=""
|
|
739
764
|
|
|
740
765
|
# Find workspace root by searching upward for revo.yaml (or mars.yaml as fallback)
|
|
741
766
|
# Usage: config_find_root [start_dir]
|
|
@@ -749,6 +774,7 @@ config_find_root() {
|
|
|
749
774
|
REVO_WORKSPACE_ROOT="$current"
|
|
750
775
|
REVO_CONFIG_FILE="$current/revo.yaml"
|
|
751
776
|
REVO_REPOS_DIR="$current/repos"
|
|
777
|
+
_config_apply_workspace_override "$start_dir"
|
|
752
778
|
return 0
|
|
753
779
|
fi
|
|
754
780
|
# Fallback: support mars.yaml for migration from Mars
|
|
@@ -756,6 +782,7 @@ config_find_root() {
|
|
|
756
782
|
REVO_WORKSPACE_ROOT="$current"
|
|
757
783
|
REVO_CONFIG_FILE="$current/mars.yaml"
|
|
758
784
|
REVO_REPOS_DIR="$current/repos"
|
|
785
|
+
_config_apply_workspace_override "$start_dir"
|
|
759
786
|
return 0
|
|
760
787
|
fi
|
|
761
788
|
current="$(dirname "$current")"
|
|
@@ -764,6 +791,37 @@ config_find_root() {
|
|
|
764
791
|
return 1
|
|
765
792
|
}
|
|
766
793
|
|
|
794
|
+
# If start_dir is inside .revo/workspaces/<name>/, point REVO_REPOS_DIR at
|
|
795
|
+
# that workspace and remember the active workspace name. Otherwise leave
|
|
796
|
+
# things alone. Called from config_find_root after the root has been set.
|
|
797
|
+
_config_apply_workspace_override() {
|
|
798
|
+
local start_dir="$1"
|
|
799
|
+
REVO_ACTIVE_WORKSPACE=""
|
|
800
|
+
|
|
801
|
+
[[ -z "$REVO_WORKSPACE_ROOT" ]] && return 0
|
|
802
|
+
|
|
803
|
+
local prefix="$REVO_WORKSPACE_ROOT/.revo/workspaces/"
|
|
804
|
+
case "$start_dir/" in
|
|
805
|
+
"$prefix"*)
|
|
806
|
+
local rest="${start_dir#"$prefix"}"
|
|
807
|
+
local ws_name="${rest%%/*}"
|
|
808
|
+
if [[ -n "$ws_name" ]] && [[ -d "$prefix$ws_name" ]]; then
|
|
809
|
+
REVO_REPOS_DIR="$prefix$ws_name"
|
|
810
|
+
REVO_ACTIVE_WORKSPACE="$ws_name"
|
|
811
|
+
fi
|
|
812
|
+
;;
|
|
813
|
+
esac
|
|
814
|
+
return 0
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
# Always returns the source repos dir ($REVO_WORKSPACE_ROOT/repos),
|
|
818
|
+
# regardless of any active workspace override. Used by `revo workspace`
|
|
819
|
+
# itself, which must read from the source tree even when invoked from
|
|
820
|
+
# inside another workspace.
|
|
821
|
+
config_source_repos_dir() {
|
|
822
|
+
printf '%s/repos' "$REVO_WORKSPACE_ROOT"
|
|
823
|
+
}
|
|
824
|
+
|
|
767
825
|
# Initialize workspace in current directory
|
|
768
826
|
# Usage: config_init "workspace_name"
|
|
769
827
|
# Returns: 0 on success, 1 if already initialized
|
|
@@ -787,6 +845,7 @@ config_init() {
|
|
|
787
845
|
YAML_REPO_PATHS=()
|
|
788
846
|
YAML_REPO_TAGS=()
|
|
789
847
|
YAML_REPO_DEPS=()
|
|
848
|
+
YAML_REPO_BRANCHES=()
|
|
790
849
|
|
|
791
850
|
# Create directory structure
|
|
792
851
|
mkdir -p "$REVO_REPOS_DIR"
|
|
@@ -928,11 +987,25 @@ config_workspace_name() {
|
|
|
928
987
|
printf '%s' "$YAML_WORKSPACE_NAME"
|
|
929
988
|
}
|
|
930
989
|
|
|
931
|
-
# Get default branch
|
|
990
|
+
# Get workspace default branch
|
|
932
991
|
config_default_branch() {
|
|
933
992
|
printf '%s' "$YAML_DEFAULTS_BRANCH"
|
|
934
993
|
}
|
|
935
994
|
|
|
995
|
+
# Get effective default branch for a specific repo
|
|
996
|
+
# Falls back to workspace default if no per-repo branch is set
|
|
997
|
+
# Usage: branch=$(config_repo_default_branch "repo_index")
|
|
998
|
+
config_repo_default_branch() {
|
|
999
|
+
local idx="$1"
|
|
1000
|
+
local branch
|
|
1001
|
+
branch=$(yaml_get_branch "$idx")
|
|
1002
|
+
if [[ -n "$branch" ]]; then
|
|
1003
|
+
printf '%s' "$branch"
|
|
1004
|
+
else
|
|
1005
|
+
printf '%s' "$YAML_DEFAULTS_BRANCH"
|
|
1006
|
+
fi
|
|
1007
|
+
}
|
|
1008
|
+
|
|
936
1009
|
# === lib/git.sh ===
|
|
937
1010
|
# Revo CLI - Git Operations
|
|
938
1011
|
# Wrapper functions for git commands with consistent error handling
|
|
@@ -1141,6 +1214,34 @@ git_remote_url() {
|
|
|
1141
1214
|
git -C "$repo_dir" remote get-url origin 2>/dev/null
|
|
1142
1215
|
}
|
|
1143
1216
|
|
|
1217
|
+
# Detect the default branch for a cloned repo
|
|
1218
|
+
# Tries symbolic-ref first, then falls back to checking main/master
|
|
1219
|
+
# Usage: branch=$(git_default_branch "repo_dir")
|
|
1220
|
+
git_default_branch() {
|
|
1221
|
+
local repo_dir="$1"
|
|
1222
|
+
local ref
|
|
1223
|
+
|
|
1224
|
+
# Best source: what the remote says HEAD points to
|
|
1225
|
+
ref=$(git -C "$repo_dir" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null)
|
|
1226
|
+
if [[ -n "$ref" ]]; then
|
|
1227
|
+
printf '%s' "${ref##*/}"
|
|
1228
|
+
return 0
|
|
1229
|
+
fi
|
|
1230
|
+
|
|
1231
|
+
# Fallback: check which of main/master exists
|
|
1232
|
+
if git -C "$repo_dir" rev-parse --verify origin/main >/dev/null 2>&1; then
|
|
1233
|
+
printf '%s' "main"
|
|
1234
|
+
return 0
|
|
1235
|
+
fi
|
|
1236
|
+
if git -C "$repo_dir" rev-parse --verify origin/master >/dev/null 2>&1; then
|
|
1237
|
+
printf '%s' "master"
|
|
1238
|
+
return 0
|
|
1239
|
+
fi
|
|
1240
|
+
|
|
1241
|
+
# Last resort: whatever branch we're on
|
|
1242
|
+
git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1144
1245
|
# Check if branch exists (local or remote)
|
|
1145
1246
|
# Usage: if git_branch_exists "repo_dir" "branch_name"; then ...
|
|
1146
1247
|
git_branch_exists() {
|
|
@@ -1694,8 +1795,10 @@ cmd_init() {
|
|
|
1694
1795
|
fi
|
|
1695
1796
|
|
|
1696
1797
|
tags=$(_init_auto_tags "$dir" "$name")
|
|
1697
|
-
|
|
1698
|
-
|
|
1798
|
+
local branch
|
|
1799
|
+
branch=$(git_default_branch "$dir")
|
|
1800
|
+
yaml_add_repo "$remote" "$path" "$tags" "" "$branch"
|
|
1801
|
+
ui_step_done "Detected:" "$name → $remote (branch: $branch)"
|
|
1699
1802
|
detected_count=$((detected_count + 1))
|
|
1700
1803
|
done <<< "$_INIT_FOUND_DIRS"
|
|
1701
1804
|
|
|
@@ -1800,8 +1903,10 @@ cmd_detect() {
|
|
|
1800
1903
|
ln -s "../$name" "repos/$name"
|
|
1801
1904
|
fi
|
|
1802
1905
|
|
|
1803
|
-
|
|
1804
|
-
|
|
1906
|
+
local branch
|
|
1907
|
+
branch=$(git_default_branch "$d")
|
|
1908
|
+
yaml_add_repo "$remote" "$name" "$tags" "" "$branch"
|
|
1909
|
+
ui_step_done "Found:" "$name ($remote, branch: $branch)"
|
|
1805
1910
|
found=$((found + 1))
|
|
1806
1911
|
done
|
|
1807
1912
|
|
|
@@ -1821,8 +1926,10 @@ cmd_detect() {
|
|
|
1821
1926
|
done
|
|
1822
1927
|
[[ $already -eq 1 ]] && continue
|
|
1823
1928
|
remote=$(cd "$d" && git remote get-url origin 2>/dev/null || echo "local://$d")
|
|
1824
|
-
|
|
1825
|
-
|
|
1929
|
+
local branch
|
|
1930
|
+
branch=$(git_default_branch "$d")
|
|
1931
|
+
yaml_add_repo "$remote" "$name" "$name" "" "$branch"
|
|
1932
|
+
ui_step_done "Found:" "$name ($remote, branch: $branch)"
|
|
1826
1933
|
found=$((found + 1))
|
|
1827
1934
|
done
|
|
1828
1935
|
fi
|
|
@@ -1927,7 +2034,13 @@ cmd_clone() {
|
|
|
1927
2034
|
local clone_err
|
|
1928
2035
|
if clone_err=$(git clone --quiet "$url" "$full_path" 2>&1); then
|
|
1929
2036
|
ui_spinner_stop
|
|
1930
|
-
|
|
2037
|
+
# Detect and store the repo's default branch
|
|
2038
|
+
local detected_branch
|
|
2039
|
+
detected_branch=$(git_default_branch "$full_path")
|
|
2040
|
+
if [[ -n "$detected_branch" ]]; then
|
|
2041
|
+
YAML_REPO_BRANCHES[$repo]="$detected_branch"
|
|
2042
|
+
fi
|
|
2043
|
+
ui_step_done "Cloned:" "$path (branch: ${detected_branch:-$YAML_DEFAULTS_BRANCH})"
|
|
1931
2044
|
success_count=$((success_count + 1))
|
|
1932
2045
|
else
|
|
1933
2046
|
ui_spinner_error "Failed to clone: $path"
|
|
@@ -1938,9 +2051,12 @@ cmd_clone() {
|
|
|
1938
2051
|
fi
|
|
1939
2052
|
done <<< "$repos"
|
|
1940
2053
|
|
|
1941
|
-
#
|
|
1942
|
-
# newly cloned repos appear in the context immediately.
|
|
2054
|
+
# Persist any newly detected per-repo branches and regenerate CLAUDE.md
|
|
2055
|
+
# so newly cloned repos appear in the context immediately.
|
|
1943
2056
|
if [[ $fail_count -eq 0 ]] && { [[ $success_count -gt 0 ]] || [[ $skip_count -gt 0 ]]; }; then
|
|
2057
|
+
if [[ $success_count -gt 0 ]]; then
|
|
2058
|
+
config_save
|
|
2059
|
+
fi
|
|
1944
2060
|
context_regenerate_silent
|
|
1945
2061
|
fi
|
|
1946
2062
|
|
|
@@ -2229,6 +2345,12 @@ cmd_checkout() {
|
|
|
2229
2345
|
continue
|
|
2230
2346
|
fi
|
|
2231
2347
|
|
|
2348
|
+
# Resolve "default" to each repo's own default branch
|
|
2349
|
+
local target="$branch_name"
|
|
2350
|
+
if [[ "$target" == "default" ]]; then
|
|
2351
|
+
target=$(config_repo_default_branch "$repo")
|
|
2352
|
+
fi
|
|
2353
|
+
|
|
2232
2354
|
# Check for uncommitted changes
|
|
2233
2355
|
if git_is_dirty "$full_path" && [[ $force -eq 0 ]]; then
|
|
2234
2356
|
ui_step_error "Uncommitted changes: $path"
|
|
@@ -2238,15 +2360,15 @@ cmd_checkout() {
|
|
|
2238
2360
|
fi
|
|
2239
2361
|
|
|
2240
2362
|
# Check if branch exists
|
|
2241
|
-
if ! git_branch_exists "$full_path" "$
|
|
2242
|
-
ui_step_error "Branch not found: $path"
|
|
2363
|
+
if ! git_branch_exists "$full_path" "$target"; then
|
|
2364
|
+
ui_step_error "Branch not found: $path ($target)"
|
|
2243
2365
|
fail_count=$((fail_count + 1))
|
|
2244
2366
|
continue
|
|
2245
2367
|
fi
|
|
2246
2368
|
|
|
2247
2369
|
# Checkout
|
|
2248
|
-
if git_checkout "$full_path" "$
|
|
2249
|
-
ui_step_done "Checked out:" "$path → $
|
|
2370
|
+
if git_checkout "$full_path" "$target"; then
|
|
2371
|
+
ui_step_done "Checked out:" "$path → $target"
|
|
2250
2372
|
success_count=$((success_count + 1))
|
|
2251
2373
|
else
|
|
2252
2374
|
ui_step_error "Failed: $path - $GIT_ERROR"
|
|
@@ -2860,6 +2982,17 @@ _context_write_file() {
|
|
|
2860
2982
|
url=$(yaml_get_url "$i")
|
|
2861
2983
|
full_path="$REVO_REPOS_DIR/$path"
|
|
2862
2984
|
|
|
2985
|
+
local branch
|
|
2986
|
+
branch=$(yaml_get_branch "$i")
|
|
2987
|
+
|
|
2988
|
+
# Backfill: detect default branch for cloned repos that don't have one stored
|
|
2989
|
+
if [[ -z "$branch" ]] && [[ -d "$full_path" ]]; then
|
|
2990
|
+
branch=$(git_default_branch "$full_path")
|
|
2991
|
+
if [[ -n "$branch" ]]; then
|
|
2992
|
+
YAML_REPO_BRANCHES[$i]="$branch"
|
|
2993
|
+
fi
|
|
2994
|
+
fi
|
|
2995
|
+
|
|
2863
2996
|
{
|
|
2864
2997
|
printf '### %s\n' "$path"
|
|
2865
2998
|
|
|
@@ -2867,6 +3000,9 @@ _context_write_file() {
|
|
|
2867
3000
|
printf -- '- **Tags:** %s\n' "$tags"
|
|
2868
3001
|
fi
|
|
2869
3002
|
printf -- '- **Path:** repos/%s\n' "$path"
|
|
3003
|
+
if [[ -n "$branch" ]] && [[ "$branch" != "$YAML_DEFAULTS_BRANCH" ]]; then
|
|
3004
|
+
printf -- '- **Default branch:** %s\n' "$branch"
|
|
3005
|
+
fi
|
|
2870
3006
|
if [[ -n "$deps" ]]; then
|
|
2871
3007
|
printf -- '- **Depends on:** %s\n' "$deps"
|
|
2872
3008
|
fi
|
|
@@ -2941,6 +3077,35 @@ _context_write_file() {
|
|
|
2941
3077
|
printf '\n> Warning: a dependency cycle was detected. Listed in best-effort order.\n' >> "$output"
|
|
2942
3078
|
fi
|
|
2943
3079
|
|
|
3080
|
+
# Active workspaces (any .revo/workspaces/*/ dirs)
|
|
3081
|
+
local workspaces_dir="$REVO_WORKSPACE_ROOT/.revo/workspaces"
|
|
3082
|
+
if [[ -d "$workspaces_dir" ]]; then
|
|
3083
|
+
local has_workspaces=0
|
|
3084
|
+
local ws
|
|
3085
|
+
for ws in "$workspaces_dir"/*/; do
|
|
3086
|
+
[[ -d "$ws" ]] || continue
|
|
3087
|
+
if [[ $has_workspaces -eq 0 ]]; then
|
|
3088
|
+
{
|
|
3089
|
+
printf '\n'
|
|
3090
|
+
printf '## Active Workspaces\n'
|
|
3091
|
+
printf '\n'
|
|
3092
|
+
} >> "$output"
|
|
3093
|
+
has_workspaces=1
|
|
3094
|
+
fi
|
|
3095
|
+
local ws_name
|
|
3096
|
+
ws_name=$(basename "$ws")
|
|
3097
|
+
printf -- '- **%s** — `.revo/workspaces/%s/` (branch: feature/%s)\n' \
|
|
3098
|
+
"$ws_name" "$ws_name" "$ws_name" >> "$output"
|
|
3099
|
+
done
|
|
3100
|
+
if [[ $has_workspaces -eq 1 ]]; then
|
|
3101
|
+
{
|
|
3102
|
+
printf '\n'
|
|
3103
|
+
printf 'Run `revo workspaces` for branch/dirty status, or `cd` into one\n'
|
|
3104
|
+
printf 'and run revo from there to operate on the workspace copy.\n'
|
|
3105
|
+
} >> "$output"
|
|
3106
|
+
fi
|
|
3107
|
+
fi
|
|
3108
|
+
|
|
2944
3109
|
# Active features (any .revo/features/*.md files)
|
|
2945
3110
|
local features_dir="$REVO_WORKSPACE_ROOT/.revo/features"
|
|
2946
3111
|
if [[ -d "$features_dir" ]]; then
|
|
@@ -2978,6 +3143,8 @@ _context_write_file() {
|
|
|
2978
3143
|
printf '6. Use `revo commit "msg"` to commit across all repos at once\n'
|
|
2979
3144
|
printf '7. Use `revo feature <name>` to start a coordinated feature workspace\n'
|
|
2980
3145
|
printf '8. Use `revo pr "title"` to open coordinated pull requests\n'
|
|
3146
|
+
printf '9. Use `revo workspace <name>` to get a full-copy isolated workspace\n'
|
|
3147
|
+
printf ' under `.revo/workspaces/<name>/` (zero bootstrap, .env included)\n'
|
|
2981
3148
|
printf '\n'
|
|
2982
3149
|
printf '## Workspace Tool: revo\n'
|
|
2983
3150
|
printf '\n'
|
|
@@ -3002,6 +3169,17 @@ _context_write_file() {
|
|
|
3002
3169
|
printf -- '- `revo exec "cmd" [--tag t]` — run command in filtered repos\n'
|
|
3003
3170
|
printf -- '- `revo checkout <branch> [--tag t]` — switch branch across repos\n'
|
|
3004
3171
|
printf '\n'
|
|
3172
|
+
printf '**Workspaces (full-copy isolated workspaces):**\n'
|
|
3173
|
+
printf -- '- `revo workspace <name> [--tag t]` — full copy of repos into `.revo/workspaces/<name>/` on `feature/<name>`\n'
|
|
3174
|
+
printf -- '- `revo workspaces` — list active workspaces with branch and dirty state\n'
|
|
3175
|
+
printf -- '- `revo workspace <name> --delete [--force]` — remove a workspace\n'
|
|
3176
|
+
printf -- '- `revo workspace --clean` — remove workspaces whose branches are merged\n'
|
|
3177
|
+
printf '\n'
|
|
3178
|
+
printf 'Workspaces hardlink-copy everything (including `.env`, `node_modules`,\n'
|
|
3179
|
+
printf 'build artifacts) so Claude can start work with zero bootstrap. Run\n'
|
|
3180
|
+
printf '`revo` from inside `.revo/workspaces/<name>/` and it operates on the\n'
|
|
3181
|
+
printf 'workspace copies, not the source tree.\n'
|
|
3182
|
+
printf '\n'
|
|
3005
3183
|
printf '**Issues (cross-repo, via gh CLI):**\n'
|
|
3006
3184
|
printf -- '- `revo issue list [--tag t] [--state open|closed|all] [--label L] [--json]` — list issues across repos\n'
|
|
3007
3185
|
printf -- '- `revo issue create --repo <name> "title" [--body b] [--label L] [--feature F]` — create in one repo\n'
|
|
@@ -3070,6 +3248,8 @@ cmd_context() {
|
|
|
3070
3248
|
|
|
3071
3249
|
ui_spinner_start "Scanning $YAML_REPO_COUNT repositories..."
|
|
3072
3250
|
_context_write_file "$output"
|
|
3251
|
+
# Persist any newly detected per-repo branches back to revo.yaml
|
|
3252
|
+
config_save
|
|
3073
3253
|
ui_spinner_stop
|
|
3074
3254
|
ui_step_done "Scanned:" "$YAML_REPO_COUNT repositories"
|
|
3075
3255
|
ui_step_done "Wrote:" "CLAUDE.md"
|
|
@@ -3205,10 +3385,10 @@ cmd_feature() {
|
|
|
3205
3385
|
printf '# Feature: %s\n' "$name"
|
|
3206
3386
|
printf '\n'
|
|
3207
3387
|
printf '## Status\n'
|
|
3208
|
-
printf '- Created: %s\n' "$timestamp"
|
|
3209
|
-
printf '- Branch: %s\n' "$branch"
|
|
3388
|
+
printf -- '- Created: %s\n' "$timestamp"
|
|
3389
|
+
printf -- '- Branch: %s\n' "$branch"
|
|
3210
3390
|
if [[ -n "$tag" ]]; then
|
|
3211
|
-
printf '- Tag filter: %s\n' "$tag"
|
|
3391
|
+
printf -- '- Tag filter: %s\n' "$tag"
|
|
3212
3392
|
fi
|
|
3213
3393
|
printf '\n'
|
|
3214
3394
|
printf '## Repos\n'
|
|
@@ -4094,6 +4274,648 @@ _issue_create() {
|
|
|
4094
4274
|
fi
|
|
4095
4275
|
}
|
|
4096
4276
|
|
|
4277
|
+
# === lib/commands/workspace.sh ===
|
|
4278
|
+
# Revo CLI - workspace / workspaces commands
|
|
4279
|
+
# Full-copy workspaces under .revo/workspaces/<name>/. Unlike git worktrees
|
|
4280
|
+
# these copy *everything* — .env, node_modules, build artifacts — so Claude
|
|
4281
|
+
# can start work immediately with zero bootstrap. Hardlinks are used where
|
|
4282
|
+
# possible so the cost is near-zero on APFS/HFS+ and Linux.
|
|
4283
|
+
|
|
4284
|
+
# --- helpers ---
|
|
4285
|
+
|
|
4286
|
+
# Sanitize a workspace name: lowercase, hyphens for spaces, drop anything
|
|
4287
|
+
# that isn't [a-z0-9_-]. Keeps the resulting name safe to use as both a
|
|
4288
|
+
# directory name and a git branch suffix.
|
|
4289
|
+
_workspace_sanitize_name() {
|
|
4290
|
+
local raw="$1"
|
|
4291
|
+
local lowered
|
|
4292
|
+
lowered=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
|
|
4293
|
+
lowered=$(printf '%s' "$lowered" | tr ' ' '-')
|
|
4294
|
+
# Replace anything not a-z0-9-_ with -
|
|
4295
|
+
lowered=$(printf '%s' "$lowered" | sed 's/[^a-z0-9_-]/-/g')
|
|
4296
|
+
# Collapse runs of - and trim leading/trailing -
|
|
4297
|
+
lowered=$(printf '%s' "$lowered" | sed -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')
|
|
4298
|
+
printf '%s' "$lowered"
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
# Verify .revo/ is in the workspace .gitignore. Workspaces hardlink-copy
|
|
4302
|
+
# everything, including .env files, so this is a hard requirement to avoid
|
|
4303
|
+
# accidentally committing secrets via the parent git repo (if any).
|
|
4304
|
+
# Returns 0 if safe, 1 if .revo/ is not gitignored.
|
|
4305
|
+
_workspace_verify_gitignore_safe() {
|
|
4306
|
+
local gitignore="$REVO_WORKSPACE_ROOT/.gitignore"
|
|
4307
|
+
|
|
4308
|
+
# If the workspace root isn't itself a git repo, there's nothing to
|
|
4309
|
+
# accidentally commit into. Treat as safe.
|
|
4310
|
+
if [[ ! -d "$REVO_WORKSPACE_ROOT/.git" ]]; then
|
|
4311
|
+
return 0
|
|
4312
|
+
fi
|
|
4313
|
+
|
|
4314
|
+
if [[ ! -f "$gitignore" ]]; then
|
|
4315
|
+
return 1
|
|
4316
|
+
fi
|
|
4317
|
+
|
|
4318
|
+
# Match `.revo/`, `.revo`, `/.revo/`, `/.revo` — any of those works
|
|
4319
|
+
awk '
|
|
4320
|
+
{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") }
|
|
4321
|
+
$0 == ".revo" || $0 == ".revo/" || $0 == "/.revo" || $0 == "/.revo/" { found = 1; exit }
|
|
4322
|
+
END { exit !found }
|
|
4323
|
+
' "$gitignore"
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
# Copy a source repo into the workspace. Tries hardlinks first
|
|
4327
|
+
# (`cp -RLl`), falls back to a regular recursive copy. Always follows
|
|
4328
|
+
# symlinks so init-style symlinked repos are materialized as real
|
|
4329
|
+
# directories inside the workspace.
|
|
4330
|
+
# Usage: _workspace_copy_repo "src" "dest"
|
|
4331
|
+
# Returns: 0 on success
|
|
4332
|
+
_workspace_copy_repo() {
|
|
4333
|
+
local src="$1"
|
|
4334
|
+
local dest="$2"
|
|
4335
|
+
|
|
4336
|
+
# Make sure parent exists, dest does not
|
|
4337
|
+
mkdir -p "$(dirname "$dest")"
|
|
4338
|
+
rm -rf "$dest" 2>/dev/null
|
|
4339
|
+
|
|
4340
|
+
# Try hardlinks first. -R recursive, -L follow symlinks, -l hardlink.
|
|
4341
|
+
if cp -RLl "$src" "$dest" 2>/dev/null; then
|
|
4342
|
+
return 0
|
|
4343
|
+
fi
|
|
4344
|
+
|
|
4345
|
+
# Fall back to a real copy
|
|
4346
|
+
rm -rf "$dest" 2>/dev/null
|
|
4347
|
+
if cp -RL "$src" "$dest" 2>/dev/null; then
|
|
4348
|
+
return 0
|
|
4349
|
+
fi
|
|
4350
|
+
|
|
4351
|
+
return 1
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
# Print mtime in seconds-since-epoch for a path. Handles BSD (macOS) and
|
|
4355
|
+
# GNU (Linux) stat. Prints 0 if both fail.
|
|
4356
|
+
_workspace_mtime() {
|
|
4357
|
+
local path="$1"
|
|
4358
|
+
local mtime
|
|
4359
|
+
if mtime=$(stat -f %m "$path" 2>/dev/null); then
|
|
4360
|
+
printf '%s' "$mtime"
|
|
4361
|
+
return 0
|
|
4362
|
+
fi
|
|
4363
|
+
if mtime=$(stat -c %Y "$path" 2>/dev/null); then
|
|
4364
|
+
printf '%s' "$mtime"
|
|
4365
|
+
return 0
|
|
4366
|
+
fi
|
|
4367
|
+
printf '0'
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
# Format an age in seconds as a short, human string ("2h", "3d", ...).
|
|
4371
|
+
_workspace_format_age() {
|
|
4372
|
+
local secs="$1"
|
|
4373
|
+
if [[ -z "$secs" ]] || [[ "$secs" -le 0 ]]; then
|
|
4374
|
+
printf 'just now'
|
|
4375
|
+
return
|
|
4376
|
+
fi
|
|
4377
|
+
if [[ "$secs" -lt 60 ]]; then
|
|
4378
|
+
printf '%ds' "$secs"
|
|
4379
|
+
elif [[ "$secs" -lt 3600 ]]; then
|
|
4380
|
+
printf '%dm' $((secs / 60))
|
|
4381
|
+
elif [[ "$secs" -lt 86400 ]]; then
|
|
4382
|
+
printf '%dh' $((secs / 3600))
|
|
4383
|
+
else
|
|
4384
|
+
printf '%dd' $((secs / 86400))
|
|
4385
|
+
fi
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
# Iterate the immediate subdirectories of a workspace dir that look like
|
|
4389
|
+
# git repos. Prints one path per line.
|
|
4390
|
+
# Usage: _workspace_repo_dirs "/abs/.revo/workspaces/foo"
|
|
4391
|
+
_workspace_repo_dirs() {
|
|
4392
|
+
local ws_dir="$1"
|
|
4393
|
+
local d
|
|
4394
|
+
for d in "$ws_dir"/*/; do
|
|
4395
|
+
[[ -d "$d" ]] || continue
|
|
4396
|
+
d="${d%/}"
|
|
4397
|
+
if [[ -d "$d/.git" ]] || [[ -f "$d/.git" ]]; then
|
|
4398
|
+
printf '%s\n' "$d"
|
|
4399
|
+
fi
|
|
4400
|
+
done
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
# Returns 0 if the workspace has any repo with unpushed commits or
|
|
4404
|
+
# uncommitted changes. Used by --delete to require --force.
|
|
4405
|
+
_workspace_has_local_work() {
|
|
4406
|
+
local ws_dir="$1"
|
|
4407
|
+
local d
|
|
4408
|
+
while IFS= read -r d; do
|
|
4409
|
+
[[ -z "$d" ]] && continue
|
|
4410
|
+
# Dirty working tree?
|
|
4411
|
+
if [[ -n "$(git -C "$d" status --porcelain 2>/dev/null)" ]]; then
|
|
4412
|
+
return 0
|
|
4413
|
+
fi
|
|
4414
|
+
# Any commits not present on any remote ref?
|
|
4415
|
+
local unpushed
|
|
4416
|
+
unpushed=$(git -C "$d" log --oneline --not --remotes 2>/dev/null | head -n 1)
|
|
4417
|
+
if [[ -n "$unpushed" ]]; then
|
|
4418
|
+
return 0
|
|
4419
|
+
fi
|
|
4420
|
+
done <<< "$(_workspace_repo_dirs "$ws_dir")"
|
|
4421
|
+
return 1
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
# Returns 0 if every repo in the workspace has its current branch merged
|
|
4425
|
+
# into the workspace default branch (local or origin/<default>).
|
|
4426
|
+
# Workspaces with no repo dirs are not considered merged.
|
|
4427
|
+
_workspace_all_merged() {
|
|
4428
|
+
local ws_dir="$1"
|
|
4429
|
+
local default_branch="$2"
|
|
4430
|
+
local any=0
|
|
4431
|
+
local d
|
|
4432
|
+
while IFS= read -r d; do
|
|
4433
|
+
[[ -z "$d" ]] && continue
|
|
4434
|
+
any=1
|
|
4435
|
+
# Try local default branch first, then origin/<default>
|
|
4436
|
+
if git -C "$d" merge-base --is-ancestor HEAD "$default_branch" 2>/dev/null; then
|
|
4437
|
+
continue
|
|
4438
|
+
fi
|
|
4439
|
+
if git -C "$d" merge-base --is-ancestor HEAD "origin/$default_branch" 2>/dev/null; then
|
|
4440
|
+
continue
|
|
4441
|
+
fi
|
|
4442
|
+
return 1
|
|
4443
|
+
done <<< "$(_workspace_repo_dirs "$ws_dir")"
|
|
4444
|
+
[[ $any -eq 1 ]]
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
# Generate a workspace-specific CLAUDE.md inside the workspace dir. Kept
|
|
4448
|
+
# intentionally short — the root workspace CLAUDE.md is the canonical
|
|
4449
|
+
# reference; this one just orients the agent inside the isolated copy.
|
|
4450
|
+
_workspace_write_claude_md() {
|
|
4451
|
+
local name="$1"
|
|
4452
|
+
local ws_dir="$2"
|
|
4453
|
+
local branch="$3"
|
|
4454
|
+
local target="$ws_dir/CLAUDE.md"
|
|
4455
|
+
local timestamp
|
|
4456
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
4457
|
+
local parent_name
|
|
4458
|
+
parent_name=$(config_workspace_name)
|
|
4459
|
+
|
|
4460
|
+
{
|
|
4461
|
+
printf '# revo workspace: %s\n' "$name"
|
|
4462
|
+
printf '\n'
|
|
4463
|
+
printf '> WARNING: This workspace contains .env files and other secrets\n'
|
|
4464
|
+
printf '> hardlinked from the source repos. Never commit the .revo/\n'
|
|
4465
|
+
printf '> directory.\n'
|
|
4466
|
+
printf '\n'
|
|
4467
|
+
printf -- '- **Branch:** %s\n' "$branch"
|
|
4468
|
+
printf -- '- **Created:** %s\n' "$timestamp"
|
|
4469
|
+
if [[ -n "$parent_name" ]]; then
|
|
4470
|
+
printf -- '- **Parent workspace:** %s\n' "$parent_name"
|
|
4471
|
+
fi
|
|
4472
|
+
printf -- '- **Source:** ../../repos/\n'
|
|
4473
|
+
printf '\n'
|
|
4474
|
+
printf 'This is an isolated full-copy workspace. All edits, commits,\n'
|
|
4475
|
+
printf 'and pushes happen here — the source tree under `repos/` is\n'
|
|
4476
|
+
printf 'untouched. When you run `revo` from inside this directory it\n'
|
|
4477
|
+
printf 'automatically operates on the repos under this workspace, not\n'
|
|
4478
|
+
printf 'the source ones.\n'
|
|
4479
|
+
printf '\n'
|
|
4480
|
+
printf '## Repos\n'
|
|
4481
|
+
printf '\n'
|
|
4482
|
+
|
|
4483
|
+
local d
|
|
4484
|
+
while IFS= read -r d; do
|
|
4485
|
+
[[ -z "$d" ]] && continue
|
|
4486
|
+
local rname rbranch
|
|
4487
|
+
rname=$(basename "$d")
|
|
4488
|
+
rbranch=$(git -C "$d" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
|
|
4489
|
+
printf '### %s\n' "$rname"
|
|
4490
|
+
printf -- '- **Path:** ./%s\n' "$rname"
|
|
4491
|
+
printf -- '- **Branch:** %s\n' "$rbranch"
|
|
4492
|
+
|
|
4493
|
+
scan_repo "$d"
|
|
4494
|
+
if [[ -n "$SCAN_LANG" ]]; then
|
|
4495
|
+
if [[ -n "$SCAN_NAME" ]]; then
|
|
4496
|
+
printf -- '- **Package:** %s (%s)\n' "$SCAN_NAME" "$SCAN_LANG"
|
|
4497
|
+
else
|
|
4498
|
+
printf -- '- **Language:** %s\n' "$SCAN_LANG"
|
|
4499
|
+
fi
|
|
4500
|
+
fi
|
|
4501
|
+
if [[ -n "$SCAN_FRAMEWORK" ]]; then
|
|
4502
|
+
printf -- '- **Framework:** %s\n' "$SCAN_FRAMEWORK"
|
|
4503
|
+
fi
|
|
4504
|
+
if [[ -n "$SCAN_ROUTES" ]]; then
|
|
4505
|
+
printf -- '- **API routes:** %s\n' "$SCAN_ROUTES"
|
|
4506
|
+
fi
|
|
4507
|
+
printf '\n'
|
|
4508
|
+
done <<< "$(_workspace_repo_dirs "$ws_dir")"
|
|
4509
|
+
|
|
4510
|
+
printf '## Workflow\n'
|
|
4511
|
+
printf '\n'
|
|
4512
|
+
printf '1. Edit code across the repos above\n'
|
|
4513
|
+
printf '2. `revo commit "msg"` to commit dirty repos in one shot\n'
|
|
4514
|
+
printf '3. `revo push` to push branches\n'
|
|
4515
|
+
printf '4. `revo pr "title"` to open coordinated PRs\n'
|
|
4516
|
+
printf '5. After merge, `cd ../../.. && revo workspace %s --delete`\n' "$name"
|
|
4517
|
+
printf '\n'
|
|
4518
|
+
printf '## Workspace Tool: revo\n'
|
|
4519
|
+
printf '\n'
|
|
4520
|
+
printf 'See ../../CLAUDE.md (the parent workspace context) for the full\n'
|
|
4521
|
+
printf 'revo command reference, dependency order, and per-repo details.\n'
|
|
4522
|
+
} > "$target"
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
# --- create ---
|
|
4526
|
+
|
|
4527
|
+
_workspace_create() {
|
|
4528
|
+
local name="$1"
|
|
4529
|
+
local tag="$2"
|
|
4530
|
+
local force="$3"
|
|
4531
|
+
|
|
4532
|
+
local sanitized
|
|
4533
|
+
sanitized=$(_workspace_sanitize_name "$name")
|
|
4534
|
+
if [[ -z "$sanitized" ]]; then
|
|
4535
|
+
ui_step_error "Invalid workspace name: $name"
|
|
4536
|
+
return 1
|
|
4537
|
+
fi
|
|
4538
|
+
|
|
4539
|
+
if [[ "$sanitized" != "$name" ]]; then
|
|
4540
|
+
ui_info "$(ui_dim "Sanitized name: $name -> $sanitized")"
|
|
4541
|
+
fi
|
|
4542
|
+
name="$sanitized"
|
|
4543
|
+
|
|
4544
|
+
local branch="feature/$name"
|
|
4545
|
+
local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
|
|
4546
|
+
local ws_dir="$ws_root/$name"
|
|
4547
|
+
|
|
4548
|
+
ui_intro "Revo - Create Workspace: $name"
|
|
4549
|
+
|
|
4550
|
+
if [[ -d "$ws_dir" ]]; then
|
|
4551
|
+
ui_step_error "Workspace already exists: .revo/workspaces/$name"
|
|
4552
|
+
ui_info "$(ui_dim "Use 'revo workspace $name --delete' to remove it first")"
|
|
4553
|
+
ui_outro_cancel "Aborted"
|
|
4554
|
+
return 1
|
|
4555
|
+
fi
|
|
4556
|
+
|
|
4557
|
+
if ! _workspace_verify_gitignore_safe; then
|
|
4558
|
+
ui_step_error ".revo/ is not in .gitignore at the workspace root"
|
|
4559
|
+
ui_info "$(ui_dim "Workspaces hardlink-copy .env files and secrets — committing")"
|
|
4560
|
+
ui_info "$(ui_dim ".revo/ would leak them. Add '.revo/' to .gitignore (or run 'revo init').")"
|
|
4561
|
+
if [[ $force -ne 1 ]]; then
|
|
4562
|
+
ui_info "$(ui_dim "Re-run with --force to override.")"
|
|
4563
|
+
ui_outro_cancel "Aborted for safety"
|
|
4564
|
+
return 1
|
|
4565
|
+
fi
|
|
4566
|
+
ui_step_error "Continuing anyway because --force was passed"
|
|
4567
|
+
fi
|
|
4568
|
+
|
|
4569
|
+
local source_repos_dir
|
|
4570
|
+
source_repos_dir=$(config_source_repos_dir)
|
|
4571
|
+
|
|
4572
|
+
local repos
|
|
4573
|
+
repos=$(config_get_repos "$tag")
|
|
4574
|
+
|
|
4575
|
+
if [[ -z "$repos" ]]; then
|
|
4576
|
+
if [[ -n "$tag" ]]; then
|
|
4577
|
+
ui_step_error "No repositories match tag: $tag"
|
|
4578
|
+
else
|
|
4579
|
+
ui_step_error "No repositories configured"
|
|
4580
|
+
fi
|
|
4581
|
+
ui_outro_cancel "Nothing to copy"
|
|
4582
|
+
return 1
|
|
4583
|
+
fi
|
|
4584
|
+
|
|
4585
|
+
mkdir -p "$ws_dir"
|
|
4586
|
+
|
|
4587
|
+
local success_count=0
|
|
4588
|
+
local skip_count=0
|
|
4589
|
+
local fail_count=0
|
|
4590
|
+
|
|
4591
|
+
while IFS= read -r repo; do
|
|
4592
|
+
[[ -z "$repo" ]] && continue
|
|
4593
|
+
|
|
4594
|
+
local path
|
|
4595
|
+
path=$(yaml_get_path "$repo")
|
|
4596
|
+
local src="$source_repos_dir/$path"
|
|
4597
|
+
local dest="$ws_dir/$path"
|
|
4598
|
+
|
|
4599
|
+
if [[ ! -d "$src" ]]; then
|
|
4600
|
+
ui_step_done "Skipped (not cloned):" "$path"
|
|
4601
|
+
skip_count=$((skip_count + 1))
|
|
4602
|
+
continue
|
|
4603
|
+
fi
|
|
4604
|
+
|
|
4605
|
+
ui_spinner_start "Copying $path..."
|
|
4606
|
+
if _workspace_copy_repo "$src" "$dest"; then
|
|
4607
|
+
ui_spinner_stop
|
|
4608
|
+
ui_step_done "Copied:" "$path"
|
|
4609
|
+
else
|
|
4610
|
+
ui_spinner_error "Failed to copy: $path"
|
|
4611
|
+
fail_count=$((fail_count + 1))
|
|
4612
|
+
continue
|
|
4613
|
+
fi
|
|
4614
|
+
|
|
4615
|
+
# Check out the workspace branch in the copy
|
|
4616
|
+
if git -C "$dest" rev-parse --verify "$branch" >/dev/null 2>&1; then
|
|
4617
|
+
if git -C "$dest" checkout "$branch" >/dev/null 2>&1; then
|
|
4618
|
+
ui_step_done "Checked out existing:" "$path -> $branch"
|
|
4619
|
+
else
|
|
4620
|
+
ui_step_error "Failed to checkout existing branch in: $path"
|
|
4621
|
+
fail_count=$((fail_count + 1))
|
|
4622
|
+
continue
|
|
4623
|
+
fi
|
|
4624
|
+
else
|
|
4625
|
+
if git -C "$dest" checkout -b "$branch" >/dev/null 2>&1; then
|
|
4626
|
+
ui_step_done "Branched:" "$path -> $branch"
|
|
4627
|
+
else
|
|
4628
|
+
ui_step_error "Failed to create branch in: $path"
|
|
4629
|
+
fail_count=$((fail_count + 1))
|
|
4630
|
+
continue
|
|
4631
|
+
fi
|
|
4632
|
+
fi
|
|
4633
|
+
|
|
4634
|
+
success_count=$((success_count + 1))
|
|
4635
|
+
done <<< "$repos"
|
|
4636
|
+
|
|
4637
|
+
if [[ $success_count -eq 0 ]]; then
|
|
4638
|
+
ui_outro_cancel "No repos copied; workspace cleanup needed"
|
|
4639
|
+
rm -rf "$ws_dir" 2>/dev/null
|
|
4640
|
+
return 1
|
|
4641
|
+
fi
|
|
4642
|
+
|
|
4643
|
+
# Workspace-level CLAUDE.md
|
|
4644
|
+
_workspace_write_claude_md "$name" "$ws_dir" "$branch"
|
|
4645
|
+
ui_step_done "Wrote:" "$ws_dir/CLAUDE.md"
|
|
4646
|
+
|
|
4647
|
+
ui_bar_line
|
|
4648
|
+
|
|
4649
|
+
if [[ $fail_count -gt 0 ]]; then
|
|
4650
|
+
ui_outro_cancel "Created with errors: $success_count ok, $fail_count failed"
|
|
4651
|
+
return 1
|
|
4652
|
+
fi
|
|
4653
|
+
|
|
4654
|
+
local msg="Workspace ready at .revo/workspaces/$name/"
|
|
4655
|
+
ui_info "$(ui_dim "Branch: $branch • Repos: $success_count")"
|
|
4656
|
+
if [[ $skip_count -gt 0 ]]; then
|
|
4657
|
+
ui_info "$(ui_dim "$skip_count repo(s) skipped (not cloned in source)")"
|
|
4658
|
+
fi
|
|
4659
|
+
ui_info "$(ui_dim "cd .revo/workspaces/$name and run revo from there.")"
|
|
4660
|
+
ui_outro "$msg"
|
|
4661
|
+
return 0
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4664
|
+
# --- delete ---
|
|
4665
|
+
|
|
4666
|
+
_workspace_delete() {
|
|
4667
|
+
local name="$1"
|
|
4668
|
+
local force="$2"
|
|
4669
|
+
|
|
4670
|
+
local sanitized
|
|
4671
|
+
sanitized=$(_workspace_sanitize_name "$name")
|
|
4672
|
+
name="$sanitized"
|
|
4673
|
+
|
|
4674
|
+
local ws_dir="$REVO_WORKSPACE_ROOT/.revo/workspaces/$name"
|
|
4675
|
+
|
|
4676
|
+
ui_intro "Revo - Delete Workspace: $name"
|
|
4677
|
+
|
|
4678
|
+
if [[ ! -d "$ws_dir" ]]; then
|
|
4679
|
+
ui_step_error "No such workspace: $name"
|
|
4680
|
+
ui_outro_cancel "Nothing to delete"
|
|
4681
|
+
return 1
|
|
4682
|
+
fi
|
|
4683
|
+
|
|
4684
|
+
if [[ $force -ne 1 ]] && _workspace_has_local_work "$ws_dir"; then
|
|
4685
|
+
ui_step_error "Workspace has unpushed commits or uncommitted changes"
|
|
4686
|
+
ui_info "$(ui_dim "Push or stash your work first, or re-run with --force to discard it.")"
|
|
4687
|
+
ui_outro_cancel "Aborted"
|
|
4688
|
+
return 1
|
|
4689
|
+
fi
|
|
4690
|
+
|
|
4691
|
+
rm -rf "$ws_dir"
|
|
4692
|
+
|
|
4693
|
+
ui_step_done "Deleted:" ".revo/workspaces/$name"
|
|
4694
|
+
ui_outro "Workspace removed"
|
|
4695
|
+
return 0
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
# --- clean ---
|
|
4699
|
+
|
|
4700
|
+
_workspace_clean() {
|
|
4701
|
+
local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
|
|
4702
|
+
|
|
4703
|
+
ui_intro "Revo - Clean Merged Workspaces"
|
|
4704
|
+
|
|
4705
|
+
if [[ ! -d "$ws_root" ]]; then
|
|
4706
|
+
ui_info "No workspaces"
|
|
4707
|
+
ui_outro "Nothing to clean"
|
|
4708
|
+
return 0
|
|
4709
|
+
fi
|
|
4710
|
+
|
|
4711
|
+
local default_branch
|
|
4712
|
+
default_branch=$(config_default_branch)
|
|
4713
|
+
[[ -z "$default_branch" ]] && default_branch="main"
|
|
4714
|
+
|
|
4715
|
+
local cleaned=0
|
|
4716
|
+
local kept=0
|
|
4717
|
+
local d
|
|
4718
|
+
for d in "$ws_root"/*/; do
|
|
4719
|
+
[[ -d "$d" ]] || continue
|
|
4720
|
+
d="${d%/}"
|
|
4721
|
+
local name
|
|
4722
|
+
name=$(basename "$d")
|
|
4723
|
+
|
|
4724
|
+
if _workspace_all_merged "$d" "$default_branch"; then
|
|
4725
|
+
rm -rf "$d"
|
|
4726
|
+
ui_step_done "Removed (merged):" "$name"
|
|
4727
|
+
cleaned=$((cleaned + 1))
|
|
4728
|
+
else
|
|
4729
|
+
ui_step_done "Kept:" "$name"
|
|
4730
|
+
kept=$((kept + 1))
|
|
4731
|
+
fi
|
|
4732
|
+
done
|
|
4733
|
+
|
|
4734
|
+
ui_bar_line
|
|
4735
|
+
ui_outro "Cleaned $cleaned, kept $kept"
|
|
4736
|
+
return 0
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
# --- list (revo workspaces) ---
|
|
4740
|
+
|
|
4741
|
+
cmd_workspaces() {
|
|
4742
|
+
while [[ $# -gt 0 ]]; do
|
|
4743
|
+
case "$1" in
|
|
4744
|
+
--help|-h)
|
|
4745
|
+
printf 'Usage: revo workspaces\n\n'
|
|
4746
|
+
printf 'List all active workspaces with branch, age, and dirty state.\n'
|
|
4747
|
+
return 0
|
|
4748
|
+
;;
|
|
4749
|
+
*)
|
|
4750
|
+
ui_step_error "Unknown option: $1"
|
|
4751
|
+
return 1
|
|
4752
|
+
;;
|
|
4753
|
+
esac
|
|
4754
|
+
done
|
|
4755
|
+
|
|
4756
|
+
config_require_workspace || return 1
|
|
4757
|
+
|
|
4758
|
+
ui_intro "Revo - Workspaces"
|
|
4759
|
+
|
|
4760
|
+
local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
|
|
4761
|
+
if [[ ! -d "$ws_root" ]]; then
|
|
4762
|
+
ui_info "No workspaces"
|
|
4763
|
+
ui_bar_line
|
|
4764
|
+
ui_info "$(ui_dim "Create one with: revo workspace <name>")"
|
|
4765
|
+
ui_outro "Done"
|
|
4766
|
+
return 0
|
|
4767
|
+
fi
|
|
4768
|
+
|
|
4769
|
+
# Count first so we can short-circuit empty
|
|
4770
|
+
local total=0
|
|
4771
|
+
local d
|
|
4772
|
+
for d in "$ws_root"/*/; do
|
|
4773
|
+
[[ -d "$d" ]] || continue
|
|
4774
|
+
total=$((total + 1))
|
|
4775
|
+
done
|
|
4776
|
+
|
|
4777
|
+
if [[ $total -eq 0 ]]; then
|
|
4778
|
+
ui_info "No workspaces"
|
|
4779
|
+
ui_bar_line
|
|
4780
|
+
ui_info "$(ui_dim "Create one with: revo workspace <name>")"
|
|
4781
|
+
ui_outro "Done"
|
|
4782
|
+
return 0
|
|
4783
|
+
fi
|
|
4784
|
+
|
|
4785
|
+
ui_table_widths 24 24 8 7 12
|
|
4786
|
+
ui_table_header "Workspace" "Branch" "Age" "Repos" "Dirty"
|
|
4787
|
+
|
|
4788
|
+
local now
|
|
4789
|
+
now=$(date +%s)
|
|
4790
|
+
|
|
4791
|
+
for d in "$ws_root"/*/; do
|
|
4792
|
+
[[ -d "$d" ]] || continue
|
|
4793
|
+
d="${d%/}"
|
|
4794
|
+
local name
|
|
4795
|
+
name=$(basename "$d")
|
|
4796
|
+
|
|
4797
|
+
local mtime
|
|
4798
|
+
mtime=$(_workspace_mtime "$d")
|
|
4799
|
+
local age_secs=$((now - mtime))
|
|
4800
|
+
local age
|
|
4801
|
+
age=$(_workspace_format_age "$age_secs")
|
|
4802
|
+
|
|
4803
|
+
local repos_in_ws=0
|
|
4804
|
+
local dirty_count=0
|
|
4805
|
+
local branch=""
|
|
4806
|
+
local repo
|
|
4807
|
+
while IFS= read -r repo; do
|
|
4808
|
+
[[ -z "$repo" ]] && continue
|
|
4809
|
+
repos_in_ws=$((repos_in_ws + 1))
|
|
4810
|
+
if [[ -z "$branch" ]]; then
|
|
4811
|
+
branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
|
|
4812
|
+
fi
|
|
4813
|
+
if [[ -n "$(git -C "$repo" status --porcelain 2>/dev/null)" ]]; then
|
|
4814
|
+
dirty_count=$((dirty_count + 1))
|
|
4815
|
+
fi
|
|
4816
|
+
done <<< "$(_workspace_repo_dirs "$d")"
|
|
4817
|
+
|
|
4818
|
+
[[ -z "$branch" ]] && branch="$(ui_dim "-")"
|
|
4819
|
+
|
|
4820
|
+
local dirty_text
|
|
4821
|
+
if [[ $dirty_count -gt 0 ]]; then
|
|
4822
|
+
dirty_text="$(ui_yellow "$dirty_count dirty")"
|
|
4823
|
+
else
|
|
4824
|
+
dirty_text="$(ui_green "clean")"
|
|
4825
|
+
fi
|
|
4826
|
+
|
|
4827
|
+
ui_table_row "$name" "$branch" "$age" "$repos_in_ws" "$dirty_text"
|
|
4828
|
+
done
|
|
4829
|
+
|
|
4830
|
+
ui_bar_line
|
|
4831
|
+
ui_outro "$total workspace(s)"
|
|
4832
|
+
return 0
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
# --- entry point: revo workspace ---
|
|
4836
|
+
|
|
4837
|
+
cmd_workspace() {
|
|
4838
|
+
local name=""
|
|
4839
|
+
local tag=""
|
|
4840
|
+
local action="create"
|
|
4841
|
+
local force=0
|
|
4842
|
+
|
|
4843
|
+
while [[ $# -gt 0 ]]; do
|
|
4844
|
+
case "$1" in
|
|
4845
|
+
--tag)
|
|
4846
|
+
tag="$2"
|
|
4847
|
+
shift 2
|
|
4848
|
+
;;
|
|
4849
|
+
--delete)
|
|
4850
|
+
action="delete"
|
|
4851
|
+
shift
|
|
4852
|
+
;;
|
|
4853
|
+
--clean)
|
|
4854
|
+
action="clean"
|
|
4855
|
+
shift
|
|
4856
|
+
;;
|
|
4857
|
+
--force|-f)
|
|
4858
|
+
force=1
|
|
4859
|
+
shift
|
|
4860
|
+
;;
|
|
4861
|
+
--help|-h)
|
|
4862
|
+
cat << 'EOF'
|
|
4863
|
+
Usage:
|
|
4864
|
+
revo workspace <name> [--tag TAG] Create a workspace (full copy of repos)
|
|
4865
|
+
revo workspace <name> --delete [--force]
|
|
4866
|
+
Delete a workspace
|
|
4867
|
+
revo workspace --clean Remove workspaces whose branches are merged
|
|
4868
|
+
revo workspaces List active workspaces
|
|
4869
|
+
|
|
4870
|
+
Workspaces are full copies of all repos (or a tagged subset) under
|
|
4871
|
+
.revo/workspaces/<name>/. Each workspace gets its own feature/<name>
|
|
4872
|
+
branch. Hardlinks are used where possible so the cost is near-zero.
|
|
4873
|
+
|
|
4874
|
+
Run revo from inside .revo/workspaces/<name>/ and it automatically
|
|
4875
|
+
operates on the workspace's repos rather than the source tree.
|
|
4876
|
+
|
|
4877
|
+
EOF
|
|
4878
|
+
return 0
|
|
4879
|
+
;;
|
|
4880
|
+
-*)
|
|
4881
|
+
ui_step_error "Unknown option: $1"
|
|
4882
|
+
return 1
|
|
4883
|
+
;;
|
|
4884
|
+
*)
|
|
4885
|
+
if [[ -z "$name" ]]; then
|
|
4886
|
+
name="$1"
|
|
4887
|
+
else
|
|
4888
|
+
ui_step_error "Unexpected argument: $1"
|
|
4889
|
+
return 1
|
|
4890
|
+
fi
|
|
4891
|
+
shift
|
|
4892
|
+
;;
|
|
4893
|
+
esac
|
|
4894
|
+
done
|
|
4895
|
+
|
|
4896
|
+
config_require_workspace || return 1
|
|
4897
|
+
|
|
4898
|
+
case "$action" in
|
|
4899
|
+
clean)
|
|
4900
|
+
_workspace_clean
|
|
4901
|
+
;;
|
|
4902
|
+
delete)
|
|
4903
|
+
if [[ -z "$name" ]]; then
|
|
4904
|
+
ui_step_error "Usage: revo workspace <name> --delete [--force]"
|
|
4905
|
+
return 1
|
|
4906
|
+
fi
|
|
4907
|
+
_workspace_delete "$name" "$force"
|
|
4908
|
+
;;
|
|
4909
|
+
create)
|
|
4910
|
+
if [[ -z "$name" ]]; then
|
|
4911
|
+
ui_step_error "Usage: revo workspace <name> [--tag TAG]"
|
|
4912
|
+
return 1
|
|
4913
|
+
fi
|
|
4914
|
+
_workspace_create "$name" "$tag" "$force"
|
|
4915
|
+
;;
|
|
4916
|
+
esac
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4097
4919
|
# === Main ===
|
|
4098
4920
|
# --- Help ---
|
|
4099
4921
|
show_help() {
|
|
@@ -4122,6 +4944,10 @@ Claude-first commands:
|
|
|
4122
4944
|
pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
|
|
4123
4945
|
issue list [options] List issues across workspace repos
|
|
4124
4946
|
issue create [options] TITLE Create issue(s) in workspace repos
|
|
4947
|
+
workspace NAME [--tag TAG] Create a full-copy workspace under .revo/workspaces/
|
|
4948
|
+
workspace NAME --delete Delete a workspace (use --force to discard work)
|
|
4949
|
+
workspace --clean Remove workspaces whose branches are merged
|
|
4950
|
+
workspaces List active workspaces
|
|
4125
4951
|
|
|
4126
4952
|
Options:
|
|
4127
4953
|
--tag TAG Filter repositories by tag
|
|
@@ -4147,6 +4973,10 @@ Examples:
|
|
|
4147
4973
|
revo issue list --tag backend --json # JSON for Claude/jq
|
|
4148
4974
|
revo issue create --repo backend "Add stats endpoint"
|
|
4149
4975
|
revo issue create --tag mobile --feature stats "Add statistics screen"
|
|
4976
|
+
revo workspace clock-student # full copy of all repos
|
|
4977
|
+
revo workspace clock-student --tag backend # only backend repos
|
|
4978
|
+
revo workspaces # list active workspaces
|
|
4979
|
+
revo workspace clock-student --delete # remove when done
|
|
4150
4980
|
|
|
4151
4981
|
Documentation: https://github.com/jippylong12/revo
|
|
4152
4982
|
EOF
|
|
@@ -4218,6 +5048,12 @@ main() {
|
|
|
4218
5048
|
issue|issues)
|
|
4219
5049
|
cmd_issue "$@"
|
|
4220
5050
|
;;
|
|
5051
|
+
workspace)
|
|
5052
|
+
cmd_workspace "$@"
|
|
5053
|
+
;;
|
|
5054
|
+
workspaces)
|
|
5055
|
+
cmd_workspaces "$@"
|
|
5056
|
+
;;
|
|
4221
5057
|
--help|-h|help)
|
|
4222
5058
|
show_help
|
|
4223
5059
|
;;
|