From c26bc0d87a1a5f1de6c425a8ecdbf0d98830a38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 11:30:16 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20orbit=20remove=20=E2=80=94=20drop?= =?UTF-8?q?=20a=20repo's=20worktree=20from=20a=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inverse of `orbit add`: removes the worktree from the current workspace and (by default) deletes the scoped local branch add created. Pool repo is never touched. - Defaults mirror prune: refuses uncommitted changes / unmerged branches; --force overrides. --keep-branch leaves the branch behind. - Reuses orbit_branch_verdict (PR-merged / upstream-merged / content- upstream) for the branch decision so an unmerged branch is kept with a review hint instead of silently vanishing. - Order: compute verdict → remove worktree → delete branch. git refuses to delete a branch while a worktree has it checked out, mirroring the stale-worktree retry path in orbit_branch_delete. - Foreign repos (.git directory, not file) refuse without --force since their history lives only in the worktree. - --json output for tooling: workspace, repo, worktreeRemoved, branch, branchAction. USAGE.md documents the command and its safety defaults. 16 new bats cases cover basic removal, --keep-branch / --force / --json, dirty / unmerged / foreign guards, error paths, and add→remove→add round-trip. Verified against the full suite (411/412 pass; the single failure, `prune: unregistered worktree warns and leaves it to directory removal`, is pre-existing on main and unrelated). --- USAGE.md | 30 ++++++ orbit.sh | 140 +++++++++++++++++++++++++++ tests/30_remove.bats | 219 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 tests/30_remove.bats diff --git a/USAGE.md b/USAGE.md index 2b23967..da44b21 100644 --- a/USAGE.md +++ b/USAGE.md @@ -250,6 +250,35 @@ 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: + +- `--keep-branch` — remove only the worktree; leave the scoped branch behind. +- `--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 +653,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] [--keep-branch] [--json] orbit switch [repo] orbit switch -c [repo] orbit jot [] [""] diff --git a/orbit.sh b/orbit.sh index 5186fea..2f26141 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] [--keep-branch] [--json] $ORBIT_CMD switch [-c] [repo] $ORBIT_CMD sync [repo...] [--force] [--branch ] $ORBIT_CMD done [--pr ...] [--json] @@ -1387,6 +1388,137 @@ orbit_add() { fi } +orbit_remove() { + local repo_name="" force=0 keep_branch=0 json_mode=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --force) force=1; shift ;; + --keep-branch) keep_branch=1; shift ;; + --json) json_mode=1; shift ;; + -*) orbit_fail "usage: orbit remove [--force] [--keep-branch] [--json]" ;; + *) + [ -z "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--keep-branch] [--json]" + repo_name="$1"; shift ;; + esac + done + [ -n "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--keep-branch] [--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" v_label="merged" + if [ -n "$local_branch" ] && [ "$keep_branch" -eq 0 ]; then + orbit_branch_verdict "$repo_dir" "$local_branch" "$force" + if [ "$ORBIT_VERDICT" = "delete" ]; then + verdict="delete" + v_flag="$ORBIT_V_FLAG" + v_label="$ORBIT_V_LABEL" + 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" ] && [ "$keep_branch" -eq 0 ]; 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 + elif [ "$keep_branch" -eq 1 ] && [ -n "$local_branch" ]; then + branch_action="kept(--keep-branch)" + 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 ;; + kept\(--keep-branch\)) + printf ' kept branch: %s\n' "$local_branch" ;; + esac + fi +} + orbit_switch() { local create_mode=0 args=() @@ -4489,6 +4621,13 @@ _orbit_completions() { COMPREPLY=($(compgen -W "$(_orbit_repo_names)" -- "$cur")) fi ;; + remove) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--force --keep-branch --json" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_orbit_repo_names)" -- "$cur")) + fi + ;; switch) if [[ "$cur" == -* ]]; then COMPREPLY=($(compgen -W "-c" -- "$cur")) @@ -4596,6 +4735,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/tests/30_remove.bats b/tests/30_remove.bats new file mode 100644 index 0000000..1ddb38d --- /dev/null +++ b/tests/30_remove.bats @@ -0,0 +1,219 @@ +#!/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: --keep-branch leaves the scoped branch behind" { + local proj="$SANDBOX/remove-test3" + 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 --keep-branch >/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 +} + +@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 From f1610dfc68634f14bd2f4faf2ae937cda39ad4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 14:19:57 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20orbit=20remove=20=E2=80=94=20drop=20?= =?UTF-8?q?unused=20v=5Flabel=20local=20to=20clear=20shellcheck=20SC2034?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch-verdict bookkeeping tracked a label string (`merged`, `PR merged`, `content upstream`, `force`) for symmetry with orbit_prune, but it was never read in either the JSON output or the human report. Drop the dead local; the verdict flag (-d / -D) still drives the actual git branch command. make lint now passes; bats tests/30_remove.bats still 16/16. --- orbit.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/orbit.sh b/orbit.sh index 2f26141..2d0bc53 100755 --- a/orbit.sh +++ b/orbit.sh @@ -1452,13 +1452,12 @@ orbit_remove() { # 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" v_label="merged" + local verdict="delete" v_flag="-d" if [ -n "$local_branch" ] && [ "$keep_branch" -eq 0 ]; then orbit_branch_verdict "$repo_dir" "$local_branch" "$force" if [ "$ORBIT_VERDICT" = "delete" ]; then verdict="delete" v_flag="$ORBIT_V_FLAG" - v_label="$ORBIT_V_LABEL" else verdict="keep" fi From d9c1e87c0feead93c9c6c1a4ac329a69162224bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 15:44:38 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20orbit=20remove=20=E2=80=94=20note?= =?UTF-8?q?=20--keep-branch=20round-trips=20with=20orbit=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/USAGE.md b/USAGE.md index da44b21..6c60d84 100644 --- a/USAGE.md +++ b/USAGE.md @@ -274,7 +274,7 @@ Safety defaults mirror `prune` — refuses to silently lose anything: Options: -- `--keep-branch` — remove only the worktree; leave the scoped branch behind. +- `--keep-branch` — remove only the worktree; leave the scoped branch behind so `orbit add ` can recreate the worktree on the same branch later. - `--force` — discard uncommitted changes and force-delete the local branch. - `--json` — machine-readable output (`workspace`, `repo`, `worktreeRemoved`, `branch`, `branchAction`). From db050b1112d6fb1ab554b547b26436285950300b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 16:00:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20orbit=20remove=20=E2=80=94=20dr?= =?UTF-8?q?op=20--keep-branch=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback, the flag was redundant: orbit add rebuilds a fresh scoped branch from origin when none exists locally, and the default "delete the scoped branch" already matches the round-trip a user actually wants when they re-add the repo. Removing the flag shrinks the surface (default-refuses-dirty + --force to override is the only safety knob) and avoids the asymmetric style where remove had a flag prune does not. Verified: - 30_remove.bats: 15/15 green (the --keep-branch test was removed) - orbit.sh --help shows the new usage line Full bats suite still in flight; will report if anything regresses. --- USAGE.md | 3 +-- orbit.sh | 21 ++++++++------------- tests/30_remove.bats | 11 ----------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/USAGE.md b/USAGE.md index 6c60d84..89b6abd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -274,7 +274,6 @@ Safety defaults mirror `prune` — refuses to silently lose anything: Options: -- `--keep-branch` — remove only the worktree; leave the scoped branch behind so `orbit add ` can recreate the worktree on the same branch later. - `--force` — discard uncommitted changes and force-delete the local branch. - `--json` — machine-readable output (`workspace`, `repo`, `worktreeRemoved`, `branch`, `branchAction`). @@ -653,7 +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] [--keep-branch] [--json] +orbit remove [--force] [--json] orbit switch [repo] orbit switch -c [repo] orbit jot [] [""] diff --git a/orbit.sh b/orbit.sh index 2d0bc53..82f3554 100755 --- a/orbit.sh +++ b/orbit.sh @@ -91,7 +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] [--keep-branch] [--json] + $ORBIT_CMD remove [--force] [--json] $ORBIT_CMD switch [-c] [repo] $ORBIT_CMD sync [repo...] [--force] [--branch ] $ORBIT_CMD done [--pr ...] [--json] @@ -1389,20 +1389,19 @@ orbit_add() { } orbit_remove() { - local repo_name="" force=0 keep_branch=0 json_mode=0 + local repo_name="" force=0 json_mode=0 while [ "$#" -gt 0 ]; do case "$1" in --force) force=1; shift ;; - --keep-branch) keep_branch=1; shift ;; --json) json_mode=1; shift ;; - -*) orbit_fail "usage: orbit remove [--force] [--keep-branch] [--json]" ;; + -*) orbit_fail "usage: orbit remove [--force] [--json]" ;; *) - [ -z "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--keep-branch] [--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] [--keep-branch] [--json]" + [ -n "$repo_name" ] || orbit_fail "usage: orbit remove [--force] [--json]" orbit_require_repo_name "$repo_name" local root @@ -1453,7 +1452,7 @@ orbit_remove() { # 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" ] && [ "$keep_branch" -eq 0 ]; then + if [ -n "$local_branch" ]; then orbit_branch_verdict "$repo_dir" "$local_branch" "$force" if [ "$ORBIT_VERDICT" = "delete" ]; then verdict="delete" @@ -1479,7 +1478,7 @@ orbit_remove() { # Branch deletion: now safe (no worktree is checking out the branch). local branch_action="skipped" - if [ -n "$local_branch" ] && [ "$keep_branch" -eq 0 ]; then + 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" @@ -1490,8 +1489,6 @@ orbit_remove() { else branch_action="kept" fi - elif [ "$keep_branch" -eq 1 ] && [ -n "$local_branch" ]; then - branch_action="kept(--keep-branch)" fi if [ "$json_mode" -eq 1 ]; then @@ -1512,8 +1509,6 @@ orbit_remove() { 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 ;; - kept\(--keep-branch\)) - printf ' kept branch: %s\n' "$local_branch" ;; esac fi } @@ -4622,7 +4617,7 @@ _orbit_completions() { ;; remove) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--force --keep-branch --json" -- "$cur")) + COMPREPLY=($(compgen -W "--force --json" -- "$cur")) else COMPREPLY=($(compgen -W "$(_orbit_repo_names)" -- "$cur")) fi diff --git a/tests/30_remove.bats b/tests/30_remove.bats index 1ddb38d..7a92d1e 100644 --- a/tests/30_remove.bats +++ b/tests/30_remove.bats @@ -38,17 +38,6 @@ teardown() { ! git -C "$proj/.repos/myrepo" rev-parse --verify --quiet refs/heads/ws/dev/main >/dev/null 2>&1 } -@test "remove: --keep-branch leaves the scoped branch behind" { - local proj="$SANDBOX/remove-test3" - 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 --keep-branch >/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 -} - @test "remove: pool repo (.repos/) is never touched" { local proj="$SANDBOX/remove-test4" clone_project "$proj" From 9ff3b4268a72bc4f23bcc558e35200cd41e6b0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 17:07:25 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(skill):=20orbit=20remove=20=E2=80=94?= =?UTF-8?q?=20add=20command=20to=20vendored=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the user-level ~/.qoder/skills/orbit/SKILL.md update: - description trigger-words: add repo → add/remove repo - command map: orbit remove [--force] [--json] - safe-to-run-freely: classify remove as destructive but workspace-scoped (refuses dirty/unmerged work without --force), same call pattern as prune for one repo in your own workspace --- skills/orbit/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/orbit/SKILL.md b/skills/orbit/SKILL.md index 977f0c9..c94e843 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). @@ -150,6 +150,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 +234,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 From d3726f3fbb5fed34a92a2af169369d72bc220f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E5=85=B6?= Date: Sun, 20 Sep 2026 17:12:41 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(skill):=20orbit=20remove=20=E2=80=94?= =?UTF-8?q?=20workflow=20mid-work=20path=20+=20worker=20briefing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the two spots the previous skill-only commit missed: the Work step's "drop a repo mid-work" path, and the sub-agent briefing's Do NOT run list. --- skills/orbit/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/orbit/SKILL.md b/skills/orbit/SKILL.md index c94e843..19672ed 100644 --- a/skills/orbit/SKILL.md +++ b/skills/orbit/SKILL.md @@ -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. ```