@revotools/cli 0.4.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 +1283 -101
  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.4.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,26 @@ _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'
3086
+ printf '**Issues (cross-repo, via gh CLI):**\n'
3087
+ printf -- '- `revo issue list [--tag t] [--state open|closed|all] [--label L] [--json]` — list issues across repos\n'
3088
+ printf -- '- `revo issue create --repo <name> "title" [--body b] [--label L] [--feature F]` — create in one repo\n'
3089
+ printf -- '- `revo issue create --tag <t> "title" [--body b] [--feature F]` — create in every matching repo, cross-referenced\n'
3090
+ printf '\n'
3091
+ printf 'Use `--json` on `revo issue list` to get a flat JSON array (each entry has\n'
3092
+ printf 'a `repo` field) — easy to filter or pipe into jq when reasoning about\n'
3093
+ printf 'cross-repo issue state.\n'
3094
+ printf '\n'
3005
3095
  printf '### Tag filtering\n'
3006
3096
  printf '\n'
3007
3097
  printf 'All commands support `--tag <tag>` to target specific repos:\n'
@@ -3658,120 +3748,1212 @@ cmd_pr() {
3658
3748
  return 0
3659
3749
  }
3660
3750
 
3661
- # === Main ===
3662
- # --- Help ---
3663
- show_help() {
3664
- cat << EOF
3665
- Revo - Claude-first multi-repo workspace manager v$REVO_VERSION
3751
+ # === lib/commands/issue.sh ===
3752
+ # Revo CLI - issue command
3753
+ # List and create GitHub issues across workspace repos via the gh CLI.
3666
3754
 
3667
- Usage: revo <command> [options]
3755
+ cmd_issue() {
3756
+ local subcommand="${1:-}"
3757
+ [[ $# -gt 0 ]] && shift
3668
3758
 
3669
- Workspace commands:
3670
- init Initialize a new workspace (auto-detects existing repos)
3671
- detect Bootstrap revo around existing git repos in cwd
3672
- add URL [--tags TAGS] Add a repository to the workspace
3673
- clone [--tag TAG] Clone configured repositories
3674
- list [--tag TAG] List configured repositories
3675
- status [--tag TAG] Show status of all repositories
3676
- sync [--tag TAG] Pull latest changes on repositories
3677
- branch NAME [--tag TAG] Create a branch on repositories
3678
- checkout BRANCH [--tag TAG] Checkout a branch on repositories
3679
- exec "CMD" [--tag TAG] Run a command in each repository
3759
+ case "$subcommand" in
3760
+ list|ls)
3761
+ _issue_list "$@"
3762
+ ;;
3763
+ create|new)
3764
+ _issue_create "$@"
3765
+ ;;
3766
+ --help|-h|help|"")
3767
+ _issue_help
3768
+ return 0
3769
+ ;;
3770
+ *)
3771
+ ui_step_error "Unknown subcommand: $subcommand"
3772
+ _issue_help
3773
+ return 1
3774
+ ;;
3775
+ esac
3776
+ }
3680
3777
 
3681
- Claude-first commands:
3682
- context Scan repos and regenerate workspace CLAUDE.md
3683
- feature NAME [--tag TAG] Create feature branch + context file across repos
3684
- commit MSG [--tag TAG] Commit changes across dirty repos
3685
- push [--tag TAG] Push branches across repositories
3686
- pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
3778
+ _issue_help() {
3779
+ cat << 'EOF'
3780
+ Usage: revo issue <subcommand> [options]
3781
+
3782
+ Subcommands:
3783
+ list List issues across workspace repos
3784
+ create Create issue(s) in workspace repos
3785
+
3786
+ revo issue list [options]
3787
+ --tag TAG Filter repos by tag
3788
+ --state open|closed|all Issue state (default: open)
3789
+ --label LABEL Filter by label
3790
+ --limit N Per-repo limit (default: 30)
3791
+ --json Emit a flat JSON array (one entry per issue,
3792
+ with a "repo" field)
3793
+
3794
+ revo issue create (--repo NAME | --tag TAG) "TITLE" [options]
3795
+ --repo NAME Target a single repo by path basename
3796
+ --tag TAG Target every repo matching the tag (cross-references
3797
+ all created issues in each body)
3798
+ --body BODY Issue body (default: revo-generated stub)
3799
+ --label L,L Comma-separated labels
3800
+ --assignee USER GitHub username to assign
3801
+ --feature NAME Append issue links to .revo/features/NAME.md and
3802
+ reference the brief from each issue body
3803
+ EOF
3804
+ }
3687
3805
 
