@revotools/cli 0.5.0 → 0.6.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.
Files changed (2) hide show
  1. package/dist/revo +738 -1
  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.0"
12
12
 
13
13
 
14
14
  # === lib/ui.sh ===
@@ -736,6 +736,12 @@ yaml_add_repo() {
736
736
  REVO_WORKSPACE_ROOT=""
737
737
  REVO_CONFIG_FILE=""
738
738
  REVO_REPOS_DIR=""
739
+ # When invoked from inside .revo/workspaces/<name>/, REVO_REPOS_DIR is
740
+ # overridden to point at that workspace dir so existing commands (status,
741
+ # commit, push, pr, exec, ...) operate on the workspace's copies of the
742
+ # repos rather than the source tree under repos/. REVO_ACTIVE_WORKSPACE
743
+ # holds the workspace name in that case (empty otherwise).
744
+ REVO_ACTIVE_WORKSPACE=""
739
745
 
740
746
  # Find workspace root by searching upward for revo.yaml (or mars.yaml as fallback)
741
747
  # Usage: config_find_root [start_dir]
@@ -749,6 +755,7 @@ config_find_root() {
749
755
  REVO_WORKSPACE_ROOT="$current"
750
756
  REVO_CONFIG_FILE="$current/revo.yaml"
751
757
  REVO_REPOS_DIR="$current/repos"
758
+ _config_apply_workspace_override "$start_dir"
752
759
  return 0
753
760
  fi
754
761
  # Fallback: support mars.yaml for migration from Mars
@@ -756,6 +763,7 @@ config_find_root() {
756
763
  REVO_WORKSPACE_ROOT="$current"
757
764
  REVO_CONFIG_FILE="$current/mars.yaml"
758
765
  REVO_REPOS_DIR="$current/repos"
766
+ _config_apply_workspace_override "$start_dir"
759
767
  return 0
760
768
  fi
761
769
  current="$(dirname "$current")"
@@ -764,6 +772,37 @@ config_find_root() {
764
772
  return 1
765
773
  }
766
774
 
775
+ # If start_dir is inside .revo/workspaces/<name>/, point REVO_REPOS_DIR at
776
+ # that workspace and remember the active workspace name. Otherwise leave
777
+ # things alone. Called from config_find_root after the root has been set.
778
+ _config_apply_workspace_override() {
779
+ local start_dir="$1"
780
+ REVO_ACTIVE_WORKSPACE=""
781
+
782
+ [[ -z "$REVO_WORKSPACE_ROOT" ]] && return 0
783
+
784
+ local prefix="$REVO_WORKSPACE_ROOT/.revo/workspaces/"
785
+ case "$start_dir/" in
786
+ "$prefix"*)
787
+ local rest="${start_dir#"$prefix"}"
788
+ local ws_name="${rest%%/*}"
789
+ if [[ -n "$ws_name" ]] && [[ -d "$prefix$ws_name" ]]; then
790
+ REVO_REPOS_DIR="$prefix$ws_name"
791
+ REVO_ACTIVE_WORKSPACE="$ws_name"
792
+ fi
793
+ ;;
794
+ esac
795
+ return 0
796
+ }
797
+
798
+ # Always returns the source repos dir ($REVO_WORKSPACE_ROOT/repos),
799
+ # regardless of any active workspace override. Used by `revo workspace`
800
+ # itself, which must read from the source tree even when invoked from
801
+ # inside another workspace.
802
+ config_source_repos_dir() {
803
+ printf '%s/repos' "$REVO_WORKSPACE_ROOT"
804
+ }
805
+
767
806
  # Initialize workspace in current directory
768
807
  # Usage: config_init "workspace_name"
769
808
  # Returns: 0 on success, 1 if already initialized
@@ -2941,6 +2980,35 @@ _context_write_file() {
2941
2980
  printf '\n> Warning: a dependency cycle was detected. Listed in best-effort order.\n' >> "$output"
2942
2981
  fi
2943
2982
 
2983
+ # Active workspaces (any .revo/workspaces/*/ dirs)
2984
+ local workspaces_dir="$REVO_WORKSPACE_ROOT/.revo/workspaces"
2985
+ if [[ -d "$workspaces_dir" ]]; then
2986
+ local has_workspaces=0
2987
+ local ws
2988
+ for ws in "$workspaces_dir"/*/; do
2989
+ [[ -d "$ws" ]] || continue
2990
+ if [[ $has_workspaces -eq 0 ]]; then
2991
+ {
2992
+ printf '\n'
2993
+ printf '## Active Workspaces\n'
2994
+ printf '\n'
2995
+ } >> "$output"
2996
+ has_workspaces=1
2997
+ fi
2998
+ local ws_name
2999
+ ws_name=$(basename "$ws")
3000
+ printf -- '- **%s** — `.revo/workspaces/%s/` (branch: feature/%s)\n' \
3001
+ "$ws_name" "$ws_name" "$ws_name" >> "$output"
3002
+ done
3003
+ if [[ $has_workspaces -eq 1 ]]; then
3004
+ {
3005
+ printf '\n'
3006
+ printf 'Run `revo workspaces` for branch/dirty status, or `cd` into one\n'
3007
+ printf 'and run revo from there to operate on the workspace copy.\n'
3008
+ } >> "$output"
3009
+ fi
3010
+ fi
3011
+
2944
3012
  # Active features (any .revo/features/*.md files)
2945
3013
  local features_dir="$REVO_WORKSPACE_ROOT/.revo/features"
2946
3014
  if [[ -d "$features_dir" ]]; then
@@ -2978,6 +3046,8 @@ _context_write_file() {
2978
3046
  printf '6. Use `revo commit "msg"` to commit across all repos at once\n'
2979
3047
  printf '7. Use `revo feature <name>` to start a coordinated feature workspace\n'
2980
3048
  printf '8. Use `revo pr "title"` to open coordinated pull requests\n'
3049
+ printf '9. Use `revo workspace <name>` to get a full-copy isolated workspace\n'
3050
+ printf ' under `.revo/workspaces/<name>/` (zero bootstrap, .env included)\n'
2981
3051
  printf '\n'
2982
3052
  printf '## Workspace Tool: revo\n'
2983
3053
  printf '\n'
@@ -3002,6 +3072,17 @@ _context_write_file() {
3002
3072
  printf -- '- `revo exec "cmd" [--tag t]` — run command in filtered repos\n'
3003
3073
  printf -- '- `revo checkout <branch> [--tag t]` — switch branch across repos\n'
3004
3074
  printf '\n'
3075
+ printf '**Workspaces (full-copy isolated workspaces):**\n'
3076
+ printf -- '- `revo workspace <name> [--tag t]` — full copy of repos into `.revo/workspaces/<name>/` on `feature/<name>`\n'
3077
+ printf -- '- `revo workspaces` — list active workspaces with branch and dirty state\n'
3078
+ printf -- '- `revo workspace <name> --delete [--force]` — remove a workspace\n'
3079
+ printf -- '- `revo workspace --clean` — remove workspaces whose branches are merged\n'
3080
+ printf '\n'
3081
+ printf 'Workspaces hardlink-copy everything (including `.env`, `node_modules`,\n'
3082
+ printf 'build artifacts) so Claude can start work with zero bootstrap. Run\n'
3083
+ printf '`revo` from inside `.revo/workspaces/<name>/` and it operates on the\n'
3084
+ printf 'workspace copies, not the source tree.\n'
3085
+ printf '\n'
3005
3086
  printf '**Issues (cross-repo, via gh CLI):**\n'
3006
3087
  printf -- '- `revo issue list [--tag t] [--state open|closed|all] [--label L] [--json]` — list issues across repos\n'
3007
3088
  printf -- '- `revo issue create --repo <name> "title" [--body b] [--label L] [--feature F]` — create in one repo\n'
@@ -4094,6 +4175,648 @@ _issue_create() {
4094
4175
  fi
4095
4176
  }
4096
4177
 
4178
+ # === lib/commands/workspace.sh ===
4179
+ # Revo CLI - workspace / workspaces commands
4180
+ # Full-copy workspaces under .revo/workspaces/<name>/. Unlike git worktrees
4181
+ # these copy *everything* — .env, node_modules, build artifacts — so Claude
4182
+ # can start work immediately with zero bootstrap. Hardlinks are used where
4183
+ # possible so the cost is near-zero on APFS/HFS+ and Linux.
4184
+
4185
+ # --- helpers ---
4186
+
4187
+ # Sanitize a workspace name: lowercase, hyphens for spaces, drop anything
4188
+ # that isn't [a-z0-9_-]. Keeps the resulting name safe to use as both a
4189
+ # directory name and a git branch suffix.
4190
+ _workspace_sanitize_name() {
4191
+ local raw="$1"
4192
+ local lowered
4193
+ lowered=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
4194
+ lowered=$(printf '%s' "$lowered" | tr ' ' '-')
4195
+ # Replace anything not a-z0-9-_ with -
4196
+ lowered=$(printf '%s' "$lowered" | sed 's/[^a-z0-9_-]/-/g')
4197
+ # Collapse runs of - and trim leading/trailing -
4198
+ lowered=$(printf '%s' "$lowered" | sed -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')
4199
+ printf '%s' "$lowered"
4200
+ }
4201
+
4202
+ # Verify .revo/ is in the workspace .gitignore. Workspaces hardlink-copy
4203
+ # everything, including .env files, so this is a hard requirement to avoid
4204
+ # accidentally committing secrets via the parent git repo (if any).
4205
+ # Returns 0 if safe, 1 if .revo/ is not gitignored.
4206
+ _workspace_verify_gitignore_safe() {
4207
+ local gitignore="$REVO_WORKSPACE_ROOT/.gitignore"
4208
+
4209
+ # If the workspace root isn't itself a git repo, there's nothing to
4210
+ # accidentally commit into. Treat as safe.
4211
+ if [[ ! -d "$REVO_WORKSPACE_ROOT/.git" ]]; then
4212
+ return 0
4213
+ fi
4214
+
4215
+ if [[ ! -f "$gitignore" ]]; then
4216
+ return 1
4217
+ fi
4218
+
4219
+ # Match `.revo/`, `.revo`, `/.revo/`, `/.revo` — any of those works
4220
+ awk '
4221
+ { sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") }
4222
+ $0 == ".revo" || $0 == ".revo/" || $0 == "/.revo" || $0 == "/.revo/" { found = 1; exit }
4223
+ END { exit !found }
4224
+ ' "$gitignore"
4225
+ }
4226
+
4227
+ # Copy a source repo into the workspace. Tries hardlinks first
4228
+ # (`cp -RLl`), falls back to a regular recursive copy. Always follows
4229
+ # symlinks so init-style symlinked repos are materialized as real
4230
+ # directories inside the workspace.
4231
+ # Usage: _workspace_copy_repo "src" "dest"
4232
+ # Returns: 0 on success
4233
+ _workspace_copy_repo() {
4234
+ local src="$1"
4235
+ local dest="$2"
4236
+
4237
+ # Make sure parent exists, dest does not
4238
+ mkdir -p "$(dirname "$dest")"
4239
+ rm -rf "$dest" 2>/dev/null
4240
+
4241
+ # Try hardlinks first. -R recursive, -L follow symlinks, -l hardlink.
4242
+ if cp -RLl "$src" "$dest" 2>/dev/null; then
4243
+ return 0
4244
+ fi
4245
+
4246
+ # Fall back to a real copy
4247
+ rm -rf "$dest" 2>/dev/null
4248
+ if cp -RL "$src" "$dest" 2>/dev/null; then
4249
+ return 0
4250
+ fi
4251
+
4252
+ return 1
4253
+ }
4254
+
4255
+ # Print mtime in seconds-since-epoch for a path. Handles BSD (macOS) and
4256
+ # GNU (Linux) stat. Prints 0 if both fail.
4257
+ _workspace_mtime() {
4258
+ local path="$1"
4259
+ local mtime
4260
+ if mtime=$(stat -f %m "$path" 2>/dev/null); then
4261
+ printf '%s' "$mtime"
4262
+ return 0
4263
+ fi
4264
+ if mtime=$(stat -c %Y "$path" 2>/dev/null); then
4265
+ printf '%s' "$mtime"
4266
+ return 0
4267
+ fi
4268
+ printf '0'
4269
+ }
4270
+
4271
+ # Format an age in seconds as a short, human string ("2h", "3d", ...).
4272
+ _workspace_format_age() {
4273
+ local secs="$1"
4274
+ if [[ -z "$secs" ]] || [[ "$secs" -le 0 ]]; then
4275
+ printf 'just now'
4276
+ return
4277
+ fi
4278
+ if [[ "$secs" -lt 60 ]]; then
4279
+ printf '%ds' "$secs"
4280
+ elif [[ "$secs" -lt 3600 ]]; then
4281
+ printf '%dm' $((secs / 60))
4282
+ elif [[ "$secs" -lt 86400 ]]; then
4283
+ printf '%dh' $((secs / 3600))
4284
+ else
4285
+ printf '%dd' $((secs / 86400))
4286
+ fi
4287
+ }
4288
+
4289
+ # Iterate the immediate subdirectories of a workspace dir that look like
4290
+ # git repos. Prints one path per line.
4291
+ # Usage: _workspace_repo_dirs "/abs/.revo/workspaces/foo"
4292
+ _workspace_repo_dirs() {
4293
+ local ws_dir="$1"
4294
+ local d
4295
+ for d in "$ws_dir"/*/; do
4296
+ [[ -d "$d" ]] || continue
4297
+ d="${d%/}"
4298
+ if [[ -d "$d/.git" ]] || [[ -f "$d/.git" ]]; then
4299
+ printf '%s\n' "$d"
4300
+ fi
4301
+ done
4302
+ }
4303
+
4304
+ # Returns 0 if the workspace has any repo with unpushed commits or
4305
+ # uncommitted changes. Used by --delete to require --force.
4306
+ _workspace_has_local_work() {
4307
+ local ws_dir="$1"
4308
+ local d
4309
+ while IFS= read -r d; do
4310
+ [[ -z "$d" ]] && continue
4311
+ # Dirty working tree?
4312
+ if [[ -n "$(git -C "$d" status --porcelain 2>/dev/null)" ]]; then
4313
+ return 0
4314
+ fi
4315
+ # Any commits not present on any remote ref?
4316
+ local unpushed
4317
+ unpushed=$(git -C "$d" log --oneline --not --remotes 2>/dev/null | head -n 1)
4318
+ if [[ -n "$unpushed" ]]; then
4319
+ return 0
4320
+ fi
4321
+ done <<< "$(_workspace_repo_dirs "$ws_dir")"
4322
+ return 1
4323
+ }
4324
+
4325
+ # Returns 0 if every repo in the workspace has its current branch merged
4326
+ # into the workspace default branch (local or origin/<default>).
4327
+ # Workspaces with no repo dirs are not considered merged.
4328
+ _workspace_all_merged() {
4329
+ local ws_dir="$1"
4330
+ local default_branch="$2"
4331
+ local any=0
4332
+ local d
4333
+ while IFS= read -r d; do
4334
+ [[ -z "$d" ]] && continue
4335
+ any=1
4336
+ # Try local default branch first, then origin/<default>
4337
+ if git -C "$d" merge-base --is-ancestor HEAD "$default_branch" 2>/dev/null; then
4338
+ continue
4339
+ fi
4340
+ if git -C "$d" merge-base --is-ancestor HEAD "origin/$default_branch" 2>/dev/null; then
4341
+ continue
4342
+ fi
4343
+ return 1
4344
+ done <<< "$(_workspace_repo_dirs "$ws_dir")"
4345
+ [[ $any -eq 1 ]]
4346
+ }
4347
+
4348
+ # Generate a workspace-specific CLAUDE.md inside the workspace dir. Kept
4349
+ # intentionally short — the root workspace CLAUDE.md is the canonical
4350
+ # reference; this one just orients the agent inside the isolated copy.
4351
+ _workspace_write_claude_md() {
4352
+ local name="$1"
4353
+ local ws_dir="$2"
4354
+ local branch="$3"
4355
+ local target="$ws_dir/CLAUDE.md"
4356
+ local timestamp
4357
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
4358
+ local parent_name
4359
+ parent_name=$(config_workspace_name)
4360
+
4361
+ {
4362
+ printf '# revo workspace: %s\n' "$name"
4363
+ printf '\n'
4364
+ printf '> WARNING: This workspace contains .env files and other secrets\n'
4365
+ printf '> hardlinked from the source repos. Never commit the .revo/\n'
4366
+ printf '> directory.\n'
4367
+ printf '\n'
4368
+ printf -- '- **Branch:** %s\n' "$branch"
4369
+ printf -- '- **Created:** %s\n' "$timestamp"
4370
+ if [[ -n "$parent_name" ]]; then
4371
+ printf -- '- **Parent workspace:** %s\n' "$parent_name"
4372
+ fi
4373
+ printf -- '- **Source:** ../../repos/\n'
4374
+ printf '\n'
4375
+ printf 'This is an isolated full-copy workspace. All edits, commits,\n'
4376
+ printf 'and pushes happen here — the source tree under `repos/` is\n'
4377
+ printf 'untouched. When you run `revo` from inside this directory it\n'
4378
+ printf 'automatically operates on the repos under this workspace, not\n'
4379
+ printf 'the source ones.\n'
4380
+ printf '\n'
4381
+ printf '## Repos\n'
4382
+ printf '\n'
4383
+
4384
+ local d
4385
+ while IFS= read -r d; do
4386
+ [[ -z "$d" ]] && continue
4387
+ local rname rbranch
4388
+ rname=$(basename "$d")
4389
+ rbranch=$(git -C "$d" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
4390
+ printf '### %s\n' "$rname"
4391
+ printf -- '- **Path:** ./%s\n' "$rname"
4392
+ printf -- '- **Branch:** %s\n' "$rbranch"
4393
+
4394
+ scan_repo "$d"
4395
+ if [[ -n "$SCAN_LANG" ]]; then
4396
+ if [[ -n "$SCAN_NAME" ]]; then
4397
+ printf -- '- **Package:** %s (%s)\n' "$SCAN_NAME" "$SCAN_LANG"
4398
+ else
4399
+ printf -- '- **Language:** %s\n' "$SCAN_LANG"
4400
+ fi
4401
+ fi
4402
+ if [[ -n "$SCAN_FRAMEWORK" ]]; then
4403
+ printf -- '- **Framework:** %s\n' "$SCAN_FRAMEWORK"
4404
+ fi
4405
+ if [[ -n "$SCAN_ROUTES" ]]; then
4406
+ printf -- '- **API routes:** %s\n' "$SCAN_ROUTES"
4407
+ fi
4408
+ printf '\n'
4409
+ done <<< "$(_workspace_repo_dirs "$ws_dir")"
4410
+
4411
+ printf '## Workflow\n'
4412
+ printf '\n'
4413
+ printf '1. Edit code across the repos above\n'
4414
+ printf '2. `revo commit "msg"` to commit dirty repos in one shot\n'
4415
+ printf '3. `revo push` to push branches\n'
4416
+ printf '4. `revo pr "title"` to open coordinated PRs\n'
4417
+ printf '5. After merge, `cd ../../.. && revo workspace %s --delete`\n' "$name"
4418
+ printf '\n'
4419
+ printf '## Workspace Tool: revo\n'
4420
+ printf '\n'
4421
+ printf 'See ../../CLAUDE.md (the parent workspace context) for the full\n'
4422
+ printf 'revo command reference, dependency order, and per-repo details.\n'
4423
+ } > "$target"
4424
+ }
4425
+
4426
+ # --- create ---
4427
+
4428
+ _workspace_create() {
4429
+ local name="$1"
4430
+ local tag="$2"
4431
+ local force="$3"
4432
+
4433
+ local sanitized
4434
+ sanitized=$(_workspace_sanitize_name "$name")
4435
+ if [[ -z "$sanitized" ]]; then
4436
+ ui_step_error "Invalid workspace name: $name"
4437
+ return 1
4438
+ fi
4439
+
4440
+ if [[ "$sanitized" != "$name" ]]; then
4441
+ ui_info "$(ui_dim "Sanitized name: $name -> $sanitized")"
4442
+ fi
4443
+ name="$sanitized"
4444
+
4445
+ local branch="feature/$name"
4446
+ local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
4447
+ local ws_dir="$ws_root/$name"
4448
+
4449
+ ui_intro "Revo - Create Workspace: $name"
4450
+
4451
+ if [[ -d "$ws_dir" ]]; then
4452
+ ui_step_error "Workspace already exists: .revo/workspaces/$name"
4453
+ ui_info "$(ui_dim "Use 'revo workspace $name --delete' to remove it first")"
4454
+ ui_outro_cancel "Aborted"
4455
+ return 1
4456
+ fi
4457
+
4458
+ if ! _workspace_verify_gitignore_safe; then
4459
+ ui_step_error ".revo/ is not in .gitignore at the workspace root"
4460
+ ui_info "$(ui_dim "Workspaces hardlink-copy .env files and secrets — committing")"
4461
+ ui_info "$(ui_dim ".revo/ would leak them. Add '.revo/' to .gitignore (or run 'revo init').")"
4462
+ if [[ $force -ne 1 ]]; then
4463
+ ui_info "$(ui_dim "Re-run with --force to override.")"
4464
+ ui_outro_cancel "Aborted for safety"
4465
+ return 1
4466
+ fi
4467
+ ui_step_error "Continuing anyway because --force was passed"
4468
+ fi
4469
+
4470
+ local source_repos_dir
4471
+ source_repos_dir=$(config_source_repos_dir)
4472
+
4473
+ local repos
4474
+ repos=$(config_get_repos "$tag")
4475
+
4476
+ if [[ -z "$repos" ]]; then
4477
+ if [[ -n "$tag" ]]; then
4478
+ ui_step_error "No repositories match tag: $tag"
4479
+ else
4480
+ ui_step_error "No repositories configured"
4481
+ fi
4482
+ ui_outro_cancel "Nothing to copy"
4483
+ return 1
4484
+ fi
4485
+
4486
+ mkdir -p "$ws_dir"
4487
+
4488
+ local success_count=0
4489
+ local skip_count=0
4490
+ local fail_count=0
4491
+
4492
+ while IFS= read -r repo; do
4493
+ [[ -z "$repo" ]] && continue
4494
+
4495
+ local path
4496
+ path=$(yaml_get_path "$repo")
4497
+ local src="$source_repos_dir/$path"
4498
+ local dest="$ws_dir/$path"
4499
+
4500
+ if [[ ! -d "$src" ]]; then
4501
+ ui_step_done "Skipped (not cloned):" "$path"
4502
+ skip_count=$((skip_count + 1))
4503
+ continue
4504
+ fi
4505
+
4506
+ ui_spinner_start "Copying $path..."
4507
+ if _workspace_copy_repo "$src" "$dest"; then
4508
+ ui_spinner_stop
4509
+ ui_step_done "Copied:" "$path"
4510
+ else
4511
+ ui_spinner_error "Failed to copy: $path"
4512
+ fail_count=$((fail_count + 1))
4513
+ continue
4514
+ fi
4515
+
4516
+ # Check out the workspace branch in the copy
4517
+ if git -C "$dest" rev-parse --verify "$branch" >/dev/null 2>&1; then
4518
+ if git -C "$dest" checkout "$branch" >/dev/null 2>&1; then
4519
+ ui_step_done "Checked out existing:" "$path -> $branch"
4520
+ else
4521
+ ui_step_error "Failed to checkout existing branch in: $path"
4522
+ fail_count=$((fail_count + 1))
4523
+ continue
4524
+ fi
4525
+ else
4526
+ if git -C "$dest" checkout -b "$branch" >/dev/null 2>&1; then
4527
+ ui_step_done "Branched:" "$path -> $branch"
4528
+ else
4529
+ ui_step_error "Failed to create branch in: $path"
4530
+ fail_count=$((fail_count + 1))
4531
+ continue
4532
+ fi
4533
+ fi
4534
+
4535
+ success_count=$((success_count + 1))
4536
+ done <<< "$repos"
4537
+
4538
+ if [[ $success_count -eq 0 ]]; then
4539
+ ui_outro_cancel "No repos copied; workspace cleanup needed"
4540
+ rm -rf "$ws_dir" 2>/dev/null
4541
+ return 1
4542
+ fi
4543
+
4544
+ # Workspace-level CLAUDE.md
4545
+ _workspace_write_claude_md "$name" "$ws_dir" "$branch"
4546
+ ui_step_done "Wrote:" "$ws_dir/CLAUDE.md"
4547
+
4548
+ ui_bar_line
4549
+
4550
+ if [[ $fail_count -gt 0 ]]; then
4551
+ ui_outro_cancel "Created with errors: $success_count ok, $fail_count failed"
4552
+ return 1
4553
+ fi
4554
+
4555
+ local msg="Workspace ready at .revo/workspaces/$name/"
4556
+ ui_info "$(ui_dim "Branch: $branch • Repos: $success_count")"
4557
+ if [[ $skip_count -gt 0 ]]; then
4558
+ ui_info "$(ui_dim "$skip_count repo(s) skipped (not cloned in source)")"
4559
+ fi
4560
+ ui_info "$(ui_dim "cd .revo/workspaces/$name and run revo from there.")"
4561
+ ui_outro "$msg"
4562
+ return 0
4563
+ }
4564
+
4565
+ # --- delete ---
4566
+
4567
+ _workspace_delete() {
4568
+ local name="$1"
4569
+ local force="$2"
4570
+
4571
+ local sanitized
4572
+ sanitized=$(_workspace_sanitize_name "$name")
4573
+ name="$sanitized"
4574
+
4575
+ local ws_dir="$REVO_WORKSPACE_ROOT/.revo/workspaces/$name"
4576
+
4577
+ ui_intro "Revo - Delete Workspace: $name"
4578
+
4579
+ if [[ ! -d "$ws_dir" ]]; then
4580
+ ui_step_error "No such workspace: $name"
4581
+ ui_outro_cancel "Nothing to delete"
4582
+ return 1
4583
+ fi
4584
+
4585
+ if [[ $force -ne 1 ]] && _workspace_has_local_work "$ws_dir"; then
4586
+ ui_step_error "Workspace has unpushed commits or uncommitted changes"
4587
+ ui_info "$(ui_dim "Push or stash your work first, or re-run with --force to discard it.")"
4588
+ ui_outro_cancel "Aborted"
4589
+ return 1
4590
+ fi
4591
+
4592
+ rm -rf "$ws_dir"
4593
+
4594
+ ui_step_done "Deleted:" ".revo/workspaces/$name"
4595
+ ui_outro "Workspace removed"
4596
+ return 0
4597
+ }
4598
+
4599
+ # --- clean ---
4600
+
4601
+ _workspace_clean() {
4602
+ local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
4603
+
4604
+ ui_intro "Revo - Clean Merged Workspaces"
4605
+
4606
+ if [[ ! -d "$ws_root" ]]; then
4607
+ ui_info "No workspaces"
4608
+ ui_outro "Nothing to clean"
4609
+ return 0
4610
+ fi
4611
+
4612
+ local default_branch
4613
+ default_branch=$(config_default_branch)
4614
+ [[ -z "$default_branch" ]] && default_branch="main"
4615
+
4616
+ local cleaned=0
4617
+ local kept=0
4618
+ local d
4619
+ for d in "$ws_root"/*/; do
4620
+ [[ -d "$d" ]] || continue
4621
+ d="${d%/}"
4622
+ local name
4623
+ name=$(basename "$d")
4624
+
4625
+ if _workspace_all_merged "$d" "$default_branch"; then
4626
+ rm -rf "$d"
4627
+ ui_step_done "Removed (merged):" "$name"
4628
+ cleaned=$((cleaned + 1))
4629
+ else
4630
+ ui_step_done "Kept:" "$name"
4631
+ kept=$((kept + 1))
4632
+ fi
4633
+ done
4634
+
4635
+ ui_bar_line
4636
+ ui_outro "Cleaned $cleaned, kept $kept"
4637
+ return 0
4638
+ }
4639
+
4640
+ # --- list (revo workspaces) ---
4641
+
4642
+ cmd_workspaces() {
4643
+ while [[ $# -gt 0 ]]; do
4644
+ case "$1" in
4645
+ --help|-h)
4646
+ printf 'Usage: revo workspaces\n\n'
4647
+ printf 'List all active workspaces with branch, age, and dirty state.\n'
4648
+ return 0
4649
+ ;;
4650
+ *)
4651
+ ui_step_error "Unknown option: $1"
4652
+ return 1
4653
+ ;;
4654
+ esac
4655
+ done
4656
+
4657
+ config_require_workspace || return 1
4658
+
4659
+ ui_intro "Revo - Workspaces"
4660
+
4661
+ local ws_root="$REVO_WORKSPACE_ROOT/.revo/workspaces"
4662
+ if [[ ! -d "$ws_root" ]]; then
4663
+ ui_info "No workspaces"
4664
+ ui_bar_line
4665
+ ui_info "$(ui_dim "Create one with: revo workspace <name>")"
4666
+ ui_outro "Done"
4667
+ return 0
4668
+ fi
4669
+
4670
+ # Count first so we can short-circuit empty
4671
+ local total=0
4672
+ local d
4673
+ for d in "$ws_root"/*/; do
4674
+ [[ -d "$d" ]] || continue
4675
+ total=$((total + 1))
4676
+ done
4677
+
4678
+ if [[ $total -eq 0 ]]; then
4679
+ ui_info "No workspaces"
4680
+ ui_bar_line
4681
+ ui_info "$(ui_dim "Create one with: revo workspace <name>")"
4682
+ ui_outro "Done"
4683
+ return 0
4684
+ fi
4685
+
4686
+ ui_table_widths 24 24 8 7 12
4687
+ ui_table_header "Workspace" "Branch" "Age" "Repos" "Dirty"
4688
+
4689
+ local now
4690
+ now=$(date +%s)
4691
+
4692
+ for d in "$ws_root"/*/; do
4693
+ [[ -d "$d" ]] || continue
4694
+ d="${d%/}"
4695
+ local name
4696
+ name=$(basename "$d")
4697
+
4698
+ local mtime
4699
+ mtime=$(_workspace_mtime "$d")
4700
+ local age_secs=$((now - mtime))
4701
+ local age
4702
+ age=$(_workspace_format_age "$age_secs")
4703
+
4704
+ local repos_in_ws=0
4705
+ local dirty_count=0
4706
+ local branch=""
4707
+ local repo
4708
+ while IFS= read -r repo; do
4709
+ [[ -z "$repo" ]] && continue
4710
+ repos_in_ws=$((repos_in_ws + 1))
4711
+ if [[ -z "$branch" ]]; then
4712
+ branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
4713
+ fi
4714
+ if [[ -n "$(git -C "$repo" status --porcelain 2>/dev/null)" ]]; then
4715
+ dirty_count=$((dirty_count + 1))
4716
+ fi
4717
+ done <<< "$(_workspace_repo_dirs "$d")"
4718
+
4719
+ [[ -z "$branch" ]] && branch="$(ui_dim "-")"
4720
+
4721
+ local dirty_text
4722
+ if [[ $dirty_count -gt 0 ]]; then
4723
+ dirty_text="$(ui_yellow "$dirty_count dirty")"
4724
+ else
4725
+ dirty_text="$(ui_green "clean")"
4726
+ fi
4727
+
4728
+ ui_table_row "$name" "$branch" "$age" "$repos_in_ws" "$dirty_text"
4729
+ done
4730
+
4731
+ ui_bar_line
4732
+ ui_outro "$total workspace(s)"
4733
+ return 0
4734
+ }
4735
+
4736
+ # --- entry point: revo workspace ---
4737
+
4738
+ cmd_workspace() {
4739
+ local name=""
4740
+ local tag=""
4741
+ local action="create"
4742
+ local force=0
4743
+
4744
+ while [[ $# -gt 0 ]]; do
4745
+ case "$1" in
4746
+ --tag)
4747
+ tag="$2"
4748
+ shift 2
4749
+ ;;
4750
+ --delete)
4751
+ action="delete"
4752
+ shift
4753
+ ;;
4754
+ --clean)
4755
+ action="clean"
4756
+ shift
4757
+ ;;
4758
+ --force|-f)
4759
+ force=1
4760
+ shift
4761
+ ;;
4762
+ --help|-h)
4763
+ cat << 'EOF'
4764
+ Usage:
4765
+ revo workspace <name> [--tag TAG] Create a workspace (full copy of repos)
4766
+ revo workspace <name> --delete [--force]
4767
+ Delete a workspace
4768
+ revo workspace --clean Remove workspaces whose branches are merged
4769
+ revo workspaces List active workspaces
4770
+
4771
+ Workspaces are full copies of all repos (or a tagged subset) under
4772
+ .revo/workspaces/<name>/. Each workspace gets its own feature/<name>
4773
+ branch. Hardlinks are used where possible so the cost is near-zero.
4774
+
4775
+ Run revo from inside .revo/workspaces/<name>/ and it automatically
4776
+ operates on the workspace's repos rather than the source tree.
4777
+
4778
+ EOF
4779
+ return 0
4780
+ ;;
4781
+ -*)
4782
+ ui_step_error "Unknown option: $1"
4783
+ return 1
4784
+ ;;
4785
+ *)
4786
+ if [[ -z "$name" ]]; then
4787
+ name="$1"
4788
+ else
4789
+ ui_step_error "Unexpected argument: $1"
4790
+ return 1
4791
+ fi
4792
+ shift
4793
+ ;;
4794
+ esac
4795
+ done
4796
+
4797
+ config_require_workspace || return 1
4798
+
4799
+ case "$action" in
4800
+ clean)
4801
+ _workspace_clean
4802
+ ;;
4803
+ delete)
4804
+ if [[ -z "$name" ]]; then
4805
+ ui_step_error "Usage: revo workspace <name> --delete [--force]"
4806
+ return 1
4807
+ fi
4808
+ _workspace_delete "$name" "$force"
4809
+ ;;
4810
+ create)
4811
+ if [[ -z "$name" ]]; then
4812
+ ui_step_error "Usage: revo workspace <name> [--tag TAG]"
4813
+ return 1
4814
+ fi
4815
+ _workspace_create "$name" "$tag" "$force"
4816
+ ;;
4817
+ esac
4818
+ }
4819
+
4097
4820
  # === Main ===
4098
4821
  # --- Help ---
4099
4822
  show_help() {
@@ -4122,6 +4845,10 @@ Claude-first commands:
4122
4845
  pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
4123
4846
  issue list [options] List issues across workspace repos
4124
4847
  issue create [options] TITLE Create issue(s) in workspace repos
4848
+ workspace NAME [--tag TAG] Create a full-copy workspace under .revo/workspaces/
4849
+ workspace NAME --delete Delete a workspace (use --force to discard work)
4850
+ workspace --clean Remove workspaces whose branches are merged
4851
+ workspaces List active workspaces
4125
4852
 
4126
4853
  Options:
4127
4854
  --tag TAG Filter repositories by tag
@@ -4147,6 +4874,10 @@ Examples:
4147
4874
  revo issue list --tag backend --json # JSON for Claude/jq
4148
4875
  revo issue create --repo backend "Add stats endpoint"
4149
4876
  revo issue create --tag mobile --feature stats "Add statistics screen"
4877
+ revo workspace clock-student # full copy of all repos
4878
+ revo workspace clock-student --tag backend # only backend repos
4879
+ revo workspaces # list active workspaces
4880
+ revo workspace clock-student --delete # remove when done
4150
4881
 
4151
4882
  Documentation: https://github.com/jippylong12/revo
4152
4883
  EOF
@@ -4218,6 +4949,12 @@ main() {
4218
4949
  issue|issues)
4219
4950
  cmd_issue "$@"
4220
4951
  ;;
4952
+ workspace)
4953
+ cmd_workspace "$@"
4954
+ ;;
4955
+ workspaces)
4956
+ cmd_workspaces "$@"
4957
+ ;;
4221
4958
  --help|-h|help)
4222
4959
  show_help
4223
4960
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revotools/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude-first multi-repo workspace manager (fork of Mars)",
5
5
  "bin": {
6
6
  "revo": "./dist/revo"