@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.
- package/dist/revo +738 -1
- 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.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
|
;;
|