3688
- Options:
3689
- --tag TAG Filter repositories by tag
3690
- --tags TAGS Comma-separated list of tags (for add)
3691
- --force, -f Force operation (clone, checkout)
3692
- --rebase, -r Use rebase when syncing
3693
- --quiet, -q Suppress command output (exec)
3694
- --body TEXT PR body (pr)
3695
- --help, -h Show this help
3696
- --version, -v Show version
3806
+ # --- list ----------------------------------------------------------------
3697
3807
 
3698
- Examples:
3699
- revo init
3700
- revo add git@github.com:org/shared-types.git --tags shared
3701
- revo add git@github.com:org/backend.git --tags backend,api
3702
- revo clone
3703
- revo context # regenerate CLAUDE.md
3704
- revo feature clock-student --tag backend # coordinated branch
3705
- revo commit "wire up clock endpoint"
3706
- revo push
3707
- revo pr "Clock endpoint for students" --tag backend
3808
+ _issue_list() {
3809
+ local tag=""
3810
+ local state="open"
3811
+ local label=""
3812
+ local limit="30"
3813
+ local as_json=0
3708
3814
 
3709
- Documentation: https://github.com/jippylong12/revo
3710
- EOF
3815
+ while [[ $# -gt 0 ]]; do
3816
+ case "$1" in
3817
+ --tag) tag="$2"; shift 2 ;;
3818
+ --state) state="$2"; shift 2 ;;
3819
+ --label) label="$2"; shift 2 ;;
3820
+ --limit) limit="$2"; shift 2 ;;
3821
+ --json) as_json=1; shift ;;
3822
+ --help|-h) _issue_help; return 0 ;;
3823
+ *) ui_step_error "Unknown option: $1"; return 1 ;;
3824
+ esac
3825
+ done
3826
+
3827
+ if ! command -v gh >/dev/null 2>&1; then
3828
+ ui_step_error "gh CLI not found. Install from https://cli.github.com/"
3829
+ return 1
3830
+ fi
3831
+
3832
+ config_require_workspace || return 1
3833
+
3834
+ local repos
3835
+ repos=$(config_get_repos "$tag")
3836
+ if [[ -z "$repos" ]]; then
3837
+ if [[ $as_json -eq 1 ]]; then
3838
+ printf '[]\n'
3839
+ return 0
3840
+ fi
3841
+ ui_step_error "No repositories configured"
3842
+ return 1
3843
+ fi
3844
+
3845
+ # Build the per-repo gh args once
3846
+ local gh_extra=()
3847
+ if [[ -n "$label" ]]; then
3848
+ gh_extra+=( --label "$label" )
3849
+ fi
3850
+
3851
+ if [[ $as_json -eq 1 ]]; then
3852
+ _issue_list_json "$repos" "$state" "$limit" "${gh_extra[@]}"
3853
+ return $?
3854
+ fi
3855
+
3856
+ _issue_list_human "$repos" "$state" "$limit" "${gh_extra[@]}"
3711
3857
  }
3712
3858
 
