@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.
Files changed (2) hide show
  1. package/dist/revo +855 -19
  2. 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.5.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
- yaml_add_repo "$remote" "$path" "$tags" ""
1698
- ui_step_done "Detected:" "$name → $remote"
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
- yaml_add_repo "$remote" "$name" "$tags" ""
1804
- ui_step_done "Found:" "$name ($remote)"
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
- yaml_add_repo "$remote" "$name" "$name" ""
1825
- ui_step_done "Found:" "$name ($remote)"
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
- ui_step_done "Cloned:" "$path"
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
- # Always regenerate the workspace CLAUDE.md after a clone batch so that
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" "$branch_name"; then
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" "$branch_name"; then
2249
- ui_step_done "Checked out:" "$path → $branch_name"
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
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revotools/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Claude-first multi-repo workspace manager (fork of Mars)",
5
5
  "bin": {
6
6
  "revo": "./dist/revo"