Skip to content
Back to skills

Cleanup

BSecurity

OMP-only — clean branches/worktrees/remotes after merge-status verification; sweep stuck labels and orphan CI runs. Offered after land, never automatic.

  • 9 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 23, 2026
developmentrustgoshellbashnodegit

Security analysis

B85/100
  • highPerforms destructive filesystem operations

Pro scans all 9 files and shows the line behind each finding

Scanned November 9, 2026

npx -y skills add Roxabi/roxabi-plugins --skill cleanup --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Cleanup?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Cleanup
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/roxabi-cleanup-roxabi-plugins/badge)](https://www.skillsdirectory.com/skills/roxabi-cleanup-roxabi-plugins)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: cleanup
disable-model-invocation: true
argument-hint: '[--all | --report-only | --yes | --scope <#N>]'
description: OMP-only — clean branches/worktrees/remotes after merge-status verification; sweep stuck labels and orphan CI runs. Offered after land, never automatic.
version: 0.1.0
---

# Git Cleanup

Let: β := branch | ω := worktree | π := open PR | Π := protected branch (main/master/staging, or deploy/* — a CD production branch is an ancestor of the base by construction, so it always looks merged) | safe(β) ⟺ proven_merged(β) ∧ ¬π(β) | proven_merged(β) := regular_merge(β) ∨ verified_squash(β) | regular_merge(β) := `BASE..β` is empty | verified_squash(β) := gh reports a MERGED PR on β **and** that PR's head SHA is an ancestor of its merge commit | hinted(β) := a commit message on BASE contains `#N` or β's name — **not** a proof, since any commit may type the number | N := scope issue number (∅ if unscoped) | orphan_shell := leftover path under `~/.omp/worktrees/<repo>/` (legacy leftover of the retired `ensureWorktree`), `<principal>/.claude/worktrees/` (harness-created worktrees), or `<worktree base>/<repo>/<slug>` (the `/feature` root; base is `OMP_WORKTREE_DIR`, else stack.yml `worktree.base`, else `~/.omp/wt`) that is **not** in `git worktree list` | principal := the first `git worktree list` entry, pinned to main/master/staging by `hooks/principal-branch-pre.cjs` and the `tool_call` guard in `omp/index.ts`

Safely clean local β, ω, and remote branches with **mandatory merge-status verification** before any deletion. End-of-session sweep also strips stuck pipeline labels from closed PRs, cancels long-queued CI runs, and surfaces **orphan worktree shells** that git no longer tracks.

## Where deletion happens — the whole list

Three steps in this body delete, and no others:

| Step | Command | Backstop |
|------|---------|----------|
| 5 | `git worktree remove` · `git branch -d` · `git branch -D` | git itself: `-d` refuses an unmerged β, and the remote copy survives the mistake |
| 5b-execute | `rmdir` on allowlisted `empty_parent`; `rm -rf` only after per-row confirm on content orphans | none — outside git entirely; `--yes` is an allowlist (`--yes-targets` → `rmdir` only) |
| 6e | `git push origin --delete` | **none** — no unmerged check exists on the remote side, and after it there is no copy left |

Step 6e is the deletion that ends the work, so the evidence bar is set there and
the other steps inherit it: only proven_merged(β) reaches `safe_remote`. A hinted
β is reported as `probably_merged`, is never pre-selected, and never enters a
`safe_*` list. `analyze-branches.sh` itself deletes nothing — it classifies.

## Where this sits — the optional tail

`/cleanup` is **offered**, never run on its own initiative:

- It is not part of the feature cycle. `/feature` lands an issue and stops. Nothing
  in the review→fix loop may reach this skill: a loop that deletes branches between
  review rounds deletes the branch under review.
- The operator invokes it. No path from `/feature`, `skill://dev-review` or
  `skill://fix` runs it automatically, and none may be added.
- `disable-model-invocation: true` omits this skill from the prompt listing on omp —
  it is **not** a gate. The property that makes this user-only is the lane:
  `omp/index.ts` exposes `/cleanup` through `registerCommand`, which the model
  cannot call. `skill://cleanup` and `/skill:cleanup` still reach this body.

## Entry: parse $ARGUMENTS

At skill entry, before any other action:

```
REPORT_ONLY=false
YES=false
SCOPE=""
_next_is_scope=false
for arg in $ARGUMENTS; do
  if [ "$_next_is_scope" = true ]; then
    SCOPE="${arg#\#}"
    _next_is_scope=false
    continue
  fi
  case "$arg" in
    --report-only) REPORT_ONLY=true ;;
    --yes)         YES=true ;;
    --scope)       _next_is_scope=true ;;
    --scope=*)     SCOPE="${arg#--scope=}"; SCOPE="${SCOPE#\#}" ;;
  esac
done
```

`REPORT_ONLY=true` ⇒ **zero mutations** throughout all steps (cron-safe).  
`YES=true` ⇒ skip confirmation prompts for destructive actions (implies user already consented).  
If both set: `REPORT_ONLY` wins — no mutations.
`SCOPE=<N>` ⇒ restrict branch/worktree analysis (Steps 2–6) to issue N only — see below. `SCOPE=""` (default) ⇒ repo-wide, unchanged behavior.

## Instructions

### 1. Gather State

```bash
T=$(realpath skill://cleanup/gather-state.sh) && bash "$T"
```

Emits: `current`, branch list with tracking info, worktree list, **orphan worktree shells** (`---orphan-worktree-shells---` via `scan-orphan-worktree-shells.sh`), open PRs, closed PRs with pipeline labels, and queued/stuck CI runs. Unscoped — always full-repo; Steps 7–8 (label/CI sweeps) and the orphan-shell scan consume gather-state as-is regardless of `--scope` (see Options).

### 2. Analyze Branches

```bash
# `--report-only` promises zero mutations, and the default path here runs
# `git fetch --prune origin` — a write to refs/remotes/*. A cron-safe run takes
# the trade knowingly: a possibly stale mirror (and so a possibly stale BASE)
# rather than a write. Interactive runs still fetch, because the deletions that
# follow are priced on a fresh base.
T=$(realpath skill://cleanup/analyze-branches.sh) || {
  printf 'REFUSE: cannot resolve skill://cleanup/analyze-branches.sh\n'
  exit 1
}
FETCH_ARG=""
[ "$REPORT_ONLY" = true ] && FETCH_ARG="--no-fetch"

if [ -n "$SCOPE" ]; then
  bash "$T" $FETCH_ARG --scope "$SCOPE"
else
  bash "$T" $FETCH_ARG
fi
```

Use `--json` when you need structured output for scripting. `--no-fetch` is also the flag for tests, or when origin was fetched immediately before.

**Scoping (`--scope <#N>`):** restricts local/remote branches and worktrees to the ones belonging to issue N by an **anchored** issue-number match — the character before the number is start-of-string or `/`/`-`, and the character after is `/`, `-`, `_`, or end (`extract_issue_number()` in `analyze-branches.sh`). So `--scope 19` cannot pick up `feat/319-other`, and `--scope 1` cannot pick up issue #14's branch. Without `--scope`, behavior is unchanged (repo-wide).

The script analyzes ∀ β ∉ {Π, current branch} (∧ β ∈ scope N, if set) with these checks (base branch = `staging` if `origin/staging` exists, else `main` — `detect_base_branch`, sourced by the script from `skills/shared/lib.sh` as `${SCRIPT_DIR}/../shared/lib.sh`; `shared` is a plain directory in this plugin, not a skill, so there is no `skill://` URL that names it):

| Check | Implementation | Verdict |
|-------|----------------|---------|
| Merged into base? | `git log --oneline <base>..<branch>` empty | `merge_reason=regular` → **safe to delete** |
| Squash-merged, verified? | `gh pr list --state all` → `MERGED` on head **and** `git merge-base --is-ancestor <headRefOid> <mergeCommit>` | `merge_reason=squash_pr` → **safe to delete** |
| Message match only | `git log --grep` on issue# or branch name on base, or a `MERGED` PR whose ancestry could not be shown | `merge_reason=squash_grep` / `squash_pr_unverified` → **`probably_merged`**, never safe, never pre-selected |
| Has open PR? | Batched `gh pr list` indexed by `headRefName` | **No** — active work |
| Has worktree? | `git worktree list --porcelain` | Remove worktree first |
| Commits ahead | `git rev-list --count <base>..<branch>` | Shown on every row — non-zero on a merged row is work pushed *after* the merge |
| Last commit age | `git log -1 --format="%cr"` | Info only |

**A grep hit is not a merge.** `git log --grep "#50"` matches any commit that
*mentions* #50 — a plan, a revert, a TODO — so it says nothing about whether this
β's commits shipped. Treating it as proof is how an unmerged, pushed β reaches
`git push origin --delete`: git refuses the local `-d`, nothing refuses the
remote. Hence `merge_reason` travels with every row and the operator approves a
verdict, not a checkmark.

Emits section markers: `---local-branches---`, `---remote-branches---`, `---worktrees---`, `---safe-local---`, `---safe-remote---`, `---probably-local---`, `---probably-remote---`, plus a human `---summary-table---`. **Analyze-only** — it carries no deletion path at all.

**Protected branches never reach a deletion list.** They are dropped *before*
classification, so they never enter `local_branches` or the remote listing, and
therefore can never appear in `safe_local`, `safe_remote`, `probably_local`, or
`probably_remote`. That set is main/master/staging, plus `deploy/*`: a CD
production branch (Cloudflare builds `deploy/<branch>`, fast-forwarded from the
base) is an ancestor of the base by construction, so merge detection always
calls it merged. The branch checked out in the principal — pinned to
main/master/staging by the freeze guard — is in that set, so it never enters
`local_branches`. A branch checked out in the invoking worktree is labelled
`current` for the same reason.

The script's one write is `git fetch --prune origin` (skipped by `--no-fetch`): it
drops `refs/remotes/*` entries whose upstream is already gone. That is a mirror
sync — no branch, local or remote, is removed — and BASE detection reads those refs,
so a stale mirror would misreport the base.

### 3. Present Summary Table

```
Git Cleanup Summary
═══════════════════

Branches:
  Branch              │ Merged │ Evidence   │ Ahead │ PR    │ Worktree  │ Last Commit  │ Action
  feat/19-auth        │ ✅ yes │ regular    │ 0     │ —     │ —         │ 3 days ago   │ 🗑 Safe to delete
  feat/33-i18n        │ ❌ no  │ none       │ 4     │ #42   │ ../repo-33│ 2 hours ago  │ ⚠️ Active work
  fix/old-bug         │ ✅ yes │ squash_pr  │ 0     │ #38   │ —         │ 2 weeks ago  │ 🗑 Safe to delete
  feat/50-thing       │ 🔎 hint│ squash_grep│ 1     │ —     │ —         │ 6 days ago   │ 🔎 Probably merged — verify
  experiment/test     │ ❌ no  │ none       │ 2     │ —     │ —         │ 1 month ago  │ ⚠️ Unmerged

Worktrees:
  Path                │ Branch        │ Status
  /home/user/project  │ main          │ Principal (keep)
  /home/user/rox-33   │ feat/33-i18n  │ Active PR #42

Legend: 🗑 = proven merged, safe to delete · 🔎 = a message matched, nothing is proven —
verify before deleting · ⚠️ = needs attention · 🔒 = protected
```

### 4. Ask for Confirmation

If `REPORT_ONLY=true` → skip this confirmation **and** Step 5 (zero deletions); Step 3's table is the report. Continue to Step 6.

→ present multi-select
- Present only safe(β) items as default selections — `action == safe_delete`, i.e. `merge_reason ∈ {regular, squash_pr}`
- Show unmerged β **and** `probably_merged` β separately with a warning; **NEVER auto-select either**
- ∃ unmerged β ∨ ∃ `probably_merged` β → separate question, explicit warning, naming the evidence (`squash_grep` = "a commit message mentioned it") and the commits-ahead count
- Always include "Skip / Do nothing"

### 5. Execute Cleanup

∀ confirmed deletion. **Each `<placeholder>` below is a value, never a fragment**:
it is substituted inside the quotes exactly as the analyser reported it, and the
`--` ends option parsing. A refname is attacker-controlled text — it arrives from
`git branch -r`, so it carries whatever a PR author typed — and an unquoted
`<branch>` makes it a shell command:

```bash
# ω ∃ for β → remove ω FIRST
git worktree remove -- "<path>"

git branch -d -- "<branch>"        # merged branches (safe)
git branch -D -- "<branch>"        # unmerged — only if explicitly confirmed

git worktree prune
```

Never run any of these against the principal's path or its branch.

### 5b. Orphan worktree shells

`git worktree list` only knows registered worktrees. After `git worktree remove`, leftovers stay behind in the three roots the scanner walks — `<worktree base>/<repo>/<slug>` (the `/feature` root: `OMP_WORKTREE_DIR`, else `.dev/stack.yml` `worktree.base`, else `~/.omp/wt`), `<principal>/.claude/worktrees/` (harness), and `~/.omp/worktrees/<repo>/` (legacy leftover of the retired `ensureWorktree`):

Without an environment override, Bun.YAML (Bun ≥1.2.21) reads `worktree.base`
from the principal's stack. Quoted and block scalars retain their value; `~/`
expands against `$HOME` and relative paths resolve against the principal.
Invalid YAML or a non-string base fails nonzero and names `.dev/stack.yml`.
A failed scan is not an empty orphan inventory.

| kind | Example | Deletable? |
|------|---------|------------|
| `empty_parent` | proven-empty real directory, outside any git work tree, no symlink below its trusted anchor | **`--yes` allowlist** — `rmdir` only; never `rm -rf` |
| `empty_untracked` | empty and untracked under an enclosing work tree (harness / feature orphan, `$HOME` dotfiles) | per-row `rmdir` after confirm — **never** in `--yes-targets` |
| `unregistered` (content without `.git`) | partial dir (e.g. only `node_modules`) | per-row confirm only — **never** in `--yes-targets` |
| `unregistered` (`has .git`) | half-removed worktree of **this** repo (gitdir proven ours) | per-row confirm only — **never** in `--yes-targets` |
| `inside_worktree` / `nested_git` | owned by a work tree (tracks something at/under the path, or rooted at/below it), or a `.git` within depth 4 | **not selectable** |
| `symlink_root` / `symlink` / `dangling_git` / `not_a_dir` / `unreadable` | symlink between anchor and root (children not listed), symlink child, dangling `.git`, non-directory, not readable/searchable | **not selectable** |
| `unsafe_name` | path with a byte outside `[A-Za-z0-9._/@+-]`, shown `%q`-escaped (`|` as `\x7c`) | **not selectable** |

Source: `gather-state.sh` → `---orphan-worktree-shells---` (`path|kind|detail`). Scope is **this repo only**: a child with `.git` is listed only when its `gitdir:` back-pointer resolves under this repo's absolute `git-common-dir`/worktrees. The principal and its tracked descendants are never offered, except children of an untracked harness or feature root (`.claude/worktrees` / in-principal feature base, each only while `git ls-files` finds nothing tracked there). Anything under a registered worktree, and any child that **contains** the principal or a registered worktree, are skipped. An empty untracked child under those roots is `empty_untracked` (per-row `rmdir`); content is `unregistered` (per-row confirm). Each root is built lexically under a canonical trusted anchor (the principal, `$HOME`, or the configured / default base); a relative `worktree.base` resolves against the principal. Rows carry the lexical scanned entry; matching uses `realpath`.

#### 5b-present

```
Orphan worktree shells
══════════════════════

  Path                                              │ Kind             │ Detail
  ~/.omp/worktrees/roxabi-plugins                   │ empty_parent     │ empty leftover after worktree remove
  <principal>/.claude/worktrees/495-optional-tail   │ unregistered     │ content without git registration
  (none found)
```

If `REPORT_ONLY=true` → print table and skip deletion. Else → multi-select; always offer "Skip".

**Defaults / `--yes` set:** **only** the rows `T=$(realpath skill://cleanup/scan-orphan-worktree-shells.sh) && bash "$T" --yes-targets` emits — an allowlist of `empty_parent` rows (proven-empty real directories, outside any git work tree, no symlink component below the trusted anchor). `empty_untracked` and `unregistered` need per-row confirmation (`rmdir` / `rm -rf`). `inside_worktree`, `nested_git`, `symlink_root`, `symlink`, `dangling_git`, `not_a_dir`, `unreadable`, and `unsafe_name` are **not selectable** — never offered, even per row.

#### 5b-execute (confirmed only)

```bash
# --yes / defaults — every --yes-targets row:
rmdir -- "<path>"

# Per-row confirmed `empty_untracked` (never under --yes):
rmdir -- "<path>"

# Per-row confirmed `unregistered` row only (never under --yes):
rm -rf -- "<path>"
```

`<path>` is a value: quoted, after `--`, exactly the path the scanner emitted.
`--yes` runs **`rmdir` only**: it refuses a non-empty directory, so a row that
gained content since the scan fails instead of being wiped.

**Safety:** never `rm -rf` a path that still appears in `git worktree list`. Never wipe a worktree root wholesale — only this repo's children. `--yes` uses `--yes-targets` only (`rmdir`). Matches on canonical paths; emits the lexical scanned entry.

### 6. Clean Remote Branches

Use `---remote-branches---` and `---safe-remote---` from Step 2 (`analyze-branches.sh`). Do **not** re-analyze manually.

**CRITICAL for squash merges**: `git branch -r --merged` will NOT detect squash-merged branches, so `analyze-branches.sh` verifies the PR instead: `MERGED` **and** the PR head SHA an ancestor of the merge commit (`merge_reason=squash_pr`). A branch whose only evidence is a `git log --grep` match is `probably_merged`, **not** safe — it never appears in `---safe-remote---`. Post-merge commits on a verified `MERGED` PR β still delete safely, but the row's `Ahead` count is those commits: ¬0 → ask before deleting the remote, because that push is the last copy.

#### 6c. Present remote summary table

```
Remote Branch Cleanup
═════════════════════

  Remote Branch                       │ Merged │ Evidence   │ Ahead │ Open PR │ Last Commit  │ Action
  origin/feat/19-auth                 │ ✅ yes │ regular    │ 0     │ —       │ 5 days ago   │ 🗑 Safe to delete
  origin/docs/28-coding-standards     │ ✅ yes │ squash_pr  │ 0     │ —       │ 1 week ago   │ 🗑 Safe to delete
  origin/feat/50-thing                │ 🔎 hint│ squash_grep│ 1     │ —       │ 6 days ago   │ 🔎 Probably merged — verify
  origin/feat/33-i18n                 │ ❌ no  │ none       │ 4     │ #42     │ 2 hours ago  │ ⚠️ Active work
  origin/experiment/test              │ ❌ no  │ none       │ 2     │ —       │ 1 month ago  │ ⚠️ Unmerged
```

#### 6d. Ask for confirmation

If `REPORT_ONLY=true` → skip this confirmation **and** Step 6e (zero remote deletions); Step 6c's table is the report. Continue to Step 7.

→ present multi-select: default-select **only** `---safe-remote---` (proven merged, ¬π); show `---probably-remote---` and unmerged β separately, never pre-selected; **NEVER auto-delete remote β**; always include "Skip / Keep all remote branches".

This is the deletion with no backstop: git refuses `branch -d` on unmerged work,
nothing refuses `push --delete`, and afterwards no copy remains. A
`probably_merged` β deleted here is gone on the evidence that someone typed its
issue number.

#### 6e. Execute remote cleanup

`<branch>` is a value, never a fragment — quoted, after `--`:

```bash
git push origin --delete -- "<branch>"
```

### 7. Sweep: stuck pipeline labels on closed/merged PRs

Pipeline labels should be removed when a PR is closed or merged. This step identifies and strips them.

#### Pipeline label list

This list is free-standing: omp-build ships no second copy to drift against.

```
PRIORITY_LABELS : P0-critical  P1-high  P2-medium  P3-low
SIZE_LABELS     : size:S  size:F-lite  size:F-full
LANE_LABELS     : graph:lane/a1  graph:lane/a2  graph:lane/a3
                  graph:lane/b
                  graph:lane/c1  graph:lane/c2  graph:lane/c3
                  graph:lane/d  graph:lane/e  graph:lane/f
                  graph:lane/g  graph:lane/h  graph:lane/i
                  graph:lane/j  graph:lane/k  graph:lane/l
                  graph:lane/m  graph:lane/n  graph:lane/o
                  graph:lane/standalone
STATUS_LABELS   : status:Backlog  status:Analysis  status:Specs
                  "status:In Progress"  status:Review  status:Done
PIPELINE_OTHER  : reviewed
```

All of the above are "pipeline labels" — they should not remain on closed/merged PRs.

#### 7a. Find candidates (from gather-state.sh `---closed-prs-with-labels---` section)

The script emits at most 20 closed PRs. For each, check which labels from the pipeline label list are present.

#### 7b. Present findings

```
Stuck Labels on Closed PRs
══════════════════════════

  PR    │ Title (truncated)          │ Labels to remove
  #123  │ feat: add auth module       │ status:In Progress, P2-medium
  #117  │ fix: broken build           │ reviewed, status:Review
  (none found)
```

If `REPORT_ONLY=true` → print table and **stop this step** (zero mutations).

#### 7c. Confirm and strip

→ present multi-select
- Default: strip all listed labels from all listed PRs
- "Skip / Keep labels as-is" always available

If `YES=true` → proceed without prompt.

For each confirmed PR + label:

```bash
gh pr edit <number> --remove-label "<label>"
```

Gracefully handle `gh` permission errors — report which labels could not be removed and continue.

### 8. Sweep: queued/stuck CI runs

#### 8a. Find candidates (from gather-state.sh `---queued-runs---` section)

The script emits at most 30 runs filtered to `queued` or `in_progress` status. Consider a run "stuck" if:
- status = `queued` and created ≥ 30 min ago, OR
- status = `in_progress` and created ≥ 60 min ago (use run's `createdAt` field)

#### 8b. Present findings

```
Queued / Stuck CI Runs
══════════════════════

  Run ID    │ Workflow              │ Branch              │ Status      │ Age
  12345678  │ ci.yml                │ feat/old-branch     │ queued      │ 2h 15m
  12345679  │ release.yml           │ main                │ in_progress │ 75m
  (none found)
```

If `REPORT_ONLY=true` → print table and **stop this step** (zero mutations).

#### 8c. Confirm and cancel

→ present multi-select
- Present stuck runs as candidates; runs on protected branches (main/master/staging, or deploy/*) shown as informational — NEVER auto-cancel
- "Skip / Cancel none" always available

If `YES=true` → proceed without prompt (still skips protected branches).

For each confirmed run:

```bash
gh run cancel <run-id> 2>/dev/null || echo "⚠️  Cannot cancel run <run-id> (permission denied or already completed)"
```

Always degrade gracefully — permission errors are non-fatal; report and continue.

### 9. Final Report

```
Cleanup Complete
════════════════
  Local:
    ✅ Deleted branch: feat/19-auth
    ✅ Deleted branch: fix/old-bug
    ⏭ Skipped: feat/33-i18n (active PR)
    ⏭ Skipped: experiment/test (unmerged, user chose to keep)

  Orphan shells:
    ✅ Removed empty parent: ~/.omp/worktrees/roxabi-plugins
    ⏭ Skipped: (none)

  Remote:
    ✅ Deleted remote: origin/feat/19-auth
    ✅ Deleted remote: origin/docs/28-coding-standards
    ⏭ Skipped remote: origin/feat/33-i18n (active PR #42)

  Labels:
    ✅ Stripped: #123 — status:In Progress, P2-medium
    ⏭ Skipped: #117 (user chose to keep)

  CI Runs:
    ✅ Cancelled: run 12345678 (feat/old-branch, queued 2h 15m)
    ⏭ Skipped: run 12345679 (main — protected branch)

  Remaining branches: main, feat/33-i18n, experiment/test
```

If `REPORT_ONLY=true`, prefix the header with `[report-only — no mutations performed]`.

## Options

| Flag | Description |
|------|-------------|
| (none) / `--all` | Analyze branches, worktrees, labels, and runs |
| `--report-only` | Gather and print findings; perform zero mutations (cron-safe) — Step 2 therefore passes `--no-fetch` and reads a possibly stale mirror rather than writing `refs/remotes/*` |
| `--yes` | Skip confirmation prompts for all destructive actions |
| `--scope <#N>` | Restrict branch/worktree analysis + cleanup (Steps 2–6) to issue N — anchored match, not a substring search (see Step 2). Steps 7–8 (label/CI sweeps) stay repo-wide — they are end-of-session hygiene, not per-issue. |

## Safety Rules

1. **NEVER delete `main`, `master`, `staging`, or any `deploy/*` branch** — a CD production branch is an ancestor of the base by construction, so it always looks merged
2. **NEVER delete the current branch**
3. **NEVER touch the principal worktree or the branch checked out in it**
4. **NEVER delete a branch with an open PR** unless explicitly confirmed
5. **NEVER delete an unmerged branch** without a separate, explicit confirmation
6. **ALWAYS show merge status *and its evidence*** before any deletion — `merge_reason` and commits-ahead on the row, never a bare checkmark
7. **A message match is not a merge.** Only `regular` and `squash_pr` reach `safe_delete`; `squash_grep` / `squash_pr_unverified` are `probably_merged` — shown, never pre-selected, never in `safe_local`/`safe_remote`
8. **ALWAYS use `git branch -d`** for merged branches; **`git branch -D` only** when user explicitly confirms unmerged deletion
9. **Every `<placeholder>` in a deletion command is a value, never a fragment** — quoted and after `--`. Refnames and paths are attacker-controlled text
10. **ALWAYS remove worktree before deleting its branch**
11. **NEVER delete remote branches automatically** — always require explicit confirmation per branch
12. **ALWAYS scan all remote branches** for stale merged branches, not just locally deleted ones
13. **`--report-only` = zero mutations** — no label edits, no run cancels, no branch deletes, and no `git fetch` (Step 2 runs with `--no-fetch`)
14. **NEVER auto-cancel runs on protected branches** (main/master/staging, or deploy/*) — show as info only
15. **Degrade gracefully on `gh` permission errors** — report failure and continue; never abort entire sweep

## Edge Cases

- **Squash merges**: `git branch -d` won't detect squash merges → `analyze-branches.sh` checks the PR: `MERGED` **and** head SHA ancestor of the merge commit. A `git log --oneline --grep` match on β name or issue# is reported as `probably_merged` and requires a human verdict — it matches any commit that merely mentions the number.
- **Squash merges on remote**: `git branch -r --merged` does NOT detect squash merges → same verified-PR check. Post-merge commits on a verified `MERGED` PR are still safe to delete, and show as `Ahead > 0` so the operator sees what the push would take with it.
- **Deleted on the strength of a grep**: the failure this ordering exists to prevent — an unmerged, pushed β whose issue number appears in an unrelated commit on BASE, deleted from origin with no copy left. `safe_remote` therefore admits proofs only.
- **Remote tracking branches**: Step 6 scans **all** remote β independently — always require explicit confirmation.
- **Stale worktrees**: ω path ∉ disk → `git worktree prune`.
- **Invoked from inside ω**: the orphan scan anchors its three roots on the **principal** (first `git worktree list` entry) — `<principal>/.claude/worktrees/` by path, the other two by its `basename` and `$principal/.dev/stack.yml` — never on `git rev-parse --show-toplevel`, which inside a linked worktree names the ω itself and would make every scan report zero.
- **Symlinked `$HOME`**: git records the *resolved* worktree path, the scan root is built from `$HOME`. Compared lexically a live registered ω reads as an orphan and 5b-execute `rm -rf`s it, so `scan-orphan-worktree-shells.sh` matches on canonical paths (`realpath`) and emits the lexical scanned entry.
- **Orphan shells after remove**: `git worktree remove` does not delete empty parent dirs. Step 5b + `scan-orphan-worktree-shells.sh` cover these.

## Chain Position

- **Phase:** Ship (optional tail)
- **Predecessor:** — standalone. The operator runs it; nothing chains into it.
- **Successor:** — none
- **Class:** standalone. `/feature` may *offer* it after an issue lands; offering is
  printing a line and stopping. It is never entered from the review→fix loop, where
  deleting a branch would delete the branch under review.

## Exit

- **Success:** print summary (branches deleted, worktrees pruned, labels stripped, runs cancelled). Stop.
- **`--report-only`:** print findings report, no mutations. Exit 0.
- **Failure:** return error to the operator. There is no orchestrator to recover into.

$ARGUMENTS

Files in this skill

  • SKILL.md24.1 KB
  • __tests__/analyze-branches.integration.test.ts447 B
  • __tests__/analyze-branches.test.sh10.3 KB
  • __tests__/deletion-contract.test.ts3.4 KB
  • __tests__/helper-resolution.integration.test.ts8.9 KB
  • __tests__/orphan-scan.integration.test.ts5.5 KB
  • analyze-branches.sh19.6 KB
  • gather-state.sh1.8 KB
  • scan-orphan-worktree-shells.sh5.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.