diff --git a/USAGE.md b/USAGE.md index 2b23967..89b6abd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -250,6 +250,34 @@ Notes: - The created local branch is `ws//` (e.g., `ws/task-01/main`) - This is the local starting point; the agent branches off from here +### Removing a repo from a workspace + +The inverse of `orbit add`: drop a repo's worktree from the current workspace +and (by default) clean up the scoped local branch `add` created. The pool +repo (`.repos//`) is never touched — `remove` is a per-workspace +operation, not a clone removal. + +```bash +cd task-01/ +orbit remove backend +# → removed backend from task-01 (worktree gone) +# → deleted branch: ws/task-01/main +``` + +Safety defaults mirror `prune` — refuses to silently lose anything: + +- **Uncommitted / untracked changes** → refuses; pass `--force` to discard. +- **Unmerged local branch** (no PR, not in origin) → branch is kept and a + review hint is printed; pass `--force` to delete anyway. +- **Foreign repo** (a `.git/` directory, not file — its history lives only + in the worktree) → refuses; pass `--force` to destroy. + +Options: + +- `--force` — discard uncommitted changes and force-delete the local branch. +- `--json` — machine-readable output (`workspace`, `repo`, `worktreeRemoved`, + `branch`, `branchAction`). + ## 6. Branch Operations ### Scoped Mode (default) @@ -624,6 +652,7 @@ orbit sync [repo...] [--force] [--branch ] # --force / --branch: proje # Workspace lifecycle orbit new "" [--name ] [--no-goal] [--exec ""] orbit add [--ref ] [-s|--silent] +orbit remove [--force] [--json] orbit switch [repo] orbit switch -c [repo] orbit jot [] [""] diff --git a/orbit.sh b/orbit.sh index 5186fea..82f3554 100755 --- a/orbit.sh +++ b/orbit.sh @@ -91,6 +91,7 @@ Usage: $ORBIT_CMD memo [] [--refresh|--scaffold] $ORBIT_CMD new "" [--name ] [--no-goal] [--exec ""] $ORBIT_CMD add [--ref ] [-s|--silent] + $ORBIT_CMD remove [--force] [--json] $ORBIT_CMD switch [-c] [repo] $ORBIT_CMD sync [repo...] [--force] [--branch ] $ORBIT_CMD done [--pr ...] [--json] @@ -1387,6 +1388,131 @@ orbit_add() { fi } +orbit_remove() { + local repo_name="" force=0 json_mode=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --force) force=1; shift ;; + --json) json_mode=1; shift ;; + -*) orbit_fail "usage: orbit remove [--force] [--json]" ;; + *) + [ -z "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--json]" + repo_name="$1"; shift ;; + esac + done + [ -n "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--json]" + orbit_require_repo_name "$repo_name" + + local root + root=$(orbit_require_root) || return 1 + + local ws + ws=$(orbit_infer_workspace "$root") || return 1 + + local repo_dir="$root/.repos/$repo_name" + [ -d "$repo_dir" ] || orbit_fail "repo not in pool: $repo_name" + + local ws_dir="$root/$ws" + local wt="$ws_dir/$repo_name" + [ -d "$wt" ] || orbit_fail "repo not in workspace: $ws/$repo_name (use 'orbit add' first)" + + # Foreign-repo guard: a .git directory (not file) means the repo's history + # lives only in the worktree itself — removing the dir destroys history that + # exists nowhere else. Refuse without --force. + if [ -d "$wt/.git" ] && [ ! -f "$wt/.git" ]; then + if [ "$force" -eq 0 ]; then + orbit_fail "$repo_name is a foreign repo (history lives only in its own .git/); refusing to remove without --force" + fi + printf 'orbit: %s: foreign repo — --force discards local history\n' "$repo_name" >&2 + fi + + # Dirty check: refuse to discard uncommitted/untracked changes without --force. + if [ "$force" -eq 0 ]; then + local dirty + dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true) + if [ -n "$dirty" ]; then + printf 'orbit: %s: worktree has uncommitted changes (refusing to discard; commit/stash first, or pass --force):\n' "$repo_name" >&2 + printf '%s\n' "$dirty" | sed 's/^/ /' | head -10 >&2 + return 1 + fi + fi + + # Resolve the scoped branch add created: ws//. Empty-pool repos + # have no default branch; in that case branch deletion is skipped. + local default_branch local_branch="" + default_branch=$(orbit_default_branch "$repo_dir" 2>/dev/null || true) + if [ -n "$default_branch" ]; then + local_branch=$(orbit_tracking_branch "$ws" "$default_branch") || local_branch="" + fi + + # Branch verdict: re-use the same PR-merged / upstream-merged / content- + # upstream layers prune uses, so an unmerged branch is kept with a hint + # instead of silently vanishing. --force flips the verdict to delete. + # Computed BEFORE worktree removal — git refuses to delete a branch while + # a worktree has it checked out, so the deletion must follow the worktree. + local verdict="delete" v_flag="-d" + if [ -n "$local_branch" ]; then + orbit_branch_verdict "$repo_dir" "$local_branch" "$force" + if [ "$ORBIT_VERDICT" = "delete" ]; then + verdict="delete" + v_flag="$ORBIT_V_FLAG" + else + verdict="keep" + fi + fi + + # Worktree removal runs first: a "keep" verdict still needs the checkout + # detached, and the branch deletion below only succeeds once the worktree + # is gone. --force is passed because the dirty guard ran (or --force was + # given); worktree remove --force on a clean tree is a no-op. + if ! git -C "$repo_dir" worktree remove --force "$wt" >/dev/null 2>&1; then + # Stale registration: dir gone already, or admin entry is corrupt. + git -C "$repo_dir" worktree repair "$wt" >/dev/null 2>&1 || true + if ! git -C "$repo_dir" worktree remove --force "$wt" >/dev/null 2>&1; then + rm -rf "$wt" || orbit_fail "$repo_name: failed to remove worktree directory" + git -C "$repo_dir" worktree prune >/dev/null 2>&1 || true + printf 'orbit: %s: worktree registration was stale; directory removed directly\n' "$repo_name" >&2 + fi + fi + + # Branch deletion: now safe (no worktree is checking out the branch). + local branch_action="skipped" + if [ -n "$local_branch" ]; then + if [ "$verdict" = "delete" ]; then + if LC_ALL=C git -C "$repo_dir" branch "$v_flag" "$local_branch" >/dev/null 2>&1; then + branch_action="deleted" + else + branch_action="kept" + printf 'orbit: %s: branch deletion refused by git — kept\n' "$repo_name" >&2 + fi + else + branch_action="kept" + fi + fi + + if [ "$json_mode" -eq 1 ]; then + local out="{" + out+="$(orbit_json_kv workspace "$ws")," + out+="$(orbit_json_kv repo "$repo_name")," + out+='"worktreeRemoved":true,' + out+="$(orbit_json_kv branch "${local_branch:-}")," + out+="$(orbit_json_kv branchAction "$branch_action")" + out+='}' + printf '%s\n' "$out" + return 0 + fi + + printf 'removed %s from %s (worktree gone)\n' "$repo_name" "$ws" + if [ -n "$local_branch" ]; then + case "$branch_action" in + deleted) printf ' deleted branch: %s\n' "$local_branch" ;; + kept) printf ' kept branch (unmerged): %s — review: git -C .repos/%s log origin/%s..%s\n' \ + "$local_branch" "$repo_name" "$default_branch" "$local_branch" >&2 ;; + esac + fi +} + orbit_switch() { local create_mode=0 args=() @@ -4489,6 +4615,13 @@ _orbit_completions() { COMPREPLY=($(compgen -W "$(_orbit_repo_names)" -- "$cur")) fi ;; + remove) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--force --json" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_orbit_repo_names)" -- "$cur")) + fi + ;; switch) if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "-c" -- "$cur")) @@ -4596,6 +4729,7 @@ orbit() { clone) orbit_clone "$@" ;; new) orbit_new "$@" ;; add) orbit_add "$@" ;; + remove) orbit_remove "$@" ;; switch) orbit_switch "$@" ;; sync) orbit_sync "$@" ;; status) orbit_status "$@" ;; diff --git a/skills/orbit/SKILL.md b/skills/orbit/SKILL.md index 977f0c9..19672ed 100644 --- a/skills/orbit/SKILL.md +++ b/skills/orbit/SKILL.md @@ -1,6 +1,6 @@ --- name: orbit -description: "Operate Orbit - the user's Git-native multi-repo workspace manager. If an hook block is present, invoke BEFORE your first reply - even when the opening request seems unrelated to orbit. Also invoke on 'orbit start', or when asked to start working with no block injected (run `orbit context --startup`). Also use for clone, workspace create/manage, add repo, branch switch, status, goal, jot, memo aggregation, or done - or on mentions of workspaces, .repos/, or cross-repo tasks." +description: "Operate Orbit - the user's Git-native multi-repo workspace manager. If an hook block is present, invoke BEFORE your first reply - even when the opening request seems unrelated to orbit. Also invoke on 'orbit start', or when asked to start working with no block injected (run `orbit context --startup`). Also use for clone, workspace create/manage, add/remove repo, branch switch, status, goal, jot, memo aggregation, or done - or on mentions of workspaces, .repos/, or cross-repo tasks." --- Use this skill to operate the user's Git-native multi-repo workspace manager (Orbit). @@ -86,6 +86,7 @@ These steps describe the work itself, independent of who performs it. Run them y - **Jot triggers (event-driven — don't wait for wrap-up).** Jot the moment you realize (a) this repo also serves a role the card doesn't list, or (b) the real entry point for a task differs from — or is absent in — what the card names. If the discovery isn't a role or an entry point the card needs, it isn't a jot. - **Resume/compact sessions still jot.** A cruise block (durables + per-repo status) does not suppress discovery capture, and a compact can wipe your mental "to-jot" note — so jot findings as they surface, even mid-refactor after a compact. - **Need another repo?** (e.g., tracing a cross-repo dependency) → go back to steps 2–7: `orbit repos` to screen → `orbit info` to assess → decide whether to add → sync if needed → add → memo check. This cross-repo branch is a natural thing to delegate — a worker runs the same screen → assess → add loop autonomously via its briefing (see "Delegating to sub-agents"). + - **Drop a repo mid-work.** If a repo you added no longer belongs to this workspace (scope shrunk, or step 4's call was wrong), `orbit remove ` from inside the workspace — destructive but workspace-scoped; refuses on dirty/unmerged work without `--force` (see Safe to run freely). If a worker reaches the same conclusion, report the need rather than running it. 10. **Wrap-up.** Before finishing, aggregate jot entries and assess PR impact. This step is **incremental aggregation only** — it folds discoveries onto the understanding built in step 7; it is never where first-time exploration you skipped earlier gets done: - **Reflect first**: before popping, review what you learned this session about repos you added or worked in. If any structural insight never made it into a jot, jot it now, then continue. Keep scope to repos you added or worked in; do not sweep repos you only read via `orbit info`. - **Jot aggregation**: for each repo with jot entries, run `orbit jot --pop` to consume entries, then `orbit info ` to read current card, merge entries in — staying within the card budget orbit reports (curate, don't append) and following merge-first rules — write back via `cat <<'EOF' | orbit memo `. Before `orbit done`, run bare `orbit context` and confirm no repo you developed is left with `memo thin` and no capture — `orbit done` warns per repo as the final backstop. @@ -132,7 +133,7 @@ Recording knowledge (the moment you find it, before it's lost): scope), NOT feature-branch changes, NOT debug notes. - If you cannot run orbit, put the same items under a "## Discoveries" heading in your report. -Do NOT run: orbit memo / sync / done / new / goal / clone / config — report those needs to me. +Do NOT run: orbit memo / sync / done / new / goal / clone / config / remove — report those needs to me. Report back: findings, any repos you added, and your Discoveries list. ``` @@ -150,6 +151,7 @@ orbit sync [repo...] [--force] [--branch ] # updates the POOL repo only # Workspace lifecycle (from inside a workspace) orbit new [""] [--name ] [--exec ""] [--no-goal] orbit add [--ref ] [-s|--silent] +orbit remove [--force] [--json] # drop a repo's worktree from this workspace; pool repo untouched orbit switch [-c] [repo] orbit jot [] [""] # push a discovery to the jot queue orbit jot [] --pop [--json] # pop all entries (consume + delete) @@ -233,7 +235,7 @@ These orbit subcommands are read-only or idempotent workspace-writes — run the - **Destructive read:** `jot --pop` — it *consumes* the queue (read + delete, no undo). Safe to run without asking, but only as the first half of pop→merge: never pop until you're ready to write the memo in the same turn. - **Idempotent workspace-write:** `add` `switch` `sync` (bare or with a repo name) `memo` `jot` `goal` -`done` and `new` are workflow-timing commands — non-destructive and reversible; when to run them is governed by the workflow (step 11; Safety rule 3), and whether they prompt is the user's own permission setup — orbit takes no position at the permission layer. `clone` and `config` change project-level / shared state — confirm before running these. `sync --force` and `sync --branch` are not yours either: both destroy or re-point shared pool state and run only from the project root, so report the need rather than trying them (the bare `sync` being safe does not extend to these flags). `prune` is not on your list at all — see Safety rules. +`done` and `new` are workflow-timing commands — non-destructive and reversible; when to run them is governed by the workflow (step 11; Safety rule 3), and whether they prompt is the user's own permission setup — orbit takes no position at the permission layer. `clone` and `config` change project-level / shared state — confirm before running these. `sync --force` and `sync --branch` are not yours either: both destroy or re-point shared pool state and run only from the project root, so report the need rather than trying them (the bare `sync` being safe does not extend to these flags). `remove` is destructive but workspace-scoped — same call pattern as `prune` for one repo in your own workspace, and it refuses on dirty/unmerged work without `--force`. Run it when you've decided that repo no longer belongs to the workspace (a wrong call loses the local scoped branch unless you've pushed); when in doubt, confirm first. `prune` is not on your list at all — see Safety rules. ## Communication diff --git a/tests/30_remove.bats b/tests/30_remove.bats new file mode 100644 index 0000000..7a92d1e --- /dev/null +++ b/tests/30_remove.bats @@ -0,0 +1,208 @@ +#!/usr/bin/env bats + +setup_file() { + load test_helper/common + ensure_shared_project_with_branch +} + +setup() { + load test_helper/common + common_setup +} + +teardown() { + common_teardown +} + +# --- basic remove --- + +@test "remove: deletes the worktree directory" { + local proj="$SANDBOX/remove-test" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + assert_dir_exists "$proj/dev/myrepo" + + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 + [ ! -d "$proj/dev/myrepo" ] +} + +@test "remove: deletes the scoped local branch by default" { + local proj="$SANDBOX/remove-test2" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 + # Branch is gone from the pool repo's refs. + ! git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/dev/main >/dev/null 2>&1 +} + +@test "remove: pool repo (.repos/) is never touched" { + local proj="$SANDBOX/remove-test4" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + # Pool repo commits stay reachable. + local pool_sha_before pool_sha_after + pool_sha_before=$(git -C "$proj/.repos/myrepo" rev-parse HEAD) + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 + pool_sha_after=$(git -C "$proj/.repos/myrepo" rev-parse HEAD) + [ "$pool_sha_before" = "$pool_sha_after" ] + assert_dir_exists "$proj/.repos/myrepo" +} + +@test "remove: --json emits a structured record" { + local proj="$SANDBOX/remove-test5" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + local out + out=$(cd "$proj/dev" && orbit remove myrepo --json 2>/dev/null) + assert_contains "$out" '"workspace":"dev"' + assert_contains "$out" '"repo":"myrepo"' + assert_contains "$out" '"worktreeRemoved":true' + assert_contains "$out" '"branch":"ws/dev/main"' + assert_contains "$out" '"branchAction":"deleted"' +} + +# --- safety: dirty / unmerged --- + +@test "remove: refuses when worktree has uncommitted changes" { + local proj="$SANDBOX/remove-dirty" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + echo "wip" > "$proj/dev/myrepo/dirty.txt" + run bash -c "cd '$proj/dev' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove myrepo" + [ "$status" -ne 0 ] + # Worktree must still exist. + assert_dir_exists "$proj/dev/myrepo" +} + +@test "remove: --force discards uncommitted changes" { + local proj="$SANDBOX/remove-dirty2" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + echo "wip" > "$proj/dev/myrepo/dirty.txt" + cd "$proj/dev" && orbit remove myrepo --force >/dev/null 2>&1 + [ ! -d "$proj/dev/myrepo" ] +} + +@test "remove: keeps unmerged local branch with hint, worktree still removed" { + local proj="$SANDBOX/remove-unmerged" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + # Add an unpushed commit on the scoped branch — the branch now diverges + # from origin/main, so the verdict is "keep". + cd "$proj/dev/myrepo" && \ + echo "local-only" > local-only.txt && \ + git add local-only.txt && \ + git commit -m "local only commit" >/dev/null 2>&1 + + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 || true + # Worktree gone... + [ ! -d "$proj/dev/myrepo" ] + # ...but the local branch remains for review. + git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/dev/main >/dev/null 2>&1 +} + +@test "remove: --force force-deletes an unmerged local branch" { + local proj="$SANDBOX/remove-unmerged2" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + cd "$proj/dev/myrepo" && \ + echo "local-only" > local-only.txt && \ + git add local-only.txt && \ + git commit -m "local only commit" >/dev/null 2>&1 + + cd "$proj/dev" && orbit remove myrepo --force >/dev/null 2>&1 + [ ! -d "$proj/dev/myrepo" ] + ! git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/dev/main >/dev/null 2>&1 +} + +# --- failure modes --- + +@test "remove: fails when executed at project root" { + local proj="$SANDBOX/remove-no-ws" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + + run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove myrepo" + [ "$status" -ne 0 ] +} + +@test "remove: fails when repo is not in pool" { + local proj="$SANDBOX/remove-no-pool" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + + run bash -c "cd '$proj/dev' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove nonexistent" + [ "$status" -ne 0 ] +} + +@test "remove: fails when repo is not in this workspace" { + local proj="$SANDBOX/remove-not-in-ws" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + # Make a second workspace that never had myrepo added. + cd "$proj" && orbit new "second task" --name dev2 >/dev/null 2>&1 + run bash -c "cd '$proj/dev2' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove myrepo" + [ "$status" -ne 0 ] +} + +@test "remove: rejects unknown option" { + local proj="$SANDBOX/remove-badopt" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + run bash -c "cd '$proj/dev' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove myrepo --bogus" + [ "$status" -ne 0 ] +} + +@test "remove: requires a repo name" { + local proj="$SANDBOX/remove-noname" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + + run bash -c "cd '$proj/dev' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' remove" + [ "$status" -ne 0 ] +} + +# --- id re-add after remove --- + +@test "remove: re-adding the same repo after remove works" { + local proj="$SANDBOX/remove-readd" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 + + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + assert_dir_exists "$proj/dev/myrepo" +} + +# --- completeness: pool repo registration is preserved --- + +@test "remove: pool repo worktree list no longer lists the removed worktree" { + local proj="$SANDBOX/remove-wtlist" + clone_project "$proj" + cd "$proj" && orbit new "remove test" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add myrepo >/dev/null 2>&1 + + cd "$proj/dev" && orbit remove myrepo >/dev/null 2>&1 + # git worktree list (from the pool) should not contain the worktree path. + ! git -C "$proj/.repos/myrepo" worktree list --porcelain | grep -Fq "$proj/dev/myrepo" +} \ No newline at end of file