3713
- show_version() {
3714
- printf 'Revo v%s\n' "$REVO_VERSION"
3859
+ # Print a flat JSON array of issues across all repos. Each entry has a
3860
+ # "repo" field added in addition to the gh-provided fields. Output goes to
3861
+ # stdout, errors are silent (we want callers like Claude to be able to
3862
+ # pipe into jq).
3863
+ _issue_list_json() {
3864
+ local repos="$1"
3865
+ local state="$2"
3866
+ local limit="$3"
3867
+ shift 3
3868
+ local gh_extra=( "$@" )
3869
+
3870
+ local entries=""
3871
+ local first=1
3872
+
3873
+ local repo
3874
+ while IFS= read -r repo; do
3875
+ [[ -z "$repo" ]] && continue
3876
+ local path
3877
+ path=$(yaml_get_path "$repo")
3878
+ local full_path="$REVO_REPOS_DIR/$path"
3879
+ [[ ! -d "$full_path" ]] && continue
3880
+
3881
+ # gh's --jq runs the embedded jq engine and emits one compact JSON
3882
+ # value per line by default. We map each issue, prepend a "repo"
3883
+ # field, and stream the results into our concatenated array.
3884
+ local lines
3885
+ if lines=$(cd "$full_path" && gh issue list \
3886
+ --state "$state" --limit "$limit" "${gh_extra[@]}" \
3887
+ --json number,title,state,labels,assignees,url,updatedAt,author \
3888
+ --jq ".[] | {repo: \"$path\"} + ." 2>/dev/null); then
3889
+ local line
3890
+ while IFS= read -r line; do
3891
+ [[ -z "$line" ]] && continue
3892
+ if [[ $first -eq 1 ]]; then
3893
+ entries="$line"
3894
+ first=0
3895
+ else
3896
+ entries="$entries,$line"
3897
+ fi
3898
+ done <<< "$lines"
3899
+ fi
3900
+ done <<< "$repos"
3901
+
3902
+ printf '[%s]\n' "$entries"
3903
+ return 0
3715
3904
  }
3716
3905
 
3717
- # --- Main ---
3718
- main() {
3719
- local command="${1:-}"
3906
+ # Print a human-readable per-repo summary of issues. Each repo gets its
3907
+ # own block separated by ui_bar_line.
3908
+ _issue_list_human() {
3909
+ local repos="$1"
3910
+ local state="$2"
3911
+ local limit="$3"
3912
+ shift 3
3913
+ local gh_extra=( "$@" )
3720
3914
 
3721
- # Handle no arguments
3722
- if [[ -z "$command" ]]; then
3723
- show_help
3724
- return 0
3915
+ ui_intro "Revo - Issues ($state)"
3916
+
3917
+ local total=0
3918
+ local repo_count=0
3919
+ local skip_count=0
3920
+
3921
+ local repo
3922
+ while IFS= read -r repo; do
3923
+ [[ -z "$repo" ]] && continue
3924
+ local path
3925
+ path=$(yaml_get_path "$repo")
3926
+ local full_path="$REVO_REPOS_DIR/$path"
3927
+
3928
+ if [[ ! -d "$full_path" ]]; then
3929
+ ui_step_done "Skipped (not cloned):" "$path"
3930
+ skip_count=$((skip_count + 1))
3931
+ continue
3932
+ fi
3933
+
3934
+ ui_bar_line
3935
+ ui_step "$path"
3936
+
3937
+ local count_output
3938
+ if ! count_output=$(cd "$full_path" && gh issue list \
3939
+ --state "$state" --limit "$limit" "${gh_extra[@]}" \
3940
+ --json number --jq 'length' 2>&1); then
3941
+ ui_step_error "Failed: $count_output"
3942
+ continue
3943
+ fi
3944
+
3945
+ local repo_total="${count_output:-0}"
3946
+ if [[ "$repo_total" == "0" ]]; then
3947
+ printf '%s %s\n' "$(ui_bar)" "$(ui_dim "No $state issues")"
3948
+ continue
3949
+ fi
3950
+
3951
+ local pretty
3952
+ if pretty=$(cd "$full_path" && gh issue list \
3953
+ --state "$state" --limit "$limit" "${gh_extra[@]}" 2>&1); then
3954
+ local line
3955
+ while IFS= read -r line; do
3956
+ printf '%s %s\n' "$(ui_bar)" "$line"
3957
+ done <<< "$pretty"
3958
+ fi
3959
+
3960
+ total=$((total + repo_total))
3961
+ repo_count=$((repo_count + 1))
3962
+ done <<< "$repos"
3963
+
3964
+ ui_bar_line
3965
+ local msg="Found $total issue(s) across $repo_count repo(s)"
3966
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
3967
+ ui_outro "$msg"
3968
+ return 0
3969
+ }
3970
+
3971
+ # --- create --------------------------------------------------------------
3972
+
3973
+ _issue_create() {
3974
+ local title=""
3975
+ local repo=""
3976
+ local tag=""
3977
+ local body=""
3978
+ local labels=""
3979
+ local assignee=""
3980
+ local feature=""
3981
+
3982
+ while [[ $# -gt 0 ]]; do
3983
+ case "$1" in
3984
+ --repo) repo="$2"; shift 2 ;;
3985
+ --tag) tag="$2"; shift 2 ;;
3986
+ --body) body="$2"; shift 2 ;;
3987
+ --label) labels="$2"; shift 2 ;;
3988
+ --assignee) assignee="$2"; shift 2 ;;
3989
+ --feature) feature="$2"; shift 2 ;;
3990
+ --help|-h) _issue_help; return 0 ;;
3991
+ -*) ui_step_error "Unknown option: $1"; return 1 ;;
3992
+ *)
3993
+ if [[ -z "$title" ]]; then
3994
+ title="$1"
3995
+ else
3996
+ ui_step_error "Unexpected argument: $1"
3997
+ return 1
3998
+ fi
3999
+ shift
4000
+ ;;
4001
+ esac
4002
+ done
4003
+
4004
+ if [[ -z "$title" ]]; then
4005
+ ui_step_error "Usage: revo issue create (--repo NAME | --tag TAG) \"TITLE\""
4006
+ return 1
3725
4007
  fi
3726
4008
 
3727
- # Shift off the command
3728
- shift || true
4009
+ if [[ -z "$repo" ]] && [[ -z "$tag" ]]; then
4010
+ ui_step_error "Either --repo or --tag is required"
4011
+ return 1
4012
+ fi
3729
4013
 
3730
- case "$command" in
3731
- init)
3732
- cmd_init "$@"
3733
- ;;
3734
- detect)
3735
- cmd_detect "$@"
3736
- ;;
3737
- clone)
3738
- cmd_clone "$@"
3739
- ;;
3740
- status)
3741
- cmd_status "$@"
3742
- ;;
3743
- branch)
3744
- cmd_branch "$@"
3745
- ;;
3746
- checkout)
3747
- cmd_checkout "$@"
3748
- ;;
3749
- sync)
3750
- cmd_sync "$@"
3751
- ;;
3752
- exec)
3753
- cmd_exec "$@"
3754
- ;;
3755
- add)
3756
- cmd_add "$@"
3757
- ;;
3758
- list)
3759
- cmd_list "$@"
3760
- ;;
3761
- context)
3762
- cmd_context "$@"
3763
- ;;
3764
- feature)
3765
- cmd_feature "$@"
3766
- ;;
3767
- commit)
3768
- cmd_commit "$@"
3769
- ;;
3770
- push)
3771
- cmd_push "$@"
3772
- ;;
3773
- pr)
3774
- cmd_pr "$@"
4014
+ if [[ -n "$repo" ]] && [[ -n "$tag" ]]; then
4015
+ ui_step_error "Use either --repo or --tag, not both"
4016
+ return 1
4017
+ fi
4018
+
4019
+ if ! command -v gh >/dev/null 2>&1; then
4020
+ ui_step_error "gh CLI not found. Install from https://cli.github.com/"
4021
+ return 1
4022
+ fi
4023
+
4024
+ config_require_workspace || return 1
4025
+
4026
+ # Resolve target repo paths
4027
+ local target_paths=()
4028
+ if [[ -n "$repo" ]]; then
4029
+ local idx
4030
+ idx=$(yaml_find_by_name "$repo")
4031
+ if [[ $idx -lt 0 ]]; then
4032
+ ui_step_error "Repo not found in revo.yaml: $repo"
4033
+ return 1
4034
+ fi
4035
+ target_paths+=("$repo")
4036
+ else
4037
+ local repos
4038
+ repos=$(config_get_repos "$tag")
4039
+ local r
4040
+ while IFS= read -r r; do
4041
+ [[ -z "$r" ]] && continue
4042
+ local p
4043
+ p=$(yaml_get_path "$r")
4044
+ target_paths+=("$p")
4045
+ done <<< "$repos"
4046
+ fi
4047
+
4048
+ if [[ ${#target_paths[@]} -eq 0 ]]; then
4049
+ ui_step_error "No matching repos"
4050
+ return 1
4051
+ fi
4052
+
4053
+ ui_intro "Revo - Create issue: $title"
4054
+
4055
+ # Validate feature brief if --feature was given
4056
+ local feature_brief=""
4057
+ if [[ -n "$feature" ]]; then
4058
+ feature_brief="$REVO_WORKSPACE_ROOT/.revo/features/$feature.md"
4059
+ if [[ ! -f "$feature_brief" ]]; then
4060
+ ui_step_error "Feature brief not found: .revo/features/$feature.md"
4061
+ ui_info "$(ui_dim "Run 'revo feature $feature' first to create it")"
4062
+ return 1
4063
+ fi
4064
+ fi
4065
+
4066
+ # Build the initial body that all created issues share
4067
+ local effective_body="$body"
4068
+ if [[ -z "$effective_body" ]]; then
4069
+ effective_body="Created by \`revo issue create\`."
4070
+ fi
4071
+ if [[ -n "$feature" ]]; then
4072
+ effective_body="$effective_body"$'\n\n'"Part of feature: \`$feature\` (see \`.revo/features/$feature.md\`)"
4073
+ fi
4074
+
4075
+ # --- Pass 1: create issues -------------------------------------------
4076
+ local issue_paths=()
4077
+ local issue_urls=()
4078
+ local issue_numbers=()
4079
+ local fail_count=0
4080
+ local skip_count=0
4081
+
4082
+ local p
4083
+ for p in "${target_paths[@]}"; do
4084
+ local full_path="$REVO_REPOS_DIR/$p"
4085
+
4086
+ if [[ ! -d "$full_path" ]]; then
4087
+ ui_step_done "Skipped (not cloned):" "$p"
4088
+ skip_count=$((skip_count + 1))
4089
+ continue
4090
+ fi
4091
+
4092
+ local gh_args=( --title "$title" --body "$effective_body" )
4093
+ if [[ -n "$labels" ]]; then
4094
+ gh_args+=( --label "$labels" )
4095
+ fi
4096
+ if [[ -n "$assignee" ]]; then
4097
+ gh_args+=( --assignee "$assignee" )
4098
+ fi
4099
+
4100
+ local output
4101
+ if output=$(cd "$full_path" && gh issue create "${gh_args[@]}" 2>&1); then
4102
+ local url
4103
+ url=$(printf '%s' "$output" | grep -Eo 'https://github\.com/[^ ]+/issues/[0-9]+' | tail -1)
4104
+ if [[ -z "$url" ]]; then
4105
+ url="$output"
4106
+ fi
4107
+ local number="${url##*/}"
4108
+ issue_paths+=("$p")
4109
+ issue_urls+=("$url")
4110
+ issue_numbers+=("$number")
4111
+ ui_step_done "Created:" "$p → $url"
4112
+ else
4113
+ ui_step_error "Failed: $p"
4114
+ ui_info "$(ui_dim "$output")"
4115
+ fail_count=$((fail_count + 1))
4116
+ fi
4117
+ done
4118
+
4119
+ # --- Pass 2: cross-references ----------------------------------------
4120
+ if [[ ${#issue_paths[@]} -gt 1 ]]; then
4121
+ ui_bar_line
4122
+ ui_step "Linking issues with cross-references..."
4123
+
4124
+ local xref
4125
+ xref=$'\n\n---\n**Coordinated issues (revo):**\n'
4126
+ local i
4127
+ for ((i = 0; i < ${#issue_paths[@]}; i++)); do
4128
+ xref+="- ${issue_paths[$i]}: ${issue_urls[$i]}"$'\n'
4129
+ done
4130
+
4131
+ for ((i = 0; i < ${#issue_paths[@]}; i++)); do
4132
+ local repo_path="${issue_paths[$i]}"
4133
+ local issue_number="${issue_numbers[$i]}"
4134
+ local full_path="$REVO_REPOS_DIR/$repo_path"
4135
+ local combined_body="$effective_body$xref"
4136
+
4137
+ if (cd "$full_path" && gh issue edit "$issue_number" --body "$combined_body" >/dev/null 2>&1); then
4138
+ ui_step_done "Linked:" "$repo_path"
4139
+ else
4140
+ ui_step_error "Failed to link: $repo_path"
4141
+ fi
4142
+ done
4143
+ fi
4144
+
4145
+ # --- Pass 3: append to feature brief ---------------------------------
4146
+ if [[ -n "$feature_brief" ]] && [[ ${#issue_paths[@]} -gt 0 ]]; then
4147
+ ui_bar_line
4148
+ ui_step "Appending to feature brief: $feature"
4149
+ {
4150
+ printf '\n## Issues (created by revo)\n\n'
4151
+ local i
4152
+ for ((i = 0; i < ${#issue_paths[@]}; i++)); do
4153
+ printf -- '- **%s**: %s\n' "${issue_paths[$i]}" "${issue_urls[$i]}"
4154
+ done
4155
+ } >> "$feature_brief"
4156
+ ui_step_done "Updated:" ".revo/features/$feature.md"
4157
+ fi
4158
+
4159
+ ui_bar_line
4160
+
4161
+ # Print URLs to stdout (LLM-friendly: one URL per line, parseable)
4162
+ local i
4163
+ for ((i = 0; i < ${#issue_urls[@]}; i++)); do
4164
+ printf '%s\n' "${issue_urls[$i]}"
4165
+ done
4166
+
4167
+ if [[ $fail_count -eq 0 ]]; then
4168
+ local msg="Created ${#issue_paths[@]} issue(s)"
4169
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
4170
+ ui_outro "$msg"
4171
+ return 0
4172
+ else
4173
+ ui_outro_cancel "${#issue_paths[@]} created, $fail_count failed"
4174
+ return 1
4175
+ fi
4176
+ }
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
+
4820
+ # === Main ===
4821
+ # --- Help ---
4822
+ show_help() {
4823
+ cat << EOF
4824
+ Revo - Claude-first multi-repo workspace manager v$REVO_VERSION
4825
+
4826
+ Usage: revo <command> [options]
4827
+
4828
+ Workspace commands:
4829
+ init Initialize a new workspace (auto-detects existing repos)
4830
+ detect Bootstrap revo around existing git repos in cwd
4831
+ add URL [--tags TAGS] Add a repository to the workspace
4832
+ clone [--tag TAG] Clone configured repositories
4833
+ list [--tag TAG] List configured repositories
4834
+ status [--tag TAG] Show status of all repositories
4835
+ sync [--tag TAG] Pull latest changes on repositories
4836
+ branch NAME [--tag TAG] Create a branch on repositories
4837
+ checkout BRANCH [--tag TAG] Checkout a branch on repositories
4838
+ exec "CMD" [--tag TAG] Run a command in each repository
4839
+
4840
+ Claude-first commands:
4841
+ context Scan repos and regenerate workspace CLAUDE.md
4842
+ feature NAME [--tag TAG] Create feature branch + context file across repos
4843
+ commit MSG [--tag TAG] Commit changes across dirty repos
4844
+ push [--tag TAG] Push branches across repositories
4845
+ pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
4846
+ issue list [options] List issues across workspace repos
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
4852
+
4853
+ Options:
4854
+ --tag TAG Filter repositories by tag
4855
+ --tags TAGS Comma-separated list of tags (for add)
4856
+ --force, -f Force operation (clone, checkout)
4857
+ --rebase, -r Use rebase when syncing
4858
+ --quiet, -q Suppress command output (exec)
4859
+ --body TEXT PR body (pr)
4860
+ --help, -h Show this help
4861
+ --version, -v Show version
4862
+
4863
+ Examples:
4864
+ revo init
4865
+ revo add git@github.com:org/shared-types.git --tags shared
4866
+ revo add git@github.com:org/backend.git --tags backend,api
4867
+ revo clone
4868
+ revo context # regenerate CLAUDE.md
4869
+ revo feature clock-student --tag backend # coordinated branch
4870
+ revo commit "wire up clock endpoint"
4871
+ revo push
4872
+ revo pr "Clock endpoint for students" --tag backend
4873
+ revo issue list --state open # all open issues
4874
+ revo issue list --tag backend --json # JSON for Claude/jq
4875
+ revo issue create --repo backend "Add stats endpoint"
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
4881
+
4882
+ Documentation: https://github.com/jippylong12/revo
4883
+ EOF
4884
+ }
4885
+
4886
+ show_version() {
4887
+ printf 'Revo v%s\n' "$REVO_VERSION"
4888
+ }
4889
+
4890
+ # --- Main ---
4891
+ main() {
4892
+ local command="${1:-}"
4893
+
4894
+ # Handle no arguments
4895
+ if [[ -z "$command" ]]; then
4896
+ show_help
4897
+ return 0
4898
+ fi
4899
+
4900
+ # Shift off the command
4901
+ shift || true
4902
+
4903
+ case "$command" in
4904
+ init)
4905
+ cmd_init "$@"
4906
+ ;;
4907
+ detect)
4908
+ cmd_detect "$@"
4909
+ ;;
4910
+ clone)
4911
+ cmd_clone "$@"
4912
+ ;;
4913
+ status)
4914
+ cmd_status "$@"
4915
+ ;;
4916
+ branch)
4917
+ cmd_branch "$@"
4918
+ ;;
4919
+ checkout)
4920
+ cmd_checkout "$@"
4921
+ ;;
4922
+ sync)
4923
+ cmd_sync "$@"
4924
+ ;;
4925
+ exec)
4926
+ cmd_exec "$@"
4927
+ ;;
4928
+ add)
4929
+ cmd_add "$@"
4930
+ ;;
4931
+ list)
4932
+ cmd_list "$@"
4933
+ ;;
4934
+ context)
4935
+ cmd_context "$@"
4936
+ ;;
4937
+ feature)
4938
+ cmd_feature "$@"
4939
+ ;;
4940
+ commit)
4941
+ cmd_commit "$@"
4942
+ ;;
4943
+ push)
4944
+ cmd_push "$@"
4945
+ ;;
4946
+ pr)
4947
+ cmd_pr "$@"
4948
+ ;;
4949
+ issue|issues)
4950
+ cmd_issue "$@"
4951
+ ;;
4952
+ workspace)
4953
+ cmd_workspace "$@"
4954
+ ;;
4955
+ workspaces)
4956
+ cmd_workspaces "$@"
3775
4957
  ;;
3776
4958
  --help|-h|help)
3777
4959
  show_help