From f50a6d89db424ff2a5ca4f9ae6afe599402f12b4 Mon Sep 17 00:00:00 2001 From: jepegit Date: Sat, 26 Sep 2026 22:37:41 +0200 Subject: [PATCH 1/2] Add workspace-wide cleanup: `issue-flow workspace cleanup` and `/iflow-cleanup all` (#392) A day of epic work across several workspace members left dozens of squash-landed branches, and the only way to prune them was to run the full `/iflow-cleanup` skill once per repo, each with its own A1 and A2 confirm. `issue-flow workspace cleanup` now surveys every scaffolded member in one pass. It reuses the `agent local-branches` classifier (extracted as the console-free `classify_local_branches`, so per-member buckets are identical by construction), records the `default-sync` classification, lists linked worktrees, and computes a Phase A1 / A2 plan. It is classify-only by default; `--apply` runs A1 (switch to default, `pull --ff-only` only when fast-forwardable, remove reachable worktrees, `git branch -d` on `reachable`) and `--apply --yes-delete-squash-landed` adds A2 (`-d` then `-D` on `squash_landed` / `merged_pr_divergent`, every deletion reported with its tip SHA). Members with a dirty product-code tree, detached HEAD, missing `origin`, or a lock are skipped and reported while the loop continues; a member whose default cannot fast-forward is listed with its `default-sync` action and never pulled; `unique_work` branches are never planned or deleted. `--extra-root` includes a scaffolded repo outside the registry. The cleanup skill and command gain a workspace mode behind the `all` / `workspace` token: one CLI survey, one A1 confirm, one A2 confirm, and an optional Phase B confirm for the whole workspace. Step 10 now says "do not loop automatically unless invoked with `all`". Docs (`cli.md`, the workspaces and after-squash-merge how-tos) and the multi-repo design doc (Phase 4c) describe the new path. The rendered scaffold in this repo is refreshed, which also clears the 0.5.15 skill-stamp drift. Bumps the version to 0.5.17 and promotes the unreleased changelog entries. Co-authored-by: Cursor --- .cursor/rules/issueflow-rules.mdc | 4 +- .cursor/skills/caveman/SKILL.md | 2 +- .cursor/skills/gh-ci/SKILL.md | 2 +- .cursor/skills/grill-me/SKILL.md | 2 +- .cursor/skills/iflow-archive/SKILL.md | 2 +- .cursor/skills/iflow-auto/SKILL.md | 2 +- .cursor/skills/iflow-build/SKILL.md | 2 +- .cursor/skills/iflow-capture/SKILL.md | 2 +- .cursor/skills/iflow-cleanup/SKILL.md | 14 +- .cursor/skills/iflow-close/SKILL.md | 2 +- .cursor/skills/iflow-comments/SKILL.md | 2 +- .cursor/skills/iflow-cycle/SKILL.md | 2 +- .cursor/skills/iflow-doctor/SKILL.md | 2 +- .cursor/skills/iflow-drive/SKILL.md | 2 +- .cursor/skills/iflow-epic/SKILL.md | 2 +- .cursor/skills/iflow-fix/SKILL.md | 2 +- .cursor/skills/iflow-graphify/SKILL.md | 2 +- .cursor/skills/iflow-history-update/SKILL.md | 2 +- .cursor/skills/iflow-init/SKILL.md | 2 +- .cursor/skills/iflow-issue/SKILL.md | 2 +- .cursor/skills/iflow-ops/SKILL.md | 2 +- .cursor/skills/iflow-pause/SKILL.md | 2 +- .cursor/skills/iflow-pick/SKILL.md | 2 +- .cursor/skills/iflow-plan/SKILL.md | 2 +- .cursor/skills/iflow-pr-sync/SKILL.md | 2 +- .cursor/skills/iflow-review/SKILL.md | 2 +- .cursor/skills/iflow-setup/SKILL.md | 2 +- .cursor/skills/iflow-split/SKILL.md | 2 +- .cursor/skills/iflow-status/SKILL.md | 2 +- .cursor/skills/iflow-version-bump/SKILL.md | 2 +- .cursor/skills/iflow-workspace-git/SKILL.md | 2 +- .cursor/skills/iflow-yolo/SKILL.md | 2 +- .cursor/skills/iflow/SKILL.md | 2 +- .../03-solved-issues/issue392_original.md | 32 ++ .issueflows/03-solved-issues/issue392_plan.md | 217 ++++++++ .../03-solved-issues/issue392_status.md | 42 ++ .../multi-repo-workspaces.md | 53 +- .../04-designs-and-guides/test-registry.md | 2 + .issueflows/agent/skill-stamps.json | 64 +-- AGENTS.md | 4 +- HISTORY.md | 3 + docs/cli.md | 36 ++ docs/how-to/after-squash-merge.md | 7 +- docs/how-to/workspaces.md | 14 +- docs/issue-workflow.md | 2 +- pyproject.toml | 2 +- src/issue_flow/agent.py | 509 +++++++++++++++++- src/issue_flow/cli.py | 65 +++ src/issue_flow/gitutils.py | 12 + .../templates/commands/iflow-cleanup.md.j2 | 10 +- .../templates/docs/issue-workflow.md.j2 | 2 +- src/issue_flow/templates/rules/_body.md.j2 | 4 +- .../skills/iflow_cleanup/SKILL.md.j2 | 12 +- tests/test_templating.py | 24 + tests/test_workspace_cleanup.py | 415 ++++++++++++++ uv.lock | 2 +- 56 files changed, 1523 insertions(+), 90 deletions(-) create mode 100644 .issueflows/03-solved-issues/issue392_original.md create mode 100644 .issueflows/03-solved-issues/issue392_plan.md create mode 100644 .issueflows/03-solved-issues/issue392_status.md create mode 100644 tests/test_workspace_cleanup.py diff --git a/.cursor/rules/issueflow-rules.mdc b/.cursor/rules/issueflow-rules.mdc index 5e79f38..f6127eb 100644 --- a/.cursor/rules/issueflow-rules.mdc +++ b/.cursor/rules/issueflow-rules.mdc @@ -194,7 +194,7 @@ The full slash-command lifecycle is: 4. **`/iflow-pause`** *(optional)* — park work mid-stream: update status, move the issue group to `02-partly-solved-issues`, optional WIP commit. 5. **`/iflow-close`** — tests, optional `uv version --bump`, **changelog/`HISTORY.md` update (in the PR commit)**, status update, commit, push, PR. Does not delete branches. Never offer a HISTORY/CHANGELOG update after close finishes or after merge; use `nohistory` only to skip intentionally. (A draft opened earlier via `/iflow-build` early PR does not skip the close HISTORY step.) -6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. +6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. Trailing `all` runs it for every scaffolded workspace member with the confirms consolidated per phase (`issue-flow workspace cleanup`). `/iflow-yolo` chains `capture → plan → build → close yolo` for small, low-risk issues with up-front safeguards (clean tree, passing tests, single consolidated confirm). Its close step is hands-off: changelog decided without a prompt, PR merged (`gh pr merge --squash`; on pending checks may `gh pr checks --watch` then retry, with `--auto` as last resort), then default-branch switch + pull. @@ -328,7 +328,7 @@ When an editor workspace contains **multiple sibling repositories**, each with i - **Resolve the target repo first** — explicit `root:` / `repo:` hints, then `issue-flow agent resolve`, then branch/single-scaffold heuristics, then the **workspace default** from `issueflow-workspace.toml` at the workspace root (create it with `issue-flow workspace init`); **ask** when still ambiguous. Never let `git` or `gh` infer the repo from cwd alone. - **Scoped rules** — this repo's `issueflow-rules` apply under this project root only (path globs). Put **toolchain-specific** run/test commands in `.issueflows/04-designs-and-guides/this-project.md`, not in shared boilerplate that every repo merges. -- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed. +- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed (`/iflow-cleanup all` is the one opt-in workspace-wide pass). - **Design doc** — see `.issueflows/04-designs-and-guides/multi-repo-workspaces.md` when present (issue #67). diff --git a/.cursor/skills/caveman/SKILL.md b/.cursor/skills/caveman/SKILL.md index 29817dc..ec6613e 100644 --- a/.cursor/skills/caveman/SKILL.md +++ b/.cursor/skills/caveman/SKILL.md @@ -6,7 +6,7 @@ description: >- caveman mode, token-greedy / terse answers, or says "be brief" / "stop wasting tokens". Stays active across the session once on; off via "stop caveman" / "normal mode". -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # Be token greedy - as a caveman diff --git a/.cursor/skills/gh-ci/SKILL.md b/.cursor/skills/gh-ci/SKILL.md index 03ee8e7..0e99bc8 100644 --- a/.cursor/skills/gh-ci/SKILL.md +++ b/.cursor/skills/gh-ci/SKILL.md @@ -7,7 +7,7 @@ description: >- gh run list and gh run watch when PR checks are empty or unavailable. Use when waiting for CI, merge-ready, Actions pending/failed, or the user mentions gh run watch / gh pr checks / "CI green". -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # gh-ci — wait on GitHub CI with `gh` diff --git a/.cursor/skills/grill-me/SKILL.md b/.cursor/skills/grill-me/SKILL.md index 3d2bdd2..971a44e 100644 --- a/.cursor/skills/grill-me/SKILL.md +++ b/.cursor/skills/grill-me/SKILL.md @@ -5,7 +5,7 @@ description: >- the decision tree is resolved, then feed the conclusions into the issue plan. Use when the user wants to stress-test a plan, asks to "grill me", or during /iflow-plan when grilling is turned on. Off via "stop grilling" / "normal mode". -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # Grill me — relentless planning interview diff --git a/.cursor/skills/iflow-archive/SKILL.md b/.cursor/skills/iflow-archive/SKILL.md index 072ee0a..4598faf 100644 --- a/.cursor/skills/iflow-archive/SKILL.md +++ b/.cursor/skills/iflow-archive/SKILL.md @@ -4,7 +4,7 @@ description: >- Condense old solved issue groups into one dated summary file, then delete the originals. Destructive, one consolidated confirm. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — archive solved issues (`/iflow-archive`) diff --git a/.cursor/skills/iflow-auto/SKILL.md b/.cursor/skills/iflow-auto/SKILL.md index 8d1bff8..4744c85 100644 --- a/.cursor/skills/iflow-auto/SKILL.md +++ b/.cursor/skills/iflow-auto/SKILL.md @@ -4,7 +4,7 @@ description: >- Unattended large-change orchestrator over a confirmed epic: cycle a stage, adversarial review, loop budget, next-epoch gate when the queue is clear. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — advanced auto (`/iflow-auto`) diff --git a/.cursor/skills/iflow-build/SKILL.md b/.cursor/skills/iflow-build/SKILL.md index 0645355..4fdafd9 100644 --- a/.cursor/skills/iflow-build/SKILL.md +++ b/.cursor/skills/iflow-build/SKILL.md @@ -4,7 +4,7 @@ description: >- Implement the confirmed plan for the focus issue using the project's documented conventions. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue build (`/iflow-build`) diff --git a/.cursor/skills/iflow-capture/SKILL.md b/.cursor/skills/iflow-capture/SKILL.md index 90f420f..bab1a62 100644 --- a/.cursor/skills/iflow-capture/SKILL.md +++ b/.cursor/skills/iflow-capture/SKILL.md @@ -4,7 +4,7 @@ description: >- Capture a GitHub issue locally as issue_original.md and archive other current issues by done status. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue capture (`/iflow-capture`) diff --git a/.cursor/skills/iflow-cleanup/SKILL.md b/.cursor/skills/iflow-cleanup/SKILL.md index 47607d4..3c45bbc 100644 --- a/.cursor/skills/iflow-cleanup/SKILL.md +++ b/.cursor/skills/iflow-cleanup/SKILL.md @@ -6,7 +6,7 @@ description: >- confirm). Optional GitHub remote audit via trailing "include GitHub" or baked cleanup_include_github. Never --force, never deletes unique work. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue cleanup (`/iflow-cleanup`) @@ -66,7 +66,7 @@ Optional free-form text after the command: - **Self-update (opt-out tokens)** — trailing `no bleeding`, `no bleeding-edge`, or `skip self-update` (case-insensitive) **skips** the upgrade even when `on_bleeding_edge` is baked true. - **Phase A ask tokens** — trailing `ask a1` or `ask a2` (case-insensitive) forces that phase's yes/no prompt even when `cleanup_yes_a1` / `cleanup_yes_a2` is baked true. - **Pre-authorized force-delete (orchestrator token)** — trailing `drive` (or `landed`) means the caller (`/iflow-drive`) already obtained one confirm that **explicitly covered** `-D` on squash-landed branches. Phase A1 and A2 then run **without re-asking**, but A2's scope narrows: `squash_landed` always; `merged_pr_divergent` only when none of its unique commits is newer than the PR's `mergedAt`; never `unique_work` / `skipped`. Tip SHAs are still printed. A human typing `/iflow-cleanup` never passes this token. -- **Workspace walk (opt-in tokens)** — trailing `workspace`, `all`, or `include workspace` (case-insensitive) runs this skill **sequentially for every scaffolded workspace member**. One up-front confirm listing member names. Then existing Phase A1/A2 (and optional B) **per member**. A declined A2 in one repo continues to the next; user `abort` / `stop` ends the walk. Ignore these tokens when parsing a named branch. There is no mute `workspace cleanup` CLI. When self-update is enabled, upgrade the tool **once** at the start of the walk, then `issue-flow update` per member (do not reinstall PyPI on every member). +- **Workspace mode (opt-in tokens)** — trailing `all`, `workspace`, or `include workspace` (case-insensitive) runs cleanup for **every scaffolded workspace member** in one pass (see step 4b). Confirms are **consolidated across members but still split by phase**: one A1 confirm, one A2 confirm, and (when Phase B is enabled) one Phase B confirm — never more than three for the whole workspace. Extra `root:` hints include a scaffolded repo outside the registry (`--extra-root`). Ignore these tokens when parsing a named branch. When self-update is enabled, upgrade the tool **once** at the start, then `issue-flow update` per member (do not reinstall PyPI on every member). The CLI half is `issue-flow workspace cleanup` — classify-only unless told otherwise. **Phase B enable rule:** run Phase B when (`cleanup_include_github` is baked true **or** an opt-in GitHub token is present) **and** no opt-out token is present. @@ -97,6 +97,13 @@ Optional free-form text after the command: > **Why the extra buckets:** this project merges PRs with **squash**, which lands a *new* commit on the default branch. A squash-merged branch tip is therefore never an ancestor of the default, so `git branch -d` refuses it forever — `-d` alone can never prune landed branches here. +4b. **Workspace mode** (only with an `all` / `workspace` token — replaces steps 2–6 for the whole workspace; steps 7–8 then run per member). + 1. **Survey.** Resolve the workspace root (`issue-flow agent resolve --json` → `workspace_root`). Run `issue-flow workspace cleanup --json` from there (add `--extra-root ` per `root:` hint). It fetches, classifies `default-sync`, buckets local branches with the same code as `agent local-branches`, lists linked worktrees, and computes an A1 / A2 plan per member — **read-only**. Print the grouped table. Members it **refused** (`skipped: true` — dirty product-code tree, detached HEAD, missing `origin`, locked) are reported with their reason and left out of every confirm; the loop continues with the rest. + 2. **Phase A1 (one confirm for all members).** List per member: `switch ` (or why it is blocked), `pull --ff-only`, worktree removes, and `branch -d ` by name. Members whose `default_sync.action` is not `even` / `ff_only` are listed **with that action** and their pull is **skipped** — never pulled, rebased, or pushed; their `-d` deletes still run. Ask once. On yes: `issue-flow workspace cleanup --apply --json` (same `--extra-root` hints). Without the CLI, run the listed `git -C ` commands yourself. A1 never authorises A2. + 3. **Phase A2 (second confirm, never implied by A1).** Only when any member's `plan.a2.branch_D` is non-empty. List per member every ` ` with its bucket (`squash_landed` / `merged_pr_divergent`) and merged PR; show `merged_pr_divergent` unique-commit subjects; print the recovery line `git branch `. **Never** list `unique_work` or `skipped` branches — the CLI never plans them either. Ask once, separately. On yes: `issue-flow workspace cleanup --apply --yes-delete-squash-landed --json`; report every `applied.a2.deleted` entry as ` ` so the SHAs stay in the transcript. The `drive` / `landed` token replaces this prompt with the orchestrator's earlier confirm (same narrowed scope as step 6). + 4. **Phase B (third confirm, only when enabled per the Input rule).** Run step 9 per member and present **one** confirm grouped by member. + 5. Steps 7 (folder sweep) and 8 (epic gate offer) run per member. Step 10 reports each member. + 5. **Consolidated confirm (Phase A1 — local)** — one yes/no prompt listing every action: - `git switch ` (home only; skip if already on default) - `git pull --ff-only` — if it fails, **stop** A1 steps that assume default is current (apply-changelog, release tag, self-update) and recover via `default-sync` (see below). @@ -158,13 +165,14 @@ Bash accepts the same `--body-file` / `-F` flags. Use that pattern for every mul - Phase B is **read-only until that second confirm**. Declining leaves remotes untouched. -10. **Report.** Summarize: default branch, PR/merge status, Phase A1 commands and `-d` deletions, Phase A2 `-D` deletions with their tip SHAs (or "declined" / "none offered"), branches left alone as unique work, folder sweep, epic stage-gate offer, self-update action (`upgraded` / `skipped` / `failed` / "not enabled"), and (when run) Phase B bucket counts, remote deletes, findings issue URL or "skipped". If this run used a workspace token, report each member. Else if `issue-flow agent resolve --json` reports `sibling_roots`, list them and remind the user that **each scaffolded repo needs its own `/iflow-cleanup`** (or `/iflow-cleanup workspace`) — do not loop automatically without the token. If other open PRs still show `DIRTY` / CONFLICTING (often `HISTORY.md`), **offer** `/iflow-pr-sync` — do not auto-run it. +10. **Report.** Summarize: default branch, PR/merge status, Phase A1 commands and `-d` deletions, Phase A2 `-D` deletions with their tip SHAs (or "declined" / "none offered"), branches left alone as unique work, folder sweep, epic stage-gate offer, self-update action (`upgraded` / `skipped` / `failed` / "not enabled"), and (when run) Phase B bucket counts, remote deletes, findings issue URL or "skipped". In workspace mode, report each member (including refused ones with their reason). Else if `issue-flow agent resolve --json` reports `sibling_roots`, list them and remind the user that **each scaffolded repo needs its own `/iflow-cleanup`** — do not loop automatically unless invoked with `all` (or `workspace`). If other open PRs still show `DIRTY` / CONFLICTING (often `HISTORY.md`), **offer** `/iflow-pr-sync` — do not auto-run it. ## Constraints - Never use `git push --force`. Never rebase default, force-push default, or push default to skip CI. - `git branch -D` is allowed **only** for `squash_landed` / `merged_pr_divergent` branches, **only** after the Phase A2 confirm (or an orchestrator confirm that explicitly named `-D`, signalled by the `drive` / `landed` token), and **only** with their tip SHAs reported. Never `-D` a branch holding unique work, a branch you could not classify, or the current branch. In Phase A1, a `-d` refusal is reported and left alone — it is never a licence to force-delete. - Never delete the default branch (local or remote). +- `issue-flow workspace cleanup` is classify-only by default. Pass `--apply` only after the workspace A1 yes, and `--apply --yes-delete-squash-landed` only after the workspace A2 yes (or the orchestrator token). Never pass either flag to "just see what happens". - Remote deletes and findings-issue creation require the **Phase B** confirm; the Phase A1 and A2 yeses must not imply them (nor each other). - If anything is ambiguous (detached HEAD, multiple remotes, missing tracking info), report and stop rather than guess. - Do not open or update PRs. Do not bump version fields — pyproject bumps belong to `/iflow-close`. The only version action allowed here is creating a release tag / GitHub release that `/iflow-close` **planned** (tag-derived strategy or publish-on-success label), inside the Phase A consolidated confirm. diff --git a/.cursor/skills/iflow-close/SKILL.md b/.cursor/skills/iflow-close/SKILL.md index 8147ac2..fa7c90d 100644 --- a/.cursor/skills/iflow-close/SKILL.md +++ b/.cursor/skills/iflow-close/SKILL.md @@ -4,7 +4,7 @@ description: >- Finish and land the focus issue: tests, optional version bump, status update, commit, push, and PR. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue close (`/iflow-close`) diff --git a/.cursor/skills/iflow-comments/SKILL.md b/.cursor/skills/iflow-comments/SKILL.md index 6c0b6e0..6abf54b 100644 --- a/.cursor/skills/iflow-comments/SKILL.md +++ b/.cursor/skills/iflow-comments/SKILL.md @@ -4,7 +4,7 @@ description: >- Triage a GitHub issue's comment thread into the curated, bucketed summary section of issue_original.md. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue comments triage diff --git a/.cursor/skills/iflow-cycle/SKILL.md b/.cursor/skills/iflow-cycle/SKILL.md index 819ed3a..7b6f261 100644 --- a/.cursor/skills/iflow-cycle/SKILL.md +++ b/.cursor/skills/iflow-cycle/SKILL.md @@ -5,7 +5,7 @@ description: >- through the yolo chain under one up-front confirm. Stops only when input is strictly necessary. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue cycle (`/iflow-cycle`) diff --git a/.cursor/skills/iflow-doctor/SKILL.md b/.cursor/skills/iflow-doctor/SKILL.md index dd164e3..b8d2854 100644 --- a/.cursor/skills/iflow-doctor/SKILL.md +++ b/.cursor/skills/iflow-doctor/SKILL.md @@ -3,7 +3,7 @@ name: iflow-doctor description: >- Audit .issueflows/ for dirty conditions and optionally apply safe repairs. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — doctor (`.issueflows/` health) (`/iflow-doctor`) diff --git a/.cursor/skills/iflow-drive/SKILL.md b/.cursor/skills/iflow-drive/SKILL.md index b791436..86e2676 100644 --- a/.cursor/skills/iflow-drive/SKILL.md +++ b/.cursor/skills/iflow-drive/SKILL.md @@ -6,7 +6,7 @@ description: >- review, then local cleanup (-d reachable, -D squash-landed) and /iflow-status. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — drive (`/iflow-drive`) diff --git a/.cursor/skills/iflow-epic/SKILL.md b/.cursor/skills/iflow-epic/SKILL.md index 98155c7..413450e 100644 --- a/.cursor/skills/iflow-epic/SKILL.md +++ b/.cursor/skills/iflow-epic/SKILL.md @@ -4,7 +4,7 @@ description: >- Plan a larger change as a staged epic: draft epic_plan.md with stages of manageable issue specs, then publish confirmed stages as GitHub issues. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — epic planning (`/iflow-epic`) diff --git a/.cursor/skills/iflow-fix/SKILL.md b/.cursor/skills/iflow-fix/SKILL.md index 4175ede..6bce302 100644 --- a/.cursor/skills/iflow-fix/SKILL.md +++ b/.cursor/skills/iflow-fix/SKILL.md @@ -4,7 +4,7 @@ description: >- Interactive session: one long-lived branch + GitHub issue for a stream of small iterative fixes, landed together via /iflow-close. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — interactive iterative-fix session (`/iflow-fix`) diff --git a/.cursor/skills/iflow-graphify/SKILL.md b/.cursor/skills/iflow-graphify/SKILL.md index ae0a6f8..bfccbe0 100644 --- a/.cursor/skills/iflow-graphify/SKILL.md +++ b/.cursor/skills/iflow-graphify/SKILL.md @@ -4,7 +4,7 @@ description: >- Rebuild the graphify knowledge graph (graphify-out/) by shelling out to `issue-flow graphify` or `graphify` directly. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — graph rebuild (`/iflow-graphify`) diff --git a/.cursor/skills/iflow-history-update/SKILL.md b/.cursor/skills/iflow-history-update/SKILL.md index c0169f5..74f26f4 100644 --- a/.cursor/skills/iflow-history-update/SKILL.md +++ b/.cursor/skills/iflow-history-update/SKILL.md @@ -4,7 +4,7 @@ description: >- Update the changelog when landing an issue: append a bullet to [Unreleased], or promote it to a release section after a version bump. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — history update diff --git a/.cursor/skills/iflow-init/SKILL.md b/.cursor/skills/iflow-init/SKILL.md index 2e56999..349ace6 100644 --- a/.cursor/skills/iflow-init/SKILL.md +++ b/.cursor/skills/iflow-init/SKILL.md @@ -5,7 +5,7 @@ description: >- scaffold is missing; bootstrap a parent folder of git siblings; point at update / doctor / iflow-capture when a project scaffold exists. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — harness init (`/iflow-init`) diff --git a/.cursor/skills/iflow-issue/SKILL.md b/.cursor/skills/iflow-issue/SKILL.md index e21b2ee..18c751f 100644 --- a/.cursor/skills/iflow-issue/SKILL.md +++ b/.cursor/skills/iflow-issue/SKILL.md @@ -4,7 +4,7 @@ description: >- Create one well-specified normal GitHub issue, then optionally branch and run /iflow-capture into the standard lifecycle. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — create a normal issue (`/iflow-issue`) diff --git a/.cursor/skills/iflow-ops/SKILL.md b/.cursor/skills/iflow-ops/SKILL.md index 4bff257..0d8a9e2 100644 --- a/.cursor/skills/iflow-ops/SKILL.md +++ b/.cursor/skills/iflow-ops/SKILL.md @@ -4,7 +4,7 @@ description: >- Run ops / no-PR work for the focus issue (staging→prod, flag flips, external deploys), then finish via /iflow-close ops. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — ops / no-PR (`/iflow-ops`) diff --git a/.cursor/skills/iflow-pause/SKILL.md b/.cursor/skills/iflow-pause/SKILL.md index 24af065..9c6375a 100644 --- a/.cursor/skills/iflow-pause/SKILL.md +++ b/.cursor/skills/iflow-pause/SKILL.md @@ -4,7 +4,7 @@ description: >- Park work on the current issue without closing it: update status, move the group to 02-partly-solved-issues/, optional WIP commit. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue pause (`/iflow-pause`) diff --git a/.cursor/skills/iflow-pick/SKILL.md b/.cursor/skills/iflow-pick/SKILL.md index f79c23a..0012b7e 100644 --- a/.cursor/skills/iflow-pick/SKILL.md +++ b/.cursor/skills/iflow-pick/SKILL.md @@ -4,7 +4,7 @@ description: >- Front door: choose the next issue, create the issue branch, and run /iflow-capture. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — pick next issue (`/iflow-pick`) diff --git a/.cursor/skills/iflow-plan/SKILL.md b/.cursor/skills/iflow-plan/SKILL.md index 44604f8..7f9c915 100644 --- a/.cursor/skills/iflow-plan/SKILL.md +++ b/.cursor/skills/iflow-plan/SKILL.md @@ -4,7 +4,7 @@ description: >- Draft a structured plan in issue_plan.md and get explicit user confirmation before any implementation starts. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue plan (`/iflow-plan`) diff --git a/.cursor/skills/iflow-pr-sync/SKILL.md b/.cursor/skills/iflow-pr-sync/SKILL.md index 9d2705a..0dcc5d0 100644 --- a/.cursor/skills/iflow-pr-sync/SKILL.md +++ b/.cursor/skills/iflow-pr-sync/SKILL.md @@ -4,7 +4,7 @@ description: >- Refresh open PR heads onto the default branch after another merge left them DIRTY (usually HISTORY.md). Uses sync-branch keep-both + force-with-lease. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — PR queue sync (`/iflow-pr-sync`) diff --git a/.cursor/skills/iflow-review/SKILL.md b/.cursor/skills/iflow-review/SKILL.md index a301ddc..10df51a 100644 --- a/.cursor/skills/iflow-review/SKILL.md +++ b/.cursor/skills/iflow-review/SKILL.md @@ -3,7 +3,7 @@ name: iflow-review description: >- Review open GitHub issues and apply labels (extendable kinds; v1: yolo). disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — review and label issues (`/iflow-review`) diff --git a/.cursor/skills/iflow-setup/SKILL.md b/.cursor/skills/iflow-setup/SKILL.md index dad1b9f..fd1d4a2 100644 --- a/.cursor/skills/iflow-setup/SKILL.md +++ b/.cursor/skills/iflow-setup/SKILL.md @@ -4,7 +4,7 @@ description: >- Guide a new user from an empty folder or an unprepared existing project to a working issue-flow setup: uv project, git repo, GitHub remote, and scaffold. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — guided project setup (`/iflow-setup`) diff --git a/.cursor/skills/iflow-split/SKILL.md b/.cursor/skills/iflow-split/SKILL.md index cc2466c..e2b9ec1 100644 --- a/.cursor/skills/iflow-split/SKILL.md +++ b/.cursor/skills/iflow-split/SKILL.md @@ -4,7 +4,7 @@ description: >- Split one over-large GitHub issue into linked child issues (native sub-issues), then optionally start the first child. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — split an over-large issue (`/iflow-split`) diff --git a/.cursor/skills/iflow-status/SKILL.md b/.cursor/skills/iflow-status/SKILL.md index cf666d1..4542029 100644 --- a/.cursor/skills/iflow-status/SKILL.md +++ b/.cursor/skills/iflow-status/SKILL.md @@ -3,7 +3,7 @@ name: iflow-status description: >- Read-only snapshot of where every issue stands, locally and on GitHub. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue status overview (`/iflow-status`) diff --git a/.cursor/skills/iflow-version-bump/SKILL.md b/.cursor/skills/iflow-version-bump/SKILL.md index 0fedeae..045b4ae 100644 --- a/.cursor/skills/iflow-version-bump/SKILL.md +++ b/.cursor/skills/iflow-version-bump/SKILL.md @@ -4,7 +4,7 @@ description: >- Bump the project version following the project's release strategy: static pyproject versions via uv, or tag-derived versions via a planned git tag. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — version bump diff --git a/.cursor/skills/iflow-workspace-git/SKILL.md b/.cursor/skills/iflow-workspace-git/SKILL.md index 05ff13a..4111a1d 100644 --- a/.cursor/skills/iflow-workspace-git/SKILL.md +++ b/.cursor/skills/iflow-workspace-git/SKILL.md @@ -4,7 +4,7 @@ description: >- Git status (and optional fetch --prune) across every workspace member. Not the issue-flow lifecycle status command. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — workspace git (`/iflow-workspace-git`) diff --git a/.cursor/skills/iflow-yolo/SKILL.md b/.cursor/skills/iflow-yolo/SKILL.md index 44548dc..6cd6484 100644 --- a/.cursor/skills/iflow-yolo/SKILL.md +++ b/.cursor/skills/iflow-yolo/SKILL.md @@ -4,7 +4,7 @@ description: >- Chain capture → plan → build → close yolo for a small, low-risk issue under one consolidated confirm. Stops on any ambiguity. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — issue yolo (`/iflow-yolo`) diff --git a/.cursor/skills/iflow/SKILL.md b/.cursor/skills/iflow/SKILL.md index 22a2cc8..1b810c6 100644 --- a/.cursor/skills/iflow/SKILL.md +++ b/.cursor/skills/iflow/SKILL.md @@ -4,7 +4,7 @@ description: >- Smart dispatcher: detect where the focus issue stands and dispatch to /iflow-capture, /iflow-plan, /iflow-build, or /iflow-close. disable-model-invocation: true -issue-flow-version: 0.5.15 +issue-flow-version: 0.5.17 --- # issue-flow — iflow smart dispatcher (`/iflow`) diff --git a/.issueflows/03-solved-issues/issue392_original.md b/.issueflows/03-solved-issues/issue392_original.md new file mode 100644 index 0000000..43bb93d --- /dev/null +++ b/.issueflows/03-solved-issues/issue392_original.md @@ -0,0 +1,32 @@ +# Issue #392: feat: workspace-wide cleanup (`issue-flow workspace cleanup` / `/iflow-cleanup all`) + +Source: https://github.com/jepegit/issue-flow/issues/392 + +## Original issue text + +## Context + +In a multi-root workspace (\`issueflow-workspace.toml\`), a day of Epic work leaves squash-landed branches in 3–5 sibling repos. Today \`/iflow-cleanup\` is strictly per-repo — step 10 of the skill says "each scaffolded repo needs its own \`/iflow-cleanup\` — do not loop automatically". The workspace CLI has read-only/non-destructive members only (\`workspace status|doctor|dirty|update|git status|git fetch\`). + +Real example (2026-09-26, cellpy + batbase workspaces): 5 repos, 0 \`reachable\`, 17 \`squash_landed\`, 4 \`merged_pr_divergent\`, 2 \`unique_work\` branches. Five separate skill invocations, each with its own A1 + A2 confirm, to do the same thing. + +## Spec + +Add an opt-in workspace loop that reuses the existing classification (\`issue-flow agent local-branches\`) and keeps the safety model intact: + +- **CLI:** \`issue-flow workspace cleanup [--json] [--dry-run]\` — for every scaffolded member: \`git fetch --prune\`, \`default-sync\` classification, \`local-branches\` buckets, linked-worktree list. Output one table grouped by member. Read-only unless \`--apply\` (see below). +- **Skill:** \`/iflow-cleanup all\` (or trailing \`workspace\` token) → runs the loop. Confirms are **consolidated across members but still split by phase**: + - Phase A1 (one confirm): per member \`switch \`, \`pull --ff-only\`, \`branch -d \`. Members whose default cannot fast-forward are listed with the \`default-sync\` \`action\` and skipped, never pulled/pushed. + - Phase A2 (second confirm, never implied by A1): per member the \`squash_landed\` and \`merged_pr_divergent\` lists with \` \` + merged PR, recovery line \`git branch \`. \`unique_work\` never offered. + - Phase B (GitHub remote audit) stays opt-in per today's tokens; if enabled, a third confirm, grouped by member. +- **Refuse-to-loop cases** (report and skip that member, continue with the rest): dirty product-code tree, detached HEAD, branch currently checked out in a linked worktree with unique work, missing \`origin\`. +- Members outside the registry (e.g. a repo in a *different* workspace root) are out of scope; accept extra \`root:\` hints to include them. +- \`--apply\` on the CLI is only for non-interactive callers and must require an explicit \`--yes-delete-squash-landed\` for the \`-D\` bucket; default CLI behaviour is classify-only, mirroring \`agent local-branches\`. + +## Acceptance criteria + +- [ ] \`issue-flow workspace cleanup --json\` returns per-member buckets identical to running \`agent local-branches\` in each member. +- [ ] Skill \`/iflow-cleanup all\` performs at most three confirms for the whole workspace (A1, A2, optional B) and reports tip SHAs for every \`-D\`. +- [ ] A member with non-ff default is skipped with the \`default-sync\` classification printed; the other members are still processed. +- [ ] \`unique_work\` branches are never listed in any confirm. +- [ ] Docs: \`multi-repo-workspaces.md\` and the cleanup skill mention the new path; the "do not loop" sentence in step 10 becomes "…unless invoked with \`all\`". diff --git a/.issueflows/03-solved-issues/issue392_plan.md b/.issueflows/03-solved-issues/issue392_plan.md new file mode 100644 index 0000000..18c8362 --- /dev/null +++ b/.issueflows/03-solved-issues/issue392_plan.md @@ -0,0 +1,217 @@ +# Plan — Issue #392: workspace-wide cleanup + +Source: https://github.com/jepegit/issue-flow/issues/392 + +## Goal + +Add an opt-in workspace loop for post-merge branch hygiene: a read-only-by-default +`issue-flow workspace cleanup` CLI that reuses the `agent local-branches` +classification per member, plus a `/iflow-cleanup all` skill path whose confirms are +consolidated across members but still split by phase (A1 / A2 / optional B). + +## Constraints + +- Safety model of [`local-branch-cleanup.md`](../04-designs-and-guides/local-branch-cleanup.md) + is unchanged: `-d` only for `reachable`; `-D` only for `squash_landed` / + `merged_pr_divergent`, only behind a **separate** A2 confirm, tip SHA always + printed; `unique_work` / `skipped` never offered. Never rebase / force-push / + push default ([`default-branch-diverge.md`](../04-designs-and-guides/default-branch-diverge.md)). +- Workspace CLI conventions from [`multi-repo-workspaces.md`](../04-designs-and-guides/multi-repo-workspaces.md) + Phase 4/4b: subcommand under `workspace_app`, `_prepare_workspace_members`, + continue-on-fail, locked members skipped, `--json` payload with + `ok / workspace_root / members / ok_count / fail_count / skip_count`. +- Skill + command templates are the source of truth (`src/issue_flow/templates/`); + rendered `.cursor/` copies are refreshed by `issue-flow update`, not hand-edited. +- Default CLI behaviour is classify-only (mirrors `agent local-branches`). + `--apply` is for non-interactive callers; `-D` additionally needs + `--yes-delete-squash-landed`. +- `uv` only; tests via `uv run pytest`; lint via `uv run ruff check src/ tests/`. + +### Prior art + +- `agent.run_local_branches` (`agent.py` ~L593) — the five-bucket classifier. + **Migrate:** extract a console-free `classify_local_branches(project_root, *, + fetch, commit_limit) -> dict` and have `run_local_branches` call it, so + `workspace cleanup` shares the exact code path (AC1 by construction). +- `agent.run_switchback` (~L858) — switch + `pull --ff-only` + `classify_default_sync` + with dirty-tree refusal and linked-worktree skip. **Mirror** its refusal rules + for `--apply` A1; do not call it directly (it emits its own payload). +- `agent.run_workspace_git_status` / `run_workspace_git_fetch` (~L4238) — member + loop skeleton (`_prepare_workspace_members`, locked skip, per-member + `try/except`, count fields). **Mirror.** +- `gitutils.delete_branch(cwd, branch, force=)`, `switch_branch`, `pull_ff_only`, + `fetch_prune`, `classify_default_sync`, `list_worktrees`, `remove_worktree`, + `dirty_paths`, `remote_owner_repo`, `is_linked_worktree` — all reused as-is. +- `agent._dirty_class(paths, issueflows_dir)` — `clean / issueflows_only / mixed` + classification for the refuse-to-loop gate. **Reuse.** +- `tests/test_workspace_actions.py::_make_members` / `_git_init` and + `tests/test_agent_local_branches.py` fixtures (squash-landed / unique-work + branch setups, `_no_gh`). **Reuse** for the new tests. +- Existing skill text already has a "Workspace walk" token paragraph (per-member + confirms, "There is no mute `workspace cleanup` CLI") — **replace**, not add. +- Toolbox (`00-tools/`): nothing relevant. Graph: `graphify-out/graph.json` absent → skipped. + +## Approach + +### 1. Refactor: console-free classifier (`agent.py`) + +- `classify_local_branches(project_root, *, fetch=True, commit_limit=20) -> tuple[dict, int]` + returns today's payload and exit code; `run_local_branches` becomes a thin + emit wrapper. No behaviour change for `agent local-branches`. + +### 2. `issue-flow workspace cleanup` (`cli.py` + `agent.run_workspace_cleanup`) + +``` +issue-flow workspace cleanup [WORKSPACE_DIR] [--json] [--dry-run] [--apply] + [--yes-delete-squash-landed] [--no-fetch] + [--extra-root PATH ...] +``` + +Per member (after `_prepare_workspace_members`, plus `--extra-root` paths that +carry a `.issueflows/`; locked members skipped): + +1. **Refuse-to-loop gate** → member `skipped: true, reason: <…>`; loop continues: + - `git rev-parse` fails / not a repo → `not a git repo` + - no `origin` remote (`remote_owner_repo` is None and `git remote` has no origin) → `missing origin` + - detached HEAD (`current_branch` is None) → `detached HEAD` + - `_dirty_class == "mixed"` → `dirty product-code tree` (paths listed). + `issueflows_only` dirt: classify, but mark `switch_blocked: true` (A1 skips + `switch`/`pull` for that member, deletes still allowed since they don't touch the tree). +2. `git fetch --prune` (unless `--no-fetch`). +3. `default_sync` = `classify_default_sync(root, fetch=False)` → keep + `action / class / ahead / behind / ff_possible`. +4. `buckets` = `classify_local_branches(root, fetch=False)` (reachable, + squash_landed, merged_pr_divergent, unique_work, skipped, tip on each). +5. `worktrees` = linked worktrees (`list_worktrees`, `is_main` false) with + `branch`, `path`, and the bucket that branch falls in. A linked worktree whose + branch is `unique_work` is reported under `refusals` (branch never touched); + the member still proceeds (see Open questions). +6. **Plan** (always computed, even read-only): + - `a1`: `switch_default` (bool + reason when blocked), `pull_ff_only` + (only when `default_sync.action in {"even","ff_only"}`; else + `pull_skipped_reason = default_sync.action`), `worktree_remove: [reachable + worktrees]`, `branch_d: [reachable names]`. + - `a2`: `branch_D: [{name, tip, bucket, merged_prs}]` for `squash_landed` + + `merged_pr_divergent` **only**, each with `recover: "git branch "`. +7. **Apply** (only with `--apply`, never with `--dry-run`): + - A1: switch (if not blocked and not already on default) → pull ff-only (if + planned) → `remove_worktree` (clean, reachable) → `delete_branch(-d)`. + A `-d` refusal is recorded, never escalated. + - A2: only with `--yes-delete-squash-landed`: `remove_worktree` (clean) → + `delete_branch(-d)` first, `-D` on refusal; record `{name, tip, flag}` in + `deleted`. Without the flag, `a2` stays a plan and `notes` says so. + - Members that failed the gate or whose default cannot ff are never pulled/pushed. + +Payload: `{ok, workspace_root, apply, dry_run, members: [{name, path, ok, +skipped, reason, branch, default_branch, dirty_class, default_sync, buckets, +worktrees, refusals, plan: {a1, a2}, applied: {a1: {...}, a2: {deleted: [...]}}, +notes}], ok_count, fail_count, skip_count, totals: {reachable, squash_landed, +merged_pr_divergent, unique_work}}`. + +Text render: one table grouped by member — `member branch default-sync +action reachable/squash_landed/merged_pr_divergent/unique_work counts`, then +per member the named branches with tips per bucket, skipped members with their +reason, and a footer "classify-only — pass --apply …" or an apply summary. +Exit 0 when no member failed (skipped members are not failures), else 1. + +### 3. Skill: `/iflow-cleanup all` (`templates/skills/iflow_cleanup/SKILL.md.j2` + `templates/commands/iflow-cleanup.md.j2`) + +- Replace the "Workspace walk" Input paragraph: tokens `all`, `workspace`, + `include workspace`; extra `root:` hints → `--extra-root`. Point at the + new CLI ("classify-only by default"). Self-update once at the start (keep). +- New step **4b — Workspace mode** (used instead of steps 2–6 when the token is + present): + 1. `issue-flow workspace cleanup --json [--extra-root …]` from the workspace + root (resolve via `agent resolve` → `workspace_root`); print the grouped table. + 2. **Phase A1 (one confirm for all members)** — list per member: `switch`, + `pull --ff-only`, worktree removes, `branch -d `. Members with + `default_sync.action` not in `even`/`ff_only` are listed with that action + and **skipped** (never pulled/pushed); gate-refused members listed with + reason. On yes: `issue-flow workspace cleanup --apply --json` (or the + per-member git commands when the CLI is missing). `cleanup_yes_a1` honoured. + 3. **Phase A2 (second confirm, never implied by A1)** — only when any member + has `squash_landed`/`merged_pr_divergent`; list per member ` ` + + merged PR, `merged_pr_divergent` separately with unique-commit subjects, + recovery line. Never `unique_work`/`skipped`. On yes: `issue-flow workspace + cleanup --apply --yes-delete-squash-landed --json`; report every + ` `. `cleanup_yes_a2` honoured; `drive`/`landed` token + narrows scope exactly as today. + 4. **Phase B** — unchanged enable rule; when on, run `agent branches` per + member and a **third** confirm grouped by member. + 5. Folder sweep (step 7) and epic gate (step 8) run per member as today. +- Step 10: "each scaffolded repo needs its own `/iflow-cleanup` … — do not loop + automatically **unless invoked with `all`**"; workspace runs report per member. +- Constraints: add "`--apply` / `--yes-delete-squash-landed` are only ever + passed after the matching A1 / A2 yes". +- Command template mirrors the same in its terser form. + +### 4. Docs + +- `.issueflows/04-designs-and-guides/multi-repo-workspaces.md`: rewrite + "Per-repo cleanup" → "Workspace cleanup (issue #392)"; add Phase 4c section + (CLI shape, three confirms, refuse-to-loop list, `--apply` flag pairing). +- `docs/cli.md`: table row + `## issue-flow workspace cleanup` section + (usage, flags, safety notes, exit codes). +- `docs/how-to/workspaces.md`: table row + one-liner in the command block; + `docs/how-to/after-squash-merge.md`: one sentence pointing at `/iflow-cleanup all`. +- `HISTORY.md` bullet at `/iflow-close`. + +### 5. Tests + +- `tests/test_workspace_actions.py`: + - two members with reachable / squash-landed / unique-work branches → `--json` + buckets equal `agent local-branches --json` run in each member (AC1); + - member whose default is ahead of origin (non-ff) → `plan.a1.pull_ff_only` + false with `default_sync.action`, other member still classified (AC3); + - `unique_work` names never appear in `plan.a1.branch_d` / `plan.a2.branch_D` / + `applied` (AC4); + - refuse-to-loop: detached HEAD, mixed-dirty tree, no `origin` → `skipped` + with reason, loop continues, exit 0; + - `--apply` deletes reachable with `-d`, leaves squash-landed; adding + `--yes-delete-squash-landed` deletes it with `-D` and reports `tip`; + `--dry-run --apply` mutates nothing; + - `--extra-root` includes a scaffolded repo outside the registry; locked member skipped. +- `tests/test_cli.py`: `workspace --help` lists `cleanup`; `cleanup --help` + documents `--apply` / `--yes-delete-squash-landed`. +- `tests/test_templating.py`: cleanup skill + command render `workspace cleanup`, + "unless invoked with `all`", three-confirm wording, `--yes-delete-squash-landed` + gated on A2; `test_agent_local_branches.py` still green after the refactor. +- Manual: `uv run issue-flow workspace cleanup` from + `/home/jepe/scripting/issue-flow-workspace` (this repo is the only member). + +## Files to touch + +| Path | Change | +| --- | --- | +| `src/issue_flow/agent.py` | extract `classify_local_branches`; add `run_workspace_cleanup` + text renderer | +| `src/issue_flow/cli.py` | `@workspace_app.command("cleanup")` | +| `src/issue_flow/gitutils.py` | small helpers if missing: `has_remote(cwd, name)`, `is_detached(cwd)` | +| `src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2` | workspace mode step, token paragraph, step 10, constraints | +| `src/issue_flow/templates/commands/iflow-cleanup.md.j2` | mirror | +| `docs/cli.md`, `docs/how-to/workspaces.md`, `docs/how-to/after-squash-merge.md` | new command / path | +| `.issueflows/04-designs-and-guides/multi-repo-workspaces.md` | Phase 4c + rewritten cleanup section | +| `tests/test_workspace_actions.py`, `tests/test_cli.py`, `tests/test_templating.py` | new tests | +| `HISTORY.md` | at close | + +## Test strategy + +`uv run pytest` (full suite) and `uv run ruff check src/ tests/`. New tests use +real temp git repos (as the existing workspace / local-branches tests do) with +`gh` monkeypatched away, so squash-landed detection relies on `git cherry` only. + +## Open questions + +1. **Linked worktree on a `unique_work` branch** — spec lists it as a + refuse-to-loop case for the *member*. Recommended: report it under + `refusals` and still process the member's other branches (the branch itself + is `unique_work` and can never be offered). Skipping the whole member adds + friction without adding safety. Literal-spec alternative: skip the member. +2. **`--apply` in v1** — spec asks for it; it is the only part that makes the + CLI mutate git (precedent: `agent switchback`, `agent worktree-remove`). + Recommended: ship it as planned (A1 with `--apply`, A2 only with + `--yes-delete-squash-landed`), because it is what lets the skill do one + command per phase. Alternative: classify-only CLI now, skill drives + `git -C ` itself, `--apply` in a follow-up. +3. **`issueflows_only` dirt** — treat as *not* product-code dirt: classify and + delete branches, but skip `switch`/`pull` for that member (recommended), vs. + refuse the member like `mixed`. diff --git a/.issueflows/03-solved-issues/issue392_status.md b/.issueflows/03-solved-issues/issue392_status.md new file mode 100644 index 0000000..05fbe81 --- /dev/null +++ b/.issueflows/03-solved-issues/issue392_status.md @@ -0,0 +1,42 @@ +# Status — Issue #392: workspace-wide cleanup + +- [x] Done + +Branch: `392-workspace-cleanup` (worktree `../issue-flow-392`) + +## What's done + +- Plan accepted with recommended answers (unique_work worktree → skip branch not + member; `--apply` shipped in v1; `issueflows_only` dirt → classify + delete, + skip switch/pull). +- `agent.classify_local_branches` extracted from `run_local_branches` + (console-free; `agent local-branches` output unchanged). +- New `issue-flow workspace cleanup [--json] [--dry-run] [--no-fetch] [--apply] + [--yes-delete-squash-landed] [--extra-root …]` (`agent.run_workspace_cleanup`, + `cli.workspace_cleanup`). Classify-only default; per-member gate (no origin / + detached / mixed-dirty / locked → skipped, loop continues); `default-sync`; + five buckets; linked worktrees with bucket; A1/A2 plan with `recover` lines; + `--apply` runs A1, `--yes-delete-squash-landed` adds A2 with ` + ` in `applied`. Non-ff members never pulled. `unique_work` never planned. +- `gitutils.has_remote`, `gitutils.is_detached_head`. +- Skill + command templates: workspace-mode Input paragraph, step 4b (survey → + one A1 confirm → one A2 confirm → optional B), step 10 "unless invoked with + `all`", constraint on when `--apply` flags may be passed. Rules body and + workflow-doc template mention `all`. +- Docs: `docs/cli.md` (table, synopsis, section), `docs/how-to/workspaces.md`, + `docs/how-to/after-squash-merge.md`; design doc + `multi-repo-workspaces.md` Phase 4c. +- Tests: `tests/test_workspace_cleanup.py` (16, real git repos: AC1 bucket + parity, AC3 non-ff skip, AC4 unique_work, refusals, issueflows-only dirt, + unique_work worktree, extra-root + locked, dry-run, apply A1 / A2, switch, + never-pull), `test_cleanup_documents_workspace_mode` in `test_templating.py`. +- `uv run pytest` 952 passed; ruff check + format clean. +- `issue-flow update .` re-rendered `.cursor/` + `AGENTS.md` + + `docs/issue-workflow.md` (also fixes the 0.5.15 → 0.5.16 skill stamp drift). + +- Close: version 0.5.16 → 0.5.17 (`uv version --bump patch`), HISTORY.md + promoted to `[0.5.17]`, test-registry rows added; essential suite green. + +## Remaining work + +- None. `graphify update .` intentionally not run (off-path per AGENTS.md). diff --git a/.issueflows/04-designs-and-guides/multi-repo-workspaces.md b/.issueflows/04-designs-and-guides/multi-repo-workspaces.md index 7f23ba7..7b13011 100644 --- a/.issueflows/04-designs-and-guides/multi-repo-workspaces.md +++ b/.issueflows/04-designs-and-guides/multi-repo-workspaces.md @@ -54,10 +54,10 @@ so each repo's rules apply only when editing files under that root. Re-run ### Per-repo cleanup `/iflow-cleanup` runs against **one** project root by default. Trailing -`workspace` / `all` / `include workspace` walks every scaffolded member -(one up-front confirm, then the usual A1/A2 per repo). When -`agent resolve` reports `sibling_roots` and no workspace token was -passed, remind the user to repeat cleanup (or run `/iflow-cleanup workspace`). +`all` / `workspace` / `include workspace` switches to workspace mode +(Phase 4c below). When `agent resolve` reports `sibling_roots` and no +workspace token was passed, remind the user to repeat cleanup per repo or +run `/iflow-cleanup all` — never loop without the token. ## Phase 2 (issue #126) — workspace registry @@ -107,6 +107,51 @@ does **not** fetch. `issue-flow workspace git fetch` is `git fetch `/iflow-workspace-git` (`iflow git`). Distinct from `workspace status` (issue-flow lifecycle) and `workspace dirty` (post-update dirt class). +## Phase 4c (issue #392) — workspace cleanup + +Context: a day of epic work across 3–5 members left 17 `squash_landed` + +4 `merged_pr_divergent` branches; the old "walk" ran the full skill per +member, so five A1 + five A2 confirms for one decision. + +**Decision.** `issue-flow workspace cleanup` is the CLI half; the skill's +`all` token consumes it. Confirms are **consolidated across members but +still split by phase**: one A1, one A2, optional one B — at most three. + +- **Shared classifier.** `agent.classify_local_branches` is the + console-free core of `agent local-branches`; `workspace cleanup` calls + it per member, so buckets are identical by construction (AC1). +- **Classify-only default.** Same posture as `agent local-branches`. + `--apply` runs Phase A1 (`switch`, `pull --ff-only` only when + `default-sync` says `even` / `ff_only`, reachable worktree removes, + `-d` on `reachable`). `-D` on `squash_landed` / `merged_pr_divergent` + additionally needs `--yes-delete-squash-landed`. `--dry-run` overrides + both. The skill passes those flags only *after* the matching yes. + Precedent for a mutating CLI step: `agent switchback`, + `agent worktree-remove`. +- **Refuse-to-loop** (member reported and skipped, loop continues): + no `origin`, detached HEAD, dirty product-code tree (`mixed`), locked, + not a repo. `issueflows_only` dirt is *not* a refusal — it only blocks + that member's `switch` / `pull` (deletes never touch the tree). +- **Non-ff default** is not a refusal either: the member is listed with + its `default-sync` `action`, never pulled / rebased / pushed, and its + `-d` deletes still run (AC3). +- **Linked worktree on `unique_work`** is recorded under `refusals` for + the member, not a member skip: the branch can never be offered anyway, + and blocking the other landed branches adds friction without safety. +- **Payload** per member: `default_sync`, `buckets` (five), `worktrees` + (with bucket + clean flag), `plan.a1` / `plan.a2` (with `recover` + lines), and after `--apply` an `applied` block listing every + ` `. +- **Extra roots.** `--extra-root ` / `root:` includes a + scaffolded repo outside the registry; unregistered siblings stay out. + +Alternatives considered: keep the per-member walk (rejected — the confirm +count was the complaint); skill drives `git -C ` with no `--apply` +(rejected — one command per phase is what makes the consolidated confirm +cheap and auditable); refuse `issueflows_only` members like `mixed` +(rejected — doctor repairs routinely leave that dirt and it is unaffected +by branch deletes). + ## Out of scope (follow-ups) - **Cross-repo `/iflow-pick` ranking** across registry members (Phase 3a). diff --git a/.issueflows/04-designs-and-guides/test-registry.md b/.issueflows/04-designs-and-guides/test-registry.md index 0487225..ab1fadc 100644 --- a/.issueflows/04-designs-and-guides/test-registry.md +++ b/.issueflows/04-designs-and-guides/test-registry.md @@ -38,6 +38,8 @@ current issue**. `/iflow-doctor` may audit the whole suite against this table. | `tests/test_cli.py::test_agent_state_reports_version_drift` | no | no | `agent.version_drift_fields`, `rendered_skills_version` | #386 | Stale-skills warning surface | | `tests/test_version.py::test_version_comes_from_package_metadata` | yes | yes | `issue_flow.__version__` | #386 | Root cause of every stale `issue-flow-version` stamp; cheap | | `tests/test_cleanup_yes_warning.py` (module) | no | no | `init._warn_cleanup_yes_a2` | #388 | Warning when `cleanup_yes_a2` is on; leave unmarked | +| `tests/test_workspace_cleanup.py` (module) | no | no | `agent.run_workspace_cleanup`, `classify_local_branches`, `--apply` / `--yes-delete-squash-landed` | #392 | Real temp git repos (~12 s); AC1 bucket parity, refusals, non-ff skip, `unique_work` never deleted | +| `tests/test_templating.py::test_cleanup_documents_workspace_mode` | no | no | cleanup skill/command workspace-mode text | #392 | Text contract; leave unmarked | | `tests/test_templating.py::test_cleanup_bakes_yes_knobs` | no | no | cleanup/close skill bake of `cleanup_yes_*` / `auto_cleanup` | #388 | Config default bake; leave unmarked | | `tests/test_doc_configuration.py::test_all_settings_table_lists_every_config_key_once` | yes | yes | `docs/configuration.md` vs `CONFIG_KEYS` | #388 | Already essential; digit keys (`cleanup_yes_a1`) must match | | `tests/test_templating.py::test_issue_create_skills_use_body_file_not_bash_heredoc` | no | no | `_gh_body_file.md.j2` included from issue/fix/close | #380 | Docs/skill contract; leave unmarked | diff --git a/.issueflows/agent/skill-stamps.json b/.issueflows/agent/skill-stamps.json index 4a26299..81e936c 100644 --- a/.issueflows/agent/skill-stamps.json +++ b/.issueflows/agent/skill-stamps.json @@ -1,37 +1,37 @@ { "format": "issueflow-skill-stamps-v1", "hashes": { - ".cursor/skills/caveman": "f56bead0d43f64632667a48762b08d39b1387a8496f3e73bf098bdf4c55d26c9", - ".cursor/skills/gh-ci": "2059ed6489d3076c2849d53d2a0c8ddf85562c92796800caf5563eee0ab6ee9e", - ".cursor/skills/grill-me": "32796fde9c11d1b3fab845ca077e6a6f7888bceabe651ce1c66f69a2f713c48a", - ".cursor/skills/iflow": "3490c89cd01cdc49b235f14283acee06672f2e25a53ae53a03c50226511e228a", - ".cursor/skills/iflow-archive": "545f5e3eb21a701945b9b99c87f059141140c268c25455e3a7276fe0c5037039", - ".cursor/skills/iflow-auto": "01fb4880a1cb59b8a384c9a037f3363370fcdffdd4c6409cb1859f59f61e11a9", - ".cursor/skills/iflow-build": "0dacc46d635dbe0c8743f043dc519d1d7961157b99665cf9fc2c9907ea70f939", - ".cursor/skills/iflow-capture": "e3a5cc8bf9f4d7dd17b431b3bd2a45820576f6516dd66723e8b2afe0f8c537a7", - ".cursor/skills/iflow-cleanup": "bd619be144ee4925f690bd7848b3ac0031802ffcf9964983eb613801826324c6", - ".cursor/skills/iflow-close": "3edb7b5d2259dcc57faab1c4bf4aeb47bc109cb1c6a902ee665c09e45f6c1cf9", - ".cursor/skills/iflow-comments": "270b0726c0223859da6aa694094d8a9609c0b34224b675c09a783d0f2ac23f83", - ".cursor/skills/iflow-cycle": "18debb1c04ab511c06e3d5041daa21461d1e971538786b1b9482459fa96cfafb", - ".cursor/skills/iflow-doctor": "24a8aad6a3b1ae88f8f18c05af86031dfcf30bd22db340657a6bc86a4cf7ca07", - ".cursor/skills/iflow-drive": "d64283028d6840eb8367e1451691883b19e89452c328af308eca332adf661aed", - ".cursor/skills/iflow-epic": "ee773f887fea98af0b745304a0e9e05389b115da4ee1f800319d4291eb07b66d", - ".cursor/skills/iflow-fix": "06b3ebf38d48a8d54ef086a4d6e7db86fdbd35e6403df8a4f88d7dfab651607b", - ".cursor/skills/iflow-graphify": "556c5159bda6624e496413c359a075ae6621b19915ec9ced7e1d05b42b102203", - ".cursor/skills/iflow-history-update": "2206288996486af4a12b636a0a89830bbdd147806bf318b47db76dbc4d063bc4", - ".cursor/skills/iflow-init": "168f4f2263a7004a85afb36b3f9948df202f10d1c9504ca8c3afc5402db94b61", - ".cursor/skills/iflow-issue": "356ff5ec813a3f656ed24ae8587b0b5fde0407b7c66e61aca66822795dfa60aa", - ".cursor/skills/iflow-ops": "e90ce3764efd9703d0a9cd1c520c4ccc303714bfce917922d35e886f4cca01ee", - ".cursor/skills/iflow-pause": "5f05d607bf76431728a6bc08a68246a4fccc8367a190b7c65afb371583298d85", - ".cursor/skills/iflow-pick": "1923f765b11d2ad3d3a215e3e8ee8d1723f84f2e80e07a37561c8cce33b4c941", - ".cursor/skills/iflow-plan": "5d8c49e168642a14b2a96d246e8bb4cc2439400bf1a8201f08b10958a676dfde", - ".cursor/skills/iflow-pr-sync": "caf5ee53349481af7f30f78664e9edb87eaffe3a3c92c5dd7bb2bf49462c2e89", - ".cursor/skills/iflow-review": "6529ed31ae354f12c287db18ffeda79f10c147127be76878fd07aa1db8ea723a", - ".cursor/skills/iflow-setup": "342f5ed055587a7f56132ce05055c25ec98b1a9fd2cf9c54840a8af0b856e25e", - ".cursor/skills/iflow-split": "c5c531ab88ebbd7c029f3a848e86ce4f2246d11e7e1fbda028286823c6e29e6d", - ".cursor/skills/iflow-status": "7b00cb519f3b81dcfaf1ec6ee7f6248554e9f81f38972bb4a63c30d9602a6094", - ".cursor/skills/iflow-version-bump": "7110737d621468e97c86a7c2e0b6c26d33b5361ccfa06d8db15524815a9fc515", - ".cursor/skills/iflow-workspace-git": "0782ab6569ca7235eb2eac9d53575a375176f9e7c840871ca858c0cf16254222", - ".cursor/skills/iflow-yolo": "1df1e63c1a50a35331093c705076cddad3d459b0c2bd55cd7ac8bdfd1995cfd8" + ".cursor/skills/caveman": "cde321f04a79fe958e2fb12a59b129f88949a8261509023d71b85fba07b04dfd", + ".cursor/skills/gh-ci": "8bfb9a3187aff2cc8ace9635a1ea995583676dcdd4a331dd4cd8de3f15df9bfe", + ".cursor/skills/grill-me": "179c27ef104ae669a05fc4fa8e6d558a05b182ff10a355259030dc54b33c2bfa", + ".cursor/skills/iflow": "1f63049ba18b58225ebe60160fbfc46275b24e0e0e1d884efd3d57c84a905027", + ".cursor/skills/iflow-archive": "e85f80bbe343ca9f4145bc0322d81278a205b6168ed13ff71ab06caab05e25b2", + ".cursor/skills/iflow-auto": "c631a2cb29ace36e63d1ad9da01c84ca2d17f1685cf7495d12a18737f29ea561", + ".cursor/skills/iflow-build": "8d7d0a0df2516db716ec3df5485e85e50cf31c9aefac1b014b4797bcb4ec7e1f", + ".cursor/skills/iflow-capture": "a5bec8c19f48cc9989691467a246410af3e2295029eba74e1959e35dd20d8c4b", + ".cursor/skills/iflow-cleanup": "4afdca92f9d13a60d917d504a3a50ce18ac99b6679b1aa06adc6d117fe7225e4", + ".cursor/skills/iflow-close": "0c5cf08f48f25e190cdd344fb7dab9dc7e0e61cca65c0d694f85fcdeef74d300", + ".cursor/skills/iflow-comments": "094894b5438bb8eed896dfa9b4eec1610b9e661c64bbb3d3e8e748a86c285494", + ".cursor/skills/iflow-cycle": "8fc7acfe720af520a7f99c8e296706be67237b386802282525968bcc58fe80dd", + ".cursor/skills/iflow-doctor": "dfc44e37c04aa6ff9c61a05ee2b1f196d30dd4b212e7dccd7dded6b5a8cab712", + ".cursor/skills/iflow-drive": "847480c660d174cc994230b9244729904d4248ba074f7cbf06764b8295188c52", + ".cursor/skills/iflow-epic": "5b83988ed2f1eb0159f1bec8620e0da099fce3bbf4aa543179e049b527b525ec", + ".cursor/skills/iflow-fix": "f2296766d2ec820140f58e9d0e60bb36a4f5805d7c3dabd8429cc8f15602a042", + ".cursor/skills/iflow-graphify": "f352ee48173888a8f40c788f9f53cdb93cb1ca13710de94ac94af2ba7e07e325", + ".cursor/skills/iflow-history-update": "bb648442bcf2bae938beb12f5b5a9c84580c9c5b34888fdc7cf5a912c9eb006c", + ".cursor/skills/iflow-init": "b4369a282fb1eba0561f5e76525b39c7f4060421fd633b4cef6ffa10841b60bc", + ".cursor/skills/iflow-issue": "2760a68a0d548a025c3cb4bec2ba9574bce12d10e49f99c9788ec83295c6d752", + ".cursor/skills/iflow-ops": "51e4c33a4821006c61575fbbc3ccd91ab791e5fe8d9e0385b06c914be404ca90", + ".cursor/skills/iflow-pause": "51ea3866d4fd9ce4a4ec87edeca6589b4e5f4b57ac5fee1cf1bd90d3e59bdf4b", + ".cursor/skills/iflow-pick": "868d0f58ed641ce0691def2171f4cca78c207c1644b6a6707b9df0834db84591", + ".cursor/skills/iflow-plan": "99991ac6904a96fdecd5d2c0c08ab1b0795238f98c0bc995aaf242906e6b9b83", + ".cursor/skills/iflow-pr-sync": "df3141daa34a42d7edec791f342ce14326de8dd97c20b166e1dc8546f3f068d0", + ".cursor/skills/iflow-review": "8b1fc7c06ee6ddf9c4dfc77f429324c912f10ea1a7393c1ea26fe8391ee91712", + ".cursor/skills/iflow-setup": "e40b74213aeea55b1aff7be6f0ccec23b614206c3b4b9feef2c2289ecb8fecd0", + ".cursor/skills/iflow-split": "30be3d1ee3e4fca534515a27f67468be24a794e9f501383323f9880ca891adbb", + ".cursor/skills/iflow-status": "837cfb329cfea0064025f3520714dc0edceb20452c5307f476b168fb6176818a", + ".cursor/skills/iflow-version-bump": "e072f2ad95850067a6ab92f110546589813a6afb4dc1f74ae18e670973248b0f", + ".cursor/skills/iflow-workspace-git": "7b496e3eb32da90e76056b39cd7799951427ab2d9acb29de673d9010b2ad6a51", + ".cursor/skills/iflow-yolo": "045d344256aa4f18868d3f312376f16526d8d5180aa46ded5c84af5f90c67d17" } } diff --git a/AGENTS.md b/AGENTS.md index 5198f9a..59841ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -312,7 +312,7 @@ The full slash-command lifecycle is: 4. **`/iflow-pause`** *(optional)* — park work mid-stream: update status, move the issue group to `02-partly-solved-issues`, optional WIP commit. 5. **`/iflow-close`** — tests, optional `uv version --bump`, **changelog/`HISTORY.md` update (in the PR commit)**, status update, commit, push, PR. Does not delete branches. Never offer a HISTORY/CHANGELOG update after close finishes or after merge; use `nohistory` only to skip intentionally. (A draft opened earlier via `/iflow-build` early PR does not skip the close HISTORY step.) -6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. +6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. Trailing `all` runs it for every scaffolded workspace member with the confirms consolidated per phase (`issue-flow workspace cleanup`). `/iflow-yolo` chains `capture → plan → build → close yolo` for small, low-risk issues with up-front safeguards (clean tree, passing tests, single consolidated confirm). Its close step is hands-off: changelog decided without a prompt, PR merged (`gh pr merge --squash`; on pending checks may `gh pr checks --watch` then retry, with `--auto` as last resort), then default-branch switch + pull. @@ -446,7 +446,7 @@ When an editor workspace contains **multiple sibling repositories**, each with i - **Resolve the target repo first** — explicit `root:` / `repo:` hints, then `issue-flow agent resolve`, then branch/single-scaffold heuristics, then the **workspace default** from `issueflow-workspace.toml` at the workspace root (create it with `issue-flow workspace init`); **ask** when still ambiguous. Never let `git` or `gh` infer the repo from cwd alone. - **Scoped rules** — this repo's `issueflow-rules` apply under this project root only (path globs). Put **toolchain-specific** run/test commands in `.issueflows/04-designs-and-guides/this-project.md`, not in shared boilerplate that every repo merges. -- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed. +- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed (`/iflow-cleanup all` is the one opt-in workspace-wide pass). - **Design doc** — see `.issueflows/04-designs-and-guides/multi-repo-workspaces.md` when present (issue #67). diff --git a/HISTORY.md b/HISTORY.md index 234e391..70f20f8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,9 @@ than the GitHub release notes they link to. ## [Unreleased] +## [0.5.17] - 2026-09-26 + +- Workspace-wide cleanup: `issue-flow workspace cleanup` classifies every member's local branches with the same code as `agent local-branches` (read-only by default; `--apply` runs Phase A1, `--apply --yes-delete-squash-landed` adds the `-D` phase with tip SHAs), skipping members with a dirty product-code tree, detached HEAD, or no `origin`, and never pulling a member whose default cannot fast-forward. `/iflow-cleanup all` consumes it with one A1 confirm and one A2 confirm for the whole workspace instead of one pair per repo. (#392) - Epic #269 is complete: user-global config, per-repo lock, the project registry, and `update --all` shipped in stages #281–#298. (#269) - Skills that create multi-line GitHub text now say to use `--body-file` or `git commit -F`. Bash `<<'EOF'` heredocs fail in Windows PowerShell. (#380) - The 2026-09-12 remote-branch audit's three stale remotes were deleted after review: `140-agent-queue-cli`, `cursor/163-github-branches-e2ca`, and `cursor/gha-sync-issueflows-08d1`. Their work had already landed. (#267) diff --git a/docs/cli.md b/docs/cli.md index b8f754e..f702cc9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,7 @@ synopsis of every command is in the collapsible block below the tables. | [`workspace doctor`](#issue-flow-workspace-doctor) | Audit every member (no `--fix`) | | [`workspace dirty`](#issue-flow-workspace-dirty) | Classify each member's working tree | | [`workspace git`](#issue-flow-workspace-git) | Git snapshot / fetch --prune for every member | +| [`workspace cleanup`](#issue-flow-workspace-cleanup) | Post-merge branch cleanup plan (and optional apply) for every member | ### Inspect and repair @@ -100,6 +101,8 @@ synopsis of every command is in the collapsible block below the tables. issue-flow workspace git issue-flow workspace git status [WORKSPACE_DIR] [--json] issue-flow workspace git fetch [WORKSPACE_DIR] [--json] + issue-flow workspace cleanup [WORKSPACE_DIR] [--json] [--dry-run] [--no-fetch] + [--apply] [--yes-delete-squash-landed] [--extra-root PATH ...] ``` ## Shell completion @@ -131,6 +134,7 @@ pages do not repeat these flags. | Parent folder already has `issueflow-workspace.toml`; refresh members | `issue-flow workspace update` | | Status / doctor / dirty-tree for every workspace member | `issue-flow workspace status` / `doctor` / `dirty` | | Git status / fetch for every workspace member | `issue-flow workspace git status` / `fetch` | +| Which landed branches each workspace member could delete | `issue-flow workspace cleanup` (classify-only; agent path `/iflow-cleanup all`) | | Write `issueflow-workspace.toml` only (members already scaffolded) | `issue-flow workspace init --default NAME` | | Refresh every unlocked registered repo | `issue-flow update --all` | | Add / remove a root in the user-global registry | `issue-flow register` / `unregister` | @@ -425,3 +429,35 @@ Distinct from `workspace status` (issue-flow lifecycle) and | `--json` | Emit `{workspace_root, members:[…]}`. | `/iflow-workspace-git` (chat: `iflow git`) is the agent path. + +## `issue-flow workspace cleanup` { #issue-flow-workspace-cleanup } + +Post-merge branch cleanup across every scaffolded member — the workspace +loop behind `/iflow-cleanup all`. **Classify-only by default.** For each +member it runs `git fetch --prune`, classifies the default branch +(`default-sync`), buckets local branches exactly like +[`agent local-branches`](#issue-flow-agent) (`reachable` / +`squash_landed` / `merged_pr_divergent` / `unique_work` / `skipped`, tip +SHA on every entry), lists linked worktrees, and computes a Phase A1 / +A2 plan. Output is one table grouped by member. + +Members are **refused and skipped** (reported, loop continues) when the +tree has product-code changes, HEAD is detached, there is no `origin` +remote, or the member is locked. `.issueflows/`-only dirt only blocks the +`switch` / `pull` step for that member. A member whose default cannot +fast-forward is listed with its `default-sync` action and is never +pulled, rebased, or pushed. + +| Argument / Option | Description | +| ----------------- | ----------- | +| `WORKSPACE_DIR` | Start directory. Defaults to `.`. Walks up for `issueflow-workspace.toml`. | +| `--json` | Emit `{workspace_root, apply, members:[{name, path, skipped, reason, default_sync, buckets, worktrees, plan:{a1,a2}, applied}], totals}`. | +| `--dry-run` | Classify only, even when `--apply` is given. | +| `--no-fetch` | Skip `git fetch --prune` in each member. | +| `--apply` | Run Phase A1 per member: `switch `, `pull --ff-only` (only when `default-sync` says `even` / `ff_only`), remove reachable worktrees, `git branch -d` on `reachable`. For non-interactive callers; the skill asks first. | +| `--yes-delete-squash-landed` | With `--apply`: also Phase A2 — `git branch -d`, then `-D` on refusal, for `squash_landed` / `merged_pr_divergent`. Every deletion is reported as ` `; recover with `git branch `. Never touches `unique_work`. | +| `--extra-root PATH` | Include a scaffolded repo outside the registry (repeatable). | + +Exit `0` when no member failed (refused members are not failures), `1` +otherwise. `/iflow-cleanup all` is the agent path: one A1 confirm, one A2 +confirm, and an optional Phase B confirm for the whole workspace. diff --git a/docs/how-to/after-squash-merge.md b/docs/how-to/after-squash-merge.md index 5fadc67..f201801 100644 --- a/docs/how-to/after-squash-merge.md +++ b/docs/how-to/after-squash-merge.md @@ -23,8 +23,13 @@ local issue branches safely. Close / yolo / cycle **remind** you to run cleanup; they do not run it for you. +Several repos in one workspace folder? `iflow cleanup all` does the same for +every member with one `-d` confirm and one `-D` confirm in total (backed by +`issue-flow workspace cleanup`). See +[Use issue-flow in a folder of repos](workspaces.md#after-the-toml-exists). + ## Related - [Command reference](../issue-workflow.md) — `/iflow-cleanup` -- [CLI](../cli.md) — `agent local-branches` / `agent branches` (read-only audits) +- [CLI](../cli.md) — `agent local-branches` / `agent branches` (read-only audits), `workspace cleanup` - [Refresh dirty open PRs](pr-sync.md) — when other open PRs went dirty diff --git a/docs/how-to/workspaces.md b/docs/how-to/workspaces.md index c506750..f344a2f 100644 --- a/docs/how-to/workspaces.md +++ b/docs/how-to/workspaces.md @@ -27,6 +27,7 @@ one repo. | Toml exists; refresh skills/rules in every member | `issue-flow workspace update` | | Status / doctor / dirty-tree for every member | `issue-flow workspace status` / `doctor` / `dirty` | | Git status / fetch for every member | `issue-flow workspace git status` / `fetch` | +| Which landed branches every member could delete (read-only) | `issue-flow workspace cleanup` | | Peek before writing (no `init`, no toml) | `issue-flow workspace bootstrap --json` | `--default` is the member folder name lifecycle commands use when you are @@ -118,7 +119,8 @@ issue-flow workspace doctor issue-flow workspace dirty # after workspace update issue-flow workspace git status # branch / dirty / ahead-behind issue-flow workspace git fetch # git fetch --prune only -iflow cleanup workspace # opt-in sequential per-repo cleanup +issue-flow workspace cleanup # landed-branch plan per member (read-only) +iflow cleanup all # agent path: one A1 + one A2 confirm for all iflow git # agent path for workspace git ``` @@ -127,6 +129,16 @@ iflow git # agent path for workspace git `workspace dirty` lists members whose tree changed; land those commits per repo (chore branch if you are on default). There is no auto-push. +**After a day of merges** across several members, `iflow cleanup all` +runs the post-merge branch cleanup for the whole workspace: it surveys +with `issue-flow workspace cleanup --json`, then asks once for the +`-d` phase and once, separately, for the `-D` phase (squash-landed +branches, tip SHAs printed). Members with a dirty product-code tree, +detached HEAD, or no `origin` are skipped and reported; a member whose +default branch cannot fast-forward is never pulled. Branches with unique +work are never offered. Details: +[CLI: `workspace cleanup`](../cli.md#issue-flow-workspace-cleanup). + ## Related - [Upgrade, init, and workspace (for agents)](for-agents.md) — package vs scaffold; global init diff --git a/docs/issue-workflow.md b/docs/issue-workflow.md index a1d5d31..1b8ca68 100644 --- a/docs/issue-workflow.md +++ b/docs/issue-workflow.md @@ -191,7 +191,7 @@ Every command below is described the same way: **When to use**, **Arguments**, * **When to use:** The PR opened by `/iflow-close` has merged on GitHub. (The optional GitHub remote audit can also run when you only want a remote-branch report.) -**Arguments:** Nothing (acts on the current branch), an explicit branch name, and/or a GitHub-audit token such as `include GitHub` / `with github` / `github`. Opt out of a baked-on Phase B with `no github` / `local only`. With `cleanup_include_github = true` in `config.toml`, Phase B runs by default without a token. Self-update tokens (`bleeding edge` / `no bleeding`) gate `issue-flow agent self-update` after a successful FF pull; with `on_bleeding_edge = true` that step runs by default. +**Arguments:** Nothing (acts on the current branch), an explicit branch name, and/or a GitHub-audit token such as `include GitHub` / `with github` / `github`. Opt out of a baked-on Phase B with `no github` / `local only`. With `cleanup_include_github = true` in `config.toml`, Phase B runs by default without a token. Self-update tokens (`bleeding edge` / `no bleeding`) gate `issue-flow agent self-update` after a successful FF pull; with `on_bleeding_edge = true` that step runs by default. Trailing `all` (or `workspace`) runs cleanup for **every scaffolded workspace member** in one pass — surveyed by `issue-flow workspace cleanup --json`, with one Phase A1 confirm and one Phase A2 confirm for the whole workspace; members with a dirty product-code tree, detached HEAD, or no `origin` are skipped and reported. **What it does:** diff --git a/pyproject.toml b/pyproject.toml index ad0a5eb..02b80f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "issue-flow" -version = "0.5.16" +version = "0.5.17" description = "Agents should behave. Let them follow the issue flow." readme = "README.md" license = "MIT" diff --git a/src/issue_flow/agent.py b/src/issue_flow/agent.py index b224b97..bc1a712 100644 --- a/src/issue_flow/agent.py +++ b/src/issue_flow/agent.py @@ -610,6 +610,27 @@ def run_local_branches( Read-only: never deletes a branch. The confirm-gated deletes live in ``/iflow-cleanup``. """ + payload, exit_code = classify_local_branches( + project_root, fetch=fetch, commit_limit=commit_limit + ) + if as_json: + _emit_json(console, payload) + return exit_code + return _render_local_branches_text(console, payload, exit_code) + + +def classify_local_branches( + project_root: Path, + *, + fetch: bool = True, + commit_limit: int = 20, +) -> tuple[dict[str, Any], int]: + """Console-free five-bucket classifier behind ``agent local-branches``. + + Shared with ``workspace cleanup`` (issue #392) so the per-member buckets + are, by construction, identical to running ``agent local-branches`` in + each member. Returns ``(payload, exit_code)``; never mutates the repo. + """ notes: list[str] = [] remote = gitutils.remote_owner_repo(project_root) repo = f"{remote[0]}/{remote[1]}" if remote else None @@ -629,11 +650,8 @@ def run_local_branches( "notes": notes, } - def emit(exit_code: int) -> int: - if as_json: - _emit_json(console, payload) - return exit_code - return _render_local_branches_text(console, payload, exit_code) + def emit(exit_code: int) -> tuple[dict[str, Any], int]: + return payload, exit_code if not gitutils.git_available(): notes.append("git is not on PATH") @@ -4371,6 +4389,487 @@ def run_workspace_git_fetch( return 0 if fail_count == 0 else 1 +# --------------------------------------------------------------------------- +# workspace cleanup (issue #392) +# --------------------------------------------------------------------------- + +_CLEANUP_FF_ACTIONS = frozenset({"even", "ff_only"}) +_CLEANUP_BUCKETS = ( + "reachable", + "squash_landed", + "merged_pr_divergent", + "unique_work", + "skipped", +) +_CLEANUP_FORCE_BUCKETS = ("squash_landed", "merged_pr_divergent") + + +def _cleanup_gate(root: Path, settings: Settings) -> tuple[str | None, list[str]]: + """Refuse-to-loop check for one member. + + Returns ``(reason, dirty_paths)``: ``reason`` is ``None`` when the member + may be processed. ``issueflows_only`` dirt is *not* a refusal — the + caller only blocks ``switch`` / ``pull`` for it. + """ + if gitutils.head_sha(root) is None: + return "not a git repo (or no commits yet)", [] + if not gitutils.has_remote(root, "origin"): + return "missing origin remote", [] + if gitutils.is_detached_head(root): + return "detached HEAD", [] + dirty = gitutils.dirty_paths(root) + klass = _dirty_class(dirty, settings.issueflows_dir) + if klass == "unknown": + return "could not read the working tree state", [] + if klass == "mixed": + return "dirty product-code tree", dirty or [] + return None, dirty or [] + + +def _cleanup_member( + name: str, + root: Path, + settings: Settings, + *, + fetch: bool, + apply: bool, + force_delete: bool, +) -> dict[str, Any]: + """Classify (and optionally apply) cleanup for one workspace member.""" + entry: dict[str, Any] = { + "name": name, + "path": str(root), + "ok": True, + "skipped": False, + "reason": None, + "branch": None, + "default_branch": None, + "dirty_class": None, + "dirty_paths": [], + "fetched": False, + "default_sync": None, + "buckets": {bucket: [] for bucket in _CLEANUP_BUCKETS}, + "worktrees": [], + "refusals": [], + "plan": {"a1": None, "a2": None}, + "applied": None, + "notes": [], + } + notes: list[str] = entry["notes"] + + reason, dirty = _cleanup_gate(root, settings) + entry["dirty_paths"] = dirty + entry["dirty_class"] = _dirty_class(dirty, settings.issueflows_dir) + entry["branch"] = gitutils.current_branch(root) + if reason is not None: + entry["skipped"] = True + entry["reason"] = reason + return entry + + if fetch: + entry["fetched"] = gitutils.fetch_prune(root) + if not entry["fetched"]: + notes.append("git fetch --prune failed") + + default = gitutils.default_branch(root) + entry["default_branch"] = default + sync = gitutils.classify_default_sync( + root, issueflows_dir=settings.issueflows_dir, default=default, fetch=False + ) + entry["default_sync"] = { + key: sync.get(key) + for key in ("action", "class", "ahead", "behind", "ff_possible") + } + + buckets, code = classify_local_branches(root, fetch=False) + if code != 0: + entry["ok"] = False + entry["reason"] = "; ".join(buckets.get("notes") or []) or "classify failed" + return entry + for bucket in _CLEANUP_BUCKETS: + entry["buckets"][bucket] = list(buckets.get(bucket) or []) + notes.extend(buckets.get("notes") or []) + + bucket_of: dict[str, str] = {} + for bucket in _CLEANUP_BUCKETS: + for item in entry["buckets"][bucket]: + bucket_of[str(item.get("name"))] = bucket + + worktrees: list[dict[str, Any]] = [] + for info in gitutils.list_worktrees(root): + if info.is_main: + continue + wt_bucket = bucket_of.get(info.branch or "") + worktrees.append( + { + "path": str(info.path), + "branch": info.branch, + "bucket": wt_bucket, + "clean": gitutils.working_tree_clean(info.path), + } + ) + if wt_bucket == "unique_work": + entry["refusals"].append( + f"linked worktree {info.path} holds unique work on " + f"{info.branch}; branch and worktree left alone" + ) + entry["worktrees"] = worktrees + + on_default = entry["branch"] == default + issueflows_dirt = entry["dirty_class"] == "issueflows_only" + switch_blocked = ( + None + if on_default + else ("issueflows-only dirt in the working tree" if issueflows_dirt else None) + ) + action = sync.get("action") + pull_ok = action in _CLEANUP_FF_ACTIONS + a1: dict[str, Any] = { + "switch_default": (not on_default) and switch_blocked is None, + "switch_blocked_reason": switch_blocked, + "pull_ff_only": pull_ok and (on_default or switch_blocked is None), + "pull_skipped_reason": None if pull_ok else str(action), + "worktree_remove": [ + wt["path"] for wt in worktrees if wt["bucket"] == "reachable" + ], + "branch_d": [str(item.get("name")) for item in entry["buckets"]["reachable"]], + } + a2_items: list[dict[str, Any]] = [] + for bucket in _CLEANUP_FORCE_BUCKETS: + for item in entry["buckets"][bucket]: + a2_items.append( + { + "name": item.get("name"), + "tip": item.get("tip"), + "bucket": bucket, + "merged_prs": item.get("merged_prs") or [], + "commits": item.get("commits") or [], + "recover": f"git branch {item.get('name')} {item.get('tip')}", + } + ) + a2: dict[str, Any] = { + "branch_D": a2_items, + "worktree_remove": [ + wt["path"] for wt in worktrees if wt["bucket"] in _CLEANUP_FORCE_BUCKETS + ], + } + entry["plan"] = {"a1": a1, "a2": a2} + + if not apply: + return entry + + entry["applied"] = _apply_cleanup_member( + root, default, entry, a1, a2, force_delete=force_delete + ) + return entry + + +def _apply_cleanup_member( + root: Path, + default: str, + entry: dict[str, Any], + a1: dict[str, Any], + a2: dict[str, Any], + *, + force_delete: bool, +) -> dict[str, Any]: + """Run the planned A1 (and, when authorised, A2) actions for one member.""" + notes: list[str] = entry["notes"] + home = gitutils.worktree_home_path(root) or root + applied: dict[str, Any] = { + "a1": { + "switched": False, + "pulled": False, + "worktrees_removed": [], + "deleted": [], + "refused": [], + }, + "a2": { + "authorised": force_delete, + "worktrees_removed": [], + "deleted": [], + "refused": [], + }, + } + a1_out = applied["a1"] + if a1["switch_default"]: + ok, error = gitutils.switch_branch(root, default) + if ok: + a1_out["switched"] = True + entry["branch"] = default + else: + notes.append(f"git switch {default} failed: {error}") + if a1["pull_ff_only"] and (a1_out["switched"] or entry["branch"] == default): + ok, error = gitutils.pull_ff_only(root) + a1_out["pulled"] = ok + if not ok: + notes.append(f"git pull --ff-only refused: {error}") + for wt_path in a1["worktree_remove"]: + ok, error = gitutils.remove_worktree(home, Path(wt_path)) + if ok: + a1_out["worktrees_removed"].append(wt_path) + else: + notes.append(f"worktree remove {wt_path} refused: {error}") + tips = { + str(item.get("name")): item.get("tip") + for bucket in _CLEANUP_BUCKETS + for item in entry["buckets"][bucket] + } + for name in a1["branch_d"]: + ok, error = gitutils.delete_branch(root, name) + if ok: + a1_out["deleted"].append( + {"name": name, "tip": tips.get(name), "flag": "-d"} + ) + else: + a1_out["refused"].append( + {"name": name, "tip": tips.get(name), "error": error} + ) + + a2_out = applied["a2"] + if not force_delete: + if a2["branch_D"]: + notes.append( + f"{len(a2['branch_D'])} squash-landed / merged-PR divergent " + "branch(es) left in place — pass --yes-delete-squash-landed " + "after the Phase A2 confirm" + ) + return applied + for wt_path in a2["worktree_remove"]: + ok, error = gitutils.remove_worktree(home, Path(wt_path)) + if ok: + a2_out["worktrees_removed"].append(wt_path) + else: + notes.append(f"worktree remove {wt_path} refused: {error}") + for item in a2["branch_D"]: + name = str(item["name"]) + ok, _error = gitutils.delete_branch(root, name) + flag = "-d" + if not ok: + ok, error = gitutils.delete_branch(root, name, force=True) + flag = "-D" + if not ok: + a2_out["refused"].append( + {"name": name, "tip": item.get("tip"), "error": error} + ) + continue + a2_out["deleted"].append({"name": name, "tip": item.get("tip"), "flag": flag}) + return applied + + +def run_workspace_cleanup( + workspace_dir: Path, + console: Console, + as_json: bool, + *, + fetch: bool = True, + dry_run: bool = False, + apply: bool = False, + yes_delete_squash_landed: bool = False, + extra_roots: list[Path] | None = None, +) -> int: + """Classify (and optionally apply) post-merge branch cleanup per member. + + Read-only by default: every member gets ``git fetch --prune``, a + ``default-sync`` classification, the ``agent local-branches`` buckets, and + its linked worktrees, plus a computed A1 / A2 plan. ``--apply`` runs A1 + (``switch`` / ``pull --ff-only`` when fast-forwardable, ``-d`` on + ``reachable``); ``-D`` on ``squash_landed`` / ``merged_pr_divergent`` + needs ``--yes-delete-squash-landed`` as well. ``--dry-run`` forces + classify-only. ``unique_work`` is never planned or deleted. + """ + start = workspace_dir.resolve() + prepared = _prepare_workspace_members(start, console, as_json) + if prepared is None: + return 1 + workspace, member_pairs = prepared + settings = Settings() + notes: list[str] = [] + + pairs = list(member_pairs) + seen = {root for _name, root in pairs} + for extra in extra_roots or []: + resolved = extra.resolve() + if resolved in seen: + continue + if not (resolved / settings.issueflows_dir).is_dir(): + notes.append( + f"extra root {resolved} has no {settings.issueflows_dir}/; ignored" + ) + continue + seen.add(resolved) + pairs.append((resolved.name, resolved)) + + effective_apply = apply and not dry_run + if apply and dry_run: + notes.append("--dry-run given: --apply ignored, classify-only") + if yes_delete_squash_landed and not apply: + notes.append("--yes-delete-squash-landed has no effect without --apply") + + results: list[dict[str, Any]] = [] + ok_count = skip_count = fail_count = 0 + totals = {bucket: 0 for bucket in _CLEANUP_BUCKETS} + for name, root in pairs: + if settings.resolve_locked(root): + results.append( + { + "name": name, + "path": str(root), + "ok": True, + "skipped": True, + "reason": "locked", + } + ) + skip_count += 1 + continue + try: + entry = _cleanup_member( + name, + root, + settings, + fetch=fetch, + apply=effective_apply, + force_delete=effective_apply and yes_delete_squash_landed, + ) + except Exception as exc: # noqa: BLE001 — continue-on-fail fan-out + entry = { + "name": name, + "path": str(root), + "ok": False, + "skipped": False, + "reason": str(exc), + } + results.append(entry) + if not entry.get("ok", False): + fail_count += 1 + elif entry.get("skipped"): + skip_count += 1 + else: + ok_count += 1 + for bucket in _CLEANUP_BUCKETS: + totals[bucket] += len((entry.get("buckets") or {}).get(bucket) or []) + + payload = { + "ok": fail_count == 0, + "workspace_root": str(workspace.root), + "apply": effective_apply, + "dry_run": dry_run, + "yes_delete_squash_landed": effective_apply and yes_delete_squash_landed, + "members": results, + "ok_count": ok_count, + "fail_count": fail_count, + "skip_count": skip_count, + "totals": totals, + "notes": notes, + } + if as_json: + _emit_json(console, payload) + else: + _render_workspace_cleanup_text(console, payload) + return 0 if fail_count == 0 else 1 + + +def _render_workspace_cleanup_text(console: Console, payload: dict[str, Any]) -> None: + mode = ( + "apply + -D" + if payload["yes_delete_squash_landed"] + else "apply (A1 only)" + if payload["apply"] + else "classify-only" + ) + console.print( + f"\n[bold]Workspace cleanup[/bold] [cyan]{payload['workspace_root']}[/cyan]" + f" [dim]{mode}[/dim]" + ) + console.print(f"[dim]{len(payload['members'])} member(s)[/dim]\n") + for entry in payload["members"]: + name = escape(str(entry.get("name"))) + if entry.get("skipped"): + console.print( + f"[yellow]skip[/yellow] {name} ({escape(str(entry.get('reason')))})" + ) + for path in entry.get("dirty_paths") or []: + console.print(f" {escape(str(path))}") + continue + if not entry.get("ok"): + console.print( + f"[red]fail[/red] {name}: {escape(str(entry.get('reason')))}" + ) + continue + sync = entry.get("default_sync") or {} + buckets = entry.get("buckets") or {} + counts = " · ".join( + f"{bucket.replace('_', '-')} {len(buckets.get(bucket) or [])}" + for bucket in _CLEANUP_BUCKETS[:4] + ) + console.print( + f" [bold]{name}[/bold] {escape(str(entry.get('branch') or '(detached)'))}" + f" default-sync={escape(str(sync.get('action')))} {counts}" + ) + plan = entry.get("plan") or {} + a1 = plan.get("a1") or {} + if a1.get("switch_blocked_reason"): + console.print( + f" switch skipped: {escape(str(a1['switch_blocked_reason']))}" + ) + if a1.get("pull_skipped_reason"): + console.print( + f" pull --ff-only skipped: default-sync action " + f"{escape(str(a1['pull_skipped_reason']))}" + ) + a2 = plan.get("a2") or {} + for wt_path in list(a1.get("worktree_remove") or []) + list( + a2.get("worktree_remove") or [] + ): + console.print(f" [dim]worktree remove[/dim] {escape(str(wt_path))}") + for item in buckets.get("reachable") or []: + console.print( + f" [green]-d[/green] {escape(str(item.get('name')))} " + f"{escape(str(item.get('tip') or '?'))}" + ) + for item in a2.get("branch_D") or []: + console.print( + f" [yellow]-D[/yellow] {escape(str(item.get('name')))} " + f"{escape(str(item.get('tip') or '?'))} " + f"[dim]{escape(str(item.get('bucket')))}[/dim]" + ) + for item in buckets.get("unique_work") or []: + console.print( + f" [cyan]keep[/cyan] {escape(str(item.get('name')))} " + f"{escape(str(item.get('tip') or '?'))} — {escape(str(item.get('reason')))}" + ) + for line in entry.get("refusals") or []: + console.print(f" [yellow]refused[/yellow] {escape(str(line))}") + applied = entry.get("applied") + if applied: + for phase in ("a1", "a2"): + for item in applied[phase].get("deleted") or []: + console.print( + f" [green]deleted[/green] {escape(str(item['name']))} " + f"{escape(str(item.get('tip') or '?'))} ({item['flag']})" + ) + for item in applied[phase].get("refused") or []: + console.print( + f" [red]refused[/red] {escape(str(item['name']))}: " + f"{escape(str(item.get('error')))}" + ) + for note in entry.get("notes") or []: + console.print(f" [dim]note[/dim] {escape(str(note))}") + totals = payload["totals"] + console.print( + f"\n[dim]totals: reachable {totals['reachable']} · squash-landed " + f"{totals['squash_landed']} · merged-PR divergent " + f"{totals['merged_pr_divergent']} · unique work {totals['unique_work']}[/dim]" + ) + if not payload["apply"]: + console.print( + "[dim]classify-only — nothing changed. `--apply` runs Phase A1; add " + "`--yes-delete-squash-landed` for Phase A2 (-D) after its own confirm.[/dim]" + ) + for note in payload.get("notes") or []: + console.print(f"[yellow]note[/yellow] {escape(str(note))}") + + # --------------------------------------------------------------------------- # agent self-update # --------------------------------------------------------------------------- diff --git a/src/issue_flow/cli.py b/src/issue_flow/cli.py index 9f3baf5..5b6d36e 100644 --- a/src/issue_flow/cli.py +++ b/src/issue_flow/cli.py @@ -1810,6 +1810,71 @@ def workspace_dirty( raise typer.Exit(code=run_workspace_dirty(workspace_dir, _console, json_output)) +@workspace_app.command("cleanup") +def workspace_cleanup( + workspace_dir: Path = _WORKSPACE_DIR_ARGUMENT, + json_output: bool = typer.Option( + False, "--json", help="Emit a machine-readable JSON object." + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Classify only, even when --apply is given.", + ), + apply: bool = typer.Option( + False, + "--apply", + help=( + "Run Phase A1 per member: switch to default, pull --ff-only when " + "fast-forwardable, remove reachable worktrees, `git branch -d` " + "reachable branches. Non-interactive callers only — the skill " + "asks first." + ), + ), + yes_delete_squash_landed: bool = typer.Option( + False, + "--yes-delete-squash-landed", + help=( + "With --apply: also `git branch -D` squash_landed / " + "merged_pr_divergent branches (Phase A2). Tip SHAs are reported " + "for recovery. Never touches unique_work." + ), + ), + no_fetch: bool = typer.Option( + False, "--no-fetch", help="Skip `git fetch --prune` in each member." + ), + extra_root: list[Path] = typer.Option( + [], + "--extra-root", + help=( + "Additional scaffolded repo outside the registry to include (repeatable)." + ), + ), +) -> None: + """Post-merge branch cleanup across every scaffolded workspace member. + + Classify-only by default: fetch, ``default-sync`` classification, the + ``agent local-branches`` buckets, linked worktrees, and a computed + A1 / A2 plan per member. Members with a dirty product-code tree, + detached HEAD, or no ``origin`` are skipped and reported. Never + rebases, force-pushes, or deletes ``unique_work``. + """ + from issue_flow.agent import run_workspace_cleanup + + raise typer.Exit( + code=run_workspace_cleanup( + workspace_dir, + _console, + json_output, + fetch=not no_fetch, + dry_run=dry_run, + apply=apply, + yes_delete_squash_landed=yes_delete_squash_landed, + extra_roots=list(extra_root), + ) + ) + + @workspace_git_app.callback(invoke_without_command=True) def workspace_git_default(ctx: typer.Context) -> None: """Default verb is ``status`` when no subcommand is given.""" diff --git a/src/issue_flow/gitutils.py b/src/issue_flow/gitutils.py index 8fda917..93caf82 100644 --- a/src/issue_flow/gitutils.py +++ b/src/issue_flow/gitutils.py @@ -605,6 +605,18 @@ def classify_default_sync( } +def has_remote(cwd: Path, name: str = "origin") -> bool: + """True when the repo at ``cwd`` has a remote called ``name``.""" + return bool(_stdout([GIT, "remote", "get-url", name], cwd)) + + +def is_detached_head(cwd: Path) -> bool: + """True when ``cwd`` is a repo with commits whose HEAD is detached.""" + if head_sha(cwd) is None: + return False + return current_branch(cwd) is None + + def remote_owner_repo(cwd: Path) -> tuple[str, str] | None: """Parse ``owner``/``repo`` from the ``origin`` remote URL.""" url = _stdout([GIT, "remote", "get-url", "origin"], cwd) diff --git a/src/issue_flow/templates/commands/iflow-cleanup.md.j2 b/src/issue_flow/templates/commands/iflow-cleanup.md.j2 index dc2ccae..c4d1069 100644 --- a/src/issue_flow/templates/commands/iflow-cleanup.md.j2 +++ b/src/issue_flow/templates/commands/iflow-cleanup.md.j2 @@ -22,7 +22,7 @@ Optional free-form text after the command. Examples: - **Self-update opt-out tokens** (case-insensitive): `no bleeding`, `no bleeding-edge`, `skip self-update` — skip the upgrade even when `on_bleeding_edge` is baked true. - **Phase A ask tokens** (case-insensitive): `ask a1`, `ask a2` — force that phase's yes/no even when `cleanup_yes_a1` / `cleanup_yes_a2` is baked true. - **Orchestrator token** `drive` / `landed` — the caller (`/iflow-drive`) already confirmed `-D` on squash-landed branches: run A1 + A2 without re-asking, A2 narrowed to `squash_landed` plus `merged_pr_divergent` with nothing newer than the PR merge; tip SHAs still printed. Never passed by a human. -- **Workspace walk** (case-insensitive): `workspace`, `all`, `include workspace` — one up-front confirm, then this cleanup **per member**. Not a mute CLI. Ignore these tokens when parsing a named branch. When self-update is enabled, upgrade the tool **once** at the start of the walk. +- **Workspace mode** (case-insensitive): `all`, `workspace`, `include workspace` — cleanup for every scaffolded member in one pass (step 4b): survey with `issue-flow workspace cleanup --json` (classify-only), then **one** A1 confirm, **one** A2 confirm, optional **one** Phase B confirm for the whole workspace. Extra `root:` hints → `--extra-root`. Ignore these tokens when parsing a named branch. When self-update is enabled, upgrade the tool **once** at the start. **Phase B enable rule:** (`cleanup_include_github` baked true **or** opt-in token) **and** no opt-out token. @@ -56,6 +56,12 @@ Optional free-form text after the command. Examples: - Manual fallback per branch (skip the current and default branches): `git merge-base --is-ancestor origin/` (exit 0 → `reachable`); else `git cherry origin/ ` with no `+` lines → `squash_landed`; else check `gh pr list --state all --head --json number,state,mergedAt,url` and compare `git log --no-merges --format=%cI origin/..` to `mergedAt` — a merged PR with nothing newer → `merged_pr_divergent`, otherwise `unique_work`. - `git branch -d` applies git's **reachability** test, while `git cherry` proves **patch equivalence**. A {% if pr_merge_method == "squash" %}squash merge (this project's `pr_merge_method`){% else %}squash merge{% endif %} rewrites commits onto a new commit, so the branch tip stays unreachable and `-d` refuses it no matter how thoroughly the work landed. +4b. **Workspace mode** (`all` / `workspace` token — replaces steps 2–6 for every member; 7–8 then run per member). + - Survey: `issue-flow workspace cleanup --json` from the workspace root (`agent resolve` → `workspace_root`; `--extra-root ` per `root:` hint). Read-only: fetch, `default-sync`, the `agent local-branches` buckets, linked worktrees, and an A1 / A2 plan per member. Refused members (dirty product-code tree, detached HEAD, missing `origin`, locked) are reported and skipped; the rest proceed. + - **A1 (one confirm, all members):** per member `switch `, `pull --ff-only`, worktree removes, `branch -d `. Members whose `default_sync.action` is not `even` / `ff_only` are listed **with that action** and never pulled or pushed (their `-d` still runs). On yes: `issue-flow workspace cleanup --apply --json`. A1 never authorises A2. + - **A2 (second confirm, never implied by A1):** only when some `plan.a2.branch_D` is non-empty. Per member ` ` + bucket + merged PR; recovery line `git branch `. Never `unique_work` / `skipped`. On yes: `issue-flow workspace cleanup --apply --yes-delete-squash-landed --json`; report every deleted ` `. + - **Phase B (third confirm, when enabled):** step 9 per member, one confirm grouped by member. + 5. **Consolidated confirm for post-merge cleanup (Phase A1).** Gather the full action list into a single yes/no prompt: - `git switch ` (home only) - `git pull --ff-only` — if it fails, stop A1 steps that assume default is current (apply-changelog, release tag, self-update). Run `issue-flow agent default-sync --json`, print ahead/behind + unique commit onelines/paths + `action`, and follow that recovery (tracking → tiny PR; obsolete merge → replay; product → stop). Never rebase default, force-push default, or push default to skip CI. @@ -105,10 +111,12 @@ Report: - list of branches left alone, with a one-line reason (unique work, `-d` refused, unclassifiable) - folder sweep summary (`issue` → `{{ solved_folder }}/`, or "nothing to sweep") - when Phase B ran: deletable / unique-work / skipped counts, remote deletes, findings issue URL or "skipped" +- in workspace mode: the above per member, plus refused members with their reason. Otherwise, if `agent resolve` reports `sibling_roots`, remind that each scaffolded repo needs its own `/iflow-cleanup` — do not loop automatically unless invoked with `all`. ## Constraints - Never use `git push --force`. +- `issue-flow workspace cleanup` is classify-only by default; `--apply` only after the workspace A1 yes, `--apply --yes-delete-squash-landed` only after the workspace A2 yes. - `git branch -D` only for `squash_landed` / `merged_pr_divergent` branches, only after the Phase A2 confirm (or an orchestrator confirm that named `-D`, via the `drive` / `landed` token), and only with tip SHAs reported. Never `-D` unique work, an unclassifiable branch, or the current branch. A `-d` refusal in Phase A1 is reported, never force-retried. - Never delete the default branch (local or remote). - Remote deletes and findings-issue creation require the Phase B confirm. None of the three confirms implies another. diff --git a/src/issue_flow/templates/docs/issue-workflow.md.j2 b/src/issue_flow/templates/docs/issue-workflow.md.j2 index e9b1eb9..6302e50 100644 --- a/src/issue_flow/templates/docs/issue-workflow.md.j2 +++ b/src/issue_flow/templates/docs/issue-workflow.md.j2 @@ -201,7 +201,7 @@ Every command below is described the same way: **When to use**, **Arguments**, * **When to use:** The PR opened by `/iflow-close` has merged on GitHub. (The optional GitHub remote audit can also run when you only want a remote-branch report.) -**Arguments:** Nothing (acts on the current branch), an explicit branch name, and/or a GitHub-audit token such as `include GitHub` / `with github` / `github`. Opt out of a baked-on Phase B with `no github` / `local only`. With `cleanup_include_github = true` in `config.toml`, Phase B runs by default without a token. Self-update tokens (`bleeding edge` / `no bleeding`) gate `issue-flow agent self-update` after a successful FF pull; with `on_bleeding_edge = true` that step runs by default. +**Arguments:** Nothing (acts on the current branch), an explicit branch name, and/or a GitHub-audit token such as `include GitHub` / `with github` / `github`. Opt out of a baked-on Phase B with `no github` / `local only`. With `cleanup_include_github = true` in `config.toml`, Phase B runs by default without a token. Self-update tokens (`bleeding edge` / `no bleeding`) gate `issue-flow agent self-update` after a successful FF pull; with `on_bleeding_edge = true` that step runs by default. Trailing `all` (or `workspace`) runs cleanup for **every scaffolded workspace member** in one pass — surveyed by `issue-flow workspace cleanup --json`, with one Phase A1 confirm and one Phase A2 confirm for the whole workspace; members with a dirty product-code tree, detached HEAD, or no `origin` are skipped and reported. **What it does:** diff --git a/src/issue_flow/templates/rules/_body.md.j2 b/src/issue_flow/templates/rules/_body.md.j2 index c3f54b5..f5cd43e 100644 --- a/src/issue_flow/templates/rules/_body.md.j2 +++ b/src/issue_flow/templates/rules/_body.md.j2 @@ -139,7 +139,7 @@ The full slash-command lifecycle is: {% if defer_changelog %}5. **`/iflow-close`** — tests, optional `uv version --bump`, **changelog bullet recorded on the status file + PR body** (`defer_changelog`; do not write `{{ history_file }}` on the issue branch), status update, commit, push, PR. Does not delete branches. `issue-flow agent apply-changelog` writes `{{ history_file }}` on the default branch after merge (`/iflow-cleanup` / yolo post-pull). Use `nohistory` only to skip intentionally. {% else %}5. **`/iflow-close`** — tests, optional `uv version --bump`, **changelog/`{{ history_file }}` update (in the PR commit)**, status update, commit, push, PR. Does not delete branches. Never offer a HISTORY/CHANGELOG update after close finishes or after merge; use `nohistory` only to skip intentionally. (A draft opened earlier via `/iflow-build` early PR does not skip the close HISTORY step.) {% endif %} -6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. +6. **`/iflow-cleanup`** — post-merge: switch to default, `git pull --ff-only`, `git fetch --prune`, `git branch -d` on reachable local branches under a single consolidated confirm. If ff-only fails, classify with `issue-flow agent default-sync` (never rebase / force-push / push default to skip CI). Squash-landed branches (which `-d` always refuses) need `git branch -D`, offered only behind a **second** confirm that lists tip SHAs; branches with unique work are never deleted. Trailing `include GitHub` (or similar) adds a remote-branch audit with a further confirm for optional remote deletes / findings issue. Trailing `all` runs it for every scaffolded workspace member with the confirms consolidated per phase (`issue-flow workspace cleanup`). {% if "iflow_yolo" in included_skills %} `/iflow-yolo` chains `capture → plan → build → close yolo` for small, low-risk issues with up-front safeguards (clean tree, passing tests, single consolidated confirm). Its close step is hands-off: changelog decided without a prompt, PR merged (`gh pr merge --squash`; on pending checks may `gh pr checks --watch` then retry, with `--auto` as last resort), then default-branch switch + pull. @@ -337,7 +337,7 @@ When an editor workspace contains **multiple sibling repositories**, each with i - **Resolve the target repo first** — explicit `root:` / `repo:` hints, then `issue-flow agent resolve`, then branch/single-scaffold heuristics, then the **workspace default** from `issueflow-workspace.toml` at the workspace root (create it with `issue-flow workspace init`); **ask** when still ambiguous. Never let `git` or `gh` infer the repo from cwd alone. - **Scoped rules** — this repo's `issueflow-rules` apply under this project root only (path globs). Put **toolchain-specific** run/test commands in `{{ issueflows_dir }}/{{ designs_folder }}/this-project.md`, not in shared boilerplate that every repo merges. -- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed. +- **Per-repo lifecycle** — `/iflow-cleanup`, branch hygiene, and focus issue folders are **per repository**; repeat commands in each repo when needed (`/iflow-cleanup all` is the one opt-in workspace-wide pass). - **Design doc** — see `{{ issueflows_dir }}/{{ designs_folder }}/multi-repo-workspaces.md` when present (issue #67). diff --git a/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 b/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 index 197b01f..1a77b6e 100644 --- a/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 +++ b/src/issue_flow/templates/skills/iflow_cleanup/SKILL.md.j2 @@ -30,7 +30,7 @@ Optional free-form text after the command: - **Self-update (opt-out tokens)** — trailing `no bleeding`, `no bleeding-edge`, or `skip self-update` (case-insensitive) **skips** the upgrade even when `on_bleeding_edge` is baked true. - **Phase A ask tokens** — trailing `ask a1` or `ask a2` (case-insensitive) forces that phase's yes/no prompt even when `cleanup_yes_a1` / `cleanup_yes_a2` is baked true. - **Pre-authorized force-delete (orchestrator token)** — trailing `drive` (or `landed`) means the caller (`/iflow-drive`) already obtained one confirm that **explicitly covered** `-D` on squash-landed branches. Phase A1 and A2 then run **without re-asking**, but A2's scope narrows: `squash_landed` always; `merged_pr_divergent` only when none of its unique commits is newer than the PR's `mergedAt`; never `unique_work` / `skipped`. Tip SHAs are still printed. A human typing `/iflow-cleanup` never passes this token. -- **Workspace walk (opt-in tokens)** — trailing `workspace`, `all`, or `include workspace` (case-insensitive) runs this skill **sequentially for every scaffolded workspace member**. One up-front confirm listing member names. Then existing Phase A1/A2 (and optional B) **per member**. A declined A2 in one repo continues to the next; user `abort` / `stop` ends the walk. Ignore these tokens when parsing a named branch. There is no mute `workspace cleanup` CLI. When self-update is enabled, upgrade the tool **once** at the start of the walk, then `issue-flow update` per member (do not reinstall PyPI on every member). +- **Workspace mode (opt-in tokens)** — trailing `all`, `workspace`, or `include workspace` (case-insensitive) runs cleanup for **every scaffolded workspace member** in one pass (see step 4b). Confirms are **consolidated across members but still split by phase**: one A1 confirm, one A2 confirm, and (when Phase B is enabled) one Phase B confirm — never more than three for the whole workspace. Extra `root:` hints include a scaffolded repo outside the registry (`--extra-root`). Ignore these tokens when parsing a named branch. When self-update is enabled, upgrade the tool **once** at the start, then `issue-flow update` per member (do not reinstall PyPI on every member). The CLI half is `issue-flow workspace cleanup` — classify-only unless told otherwise. **Phase B enable rule:** run Phase B when (`cleanup_include_github` is baked true **or** an opt-in GitHub token is present) **and** no opt-out token is present. @@ -63,6 +63,13 @@ Optional free-form text after the command: {% else %} > **Why the extra buckets:** `git branch -d` only accepts branches *reachable* from the default branch. Any squash-merged branch (this project's `pr_merge_method` is **{{ pr_merge_method }}**, but GitHub's merge button can still squash) keeps a tip that is not an ancestor, so `-d` refuses it forever. {% endif %} +4b. **Workspace mode** (only with an `all` / `workspace` token — replaces steps 2–6 for the whole workspace; steps 7–8 then run per member). + 1. **Survey.** Resolve the workspace root (`issue-flow agent resolve --json` → `workspace_root`). Run `issue-flow workspace cleanup --json` from there (add `--extra-root ` per `root:` hint). It fetches, classifies `default-sync`, buckets local branches with the same code as `agent local-branches`, lists linked worktrees, and computes an A1 / A2 plan per member — **read-only**. Print the grouped table. Members it **refused** (`skipped: true` — dirty product-code tree, detached HEAD, missing `origin`, locked) are reported with their reason and left out of every confirm; the loop continues with the rest. + 2. **Phase A1 (one confirm for all members).** List per member: `switch ` (or why it is blocked), `pull --ff-only`, worktree removes, and `branch -d ` by name. Members whose `default_sync.action` is not `even` / `ff_only` are listed **with that action** and their pull is **skipped** — never pulled, rebased, or pushed; their `-d` deletes still run. {% if cleanup_yes_a1 %}`cleanup_yes_a1 = true`: print and run without a yes/no unless the input contains `ask a1`.{% else %}Ask once.{% endif %} On yes: `issue-flow workspace cleanup --apply --json` (same `--extra-root` hints). Without the CLI, run the listed `git -C ` commands yourself. A1 never authorises A2. + 3. **Phase A2 (second confirm, never implied by A1).** Only when any member's `plan.a2.branch_D` is non-empty. List per member every ` ` with its bucket (`squash_landed` / `merged_pr_divergent`) and merged PR; show `merged_pr_divergent` unique-commit subjects; print the recovery line `git branch `. **Never** list `unique_work` or `skipped` branches — the CLI never plans them either. {% if cleanup_yes_a2 %}`cleanup_yes_a2 = true`: print and run without a yes/no unless the input contains `ask a2`.{% else %}Ask once, separately.{% endif %} On yes: `issue-flow workspace cleanup --apply --yes-delete-squash-landed --json`; report every `applied.a2.deleted` entry as ` ` so the SHAs stay in the transcript. The `drive` / `landed` token replaces this prompt with the orchestrator's earlier confirm (same narrowed scope as step 6). + 4. **Phase B (third confirm, only when enabled per the Input rule).** Run step 9 per member and present **one** confirm grouped by member. + 5. Steps 7 (folder sweep) and 8 (epic gate offer) run per member. Step 10 reports each member. + 5. **Consolidated confirm (Phase A1 — local)** — {% if cleanup_yes_a1 %}`cleanup_yes_a1 = true`: print every action below, then run them. No yes/no unless the input contains `ask a1`. Running A1 does **not** authorize Phase A2.{% else %}one yes/no prompt listing every action:{% endif %} - `git switch ` (home only; skip if already on default) - `git pull --ff-only` — if it fails, **stop** A1 steps that assume default is current (apply-changelog, release tag, self-update) and recover via `default-sync` (see below). @@ -98,13 +105,14 @@ Optional free-form text after the command: {% include "skills/_gh_body_file.md.j2" %} - Phase B is **read-only until that second confirm**. Declining leaves remotes untouched. -10. **Report.** Summarize: default branch, PR/merge status, Phase A1 commands and `-d` deletions, Phase A2 `-D` deletions with their tip SHAs (or "declined" / "none offered"), branches left alone as unique work, folder sweep, epic stage-gate offer, self-update action (`upgraded` / `skipped` / `failed` / "not enabled"), and (when run) Phase B bucket counts, remote deletes, findings issue URL or "skipped". If this run used a workspace token, report each member. Else if `issue-flow agent resolve --json` reports `sibling_roots`, list them and remind the user that **each scaffolded repo needs its own `/iflow-cleanup`** (or `/iflow-cleanup workspace`) — do not loop automatically without the token.{% if "iflow_pr_sync" in included_skills %} If other open PRs still show `DIRTY` / CONFLICTING (often `{{ history_file }}`), **offer** `/iflow-pr-sync` — do not auto-run it.{% endif %} +10. **Report.** Summarize: default branch, PR/merge status, Phase A1 commands and `-d` deletions, Phase A2 `-D` deletions with their tip SHAs (or "declined" / "none offered"), branches left alone as unique work, folder sweep, epic stage-gate offer, self-update action (`upgraded` / `skipped` / `failed` / "not enabled"), and (when run) Phase B bucket counts, remote deletes, findings issue URL or "skipped". In workspace mode, report each member (including refused ones with their reason). Else if `issue-flow agent resolve --json` reports `sibling_roots`, list them and remind the user that **each scaffolded repo needs its own `/iflow-cleanup`** — do not loop automatically unless invoked with `all` (or `workspace`).{% if "iflow_pr_sync" in included_skills %} If other open PRs still show `DIRTY` / CONFLICTING (often `{{ history_file }}`), **offer** `/iflow-pr-sync` — do not auto-run it.{% endif %} ## Constraints - Never use `git push --force`. Never rebase default, force-push default, or push default to skip CI. - `git branch -D` is allowed **only** for `squash_landed` / `merged_pr_divergent` branches, **only** after the Phase A2 confirm{% if cleanup_yes_a2 %} (or without a prompt because `cleanup_yes_a2 = true` and the input does not contain `ask a2`){% endif %} (or an orchestrator confirm that explicitly named `-D`, signalled by the `drive` / `landed` token), and **only** with their tip SHAs reported. Never `-D` a branch holding unique work, a branch you could not classify, or the current branch. In Phase A1, a `-d` refusal is reported and left alone — it is never a licence to force-delete. - Never delete the default branch (local or remote). +- `issue-flow workspace cleanup` is classify-only by default. Pass `--apply` only after the workspace A1 yes, and `--apply --yes-delete-squash-landed` only after the workspace A2 yes (or the orchestrator token). Never pass either flag to "just see what happens". - Remote deletes and findings-issue creation require the **Phase B** confirm; the Phase A1 and A2 yeses must not imply them (nor each other). - If anything is ambiguous (detached HEAD, multiple remotes, missing tracking info), report and stop rather than guess. - Do not open or update PRs. Do not bump version fields — pyproject bumps belong to `/iflow-close`. The only version action allowed here is creating a release tag / GitHub release that `/iflow-close` **planned** (tag-derived strategy or publish-on-success label), inside the Phase A consolidated confirm. diff --git a/tests/test_templating.py b/tests/test_templating.py index e78a5ff..bc876b4 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -818,6 +818,30 @@ def test_cleanup_documents_orchestrator_token() -> None: assert "`drive` / `landed`" in cmd +def test_cleanup_documents_workspace_mode() -> None: + """Issue #392: `/iflow-cleanup all` — consolidated confirms, CLI half.""" + ctx = _default_context() + skill = render_template("skills/iflow_cleanup/SKILL.md.j2", ctx) + assert "issue-flow workspace cleanup --json" in skill + assert "issue-flow workspace cleanup --apply --json" in skill + assert ( + "issue-flow workspace cleanup --apply --yes-delete-squash-landed --json" + in skill + ) + assert "never more than three" in skill + assert "unless invoked with `all`" in skill + assert "There is no mute `workspace cleanup` CLI" not in skill + # A1 never implies A2; unique work stays out of every confirm. + assert "A1 never authorises A2" in skill + assert "**Never** list `unique_work` or `skipped` branches" in skill + # Non-ff members are listed with their default-sync action and skipped. + assert "not `even` / `ff_only`" in skill + cmd = render_template("commands/iflow-cleanup.md.j2", ctx) + assert "issue-flow workspace cleanup --json" in cmd + assert "--yes-delete-squash-landed" in cmd + assert "unless invoked with `all`" in cmd + + def test_iflow_dispatcher_warns_on_version_drift() -> None: ctx = _default_context() skill = render_template("skills/iflow_iflow/SKILL.md.j2", ctx) diff --git a/tests/test_workspace_cleanup.py b/tests/test_workspace_cleanup.py new file mode 100644 index 0000000..d32d8d6 --- /dev/null +++ b/tests/test_workspace_cleanup.py @@ -0,0 +1,415 @@ +"""Integration tests for `issue-flow workspace cleanup` (issue #392). + +Real git repos again (see ``test_agent_local_branches.py``): the behaviour +under test is the per-member classification plus the ``--apply`` git +plumbing, and both live or die on what git actually does. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from issue_flow import gitutils +from issue_flow.cli import app +from issue_flow.project import WORKSPACE_FILENAME + +pytestmark = pytest.mark.skipif( + not gitutils.git_available(), reason="git is not on PATH" +) + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + +@pytest.fixture(autouse=True) +def _no_gh(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gitutils, "GH", "gh-not-installed-for-tests") + monkeypatch.delenv("ISSUEFLOW_LOCKED", raising=False) + + +def _scaffold(root: Path) -> None: + base = root / ".issueflows" + for folder in ( + "01-current-issues", + "02-partly-solved-issues", + "03-solved-issues", + ): + (base / folder).mkdir(parents=True, exist_ok=True) + + +def _make_upstream(path: Path, *, squashed: bool, unique: bool) -> None: + """An upstream repo with ``ff-merged`` (+ optionally squashed / wip).""" + path.mkdir() + _git(path, "init", "--initial-branch=main") + _git(path, "config", "user.name", "Test") + _git(path, "config", "user.email", "test@example.com") + (path / "module.py").write_text("VALUE = 1\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "-m", "Initial commit") + + _git(path, "switch", "-c", "ff-merged") + (path / "ff.py").write_text("FF = 1\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "-m", "Add ff module") + _git(path, "switch", "main") + _git(path, "merge", "--ff-only", "ff-merged") + + if squashed: + _git(path, "switch", "-c", "squashed") + (path / "squashed.py").write_text("SQUASHED = 1\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "-m", "Add squashed module") + _git(path, "switch", "main") + _git(path, "merge", "--squash", "squashed") + _git(path, "commit", "-m", "Add squashed module (#1)") + + if unique: + _git(path, "switch", "-c", "in-progress") + (path / "wip.py").write_text("WIP = 1\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "-m", "Start some work") + _git(path, "switch", "main") + + +def _clone( + tmp_path: Path, upstream: Path, name: str, branches: tuple[str, ...] +) -> Path: + clone = tmp_path / name + _git(tmp_path, "clone", str(upstream), str(clone)) + _git(clone, "config", "user.name", "Test") + _git(clone, "config", "user.email", "test@example.com") + for branch in branches: + _git(clone, "branch", branch, f"origin/{branch}") + _scaffold(clone) + return clone + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + """Two scaffolded members: ``alpha`` (all shapes) and ``beta`` (ff only).""" + remotes = tmp_path / "remotes" + remotes.mkdir() + _make_upstream(remotes / "alpha", squashed=True, unique=True) + _make_upstream(remotes / "beta", squashed=False, unique=False) + _clone( + tmp_path, remotes / "alpha", "alpha", ("ff-merged", "squashed", "in-progress") + ) + _clone(tmp_path, remotes / "beta", "beta", ("ff-merged",)) + (tmp_path / WORKSPACE_FILENAME).write_text( + '[workspace]\ndefault = "alpha"\nmembers = ["alpha", "beta"]\n', + encoding="utf-8", + ) + return tmp_path + + +def _run(workspace: Path, *extra: str) -> tuple[int, dict[str, Any]]: + result = CliRunner().invoke( + app, + ["workspace", "cleanup", str(workspace), "--json", "--no-fetch", *extra], + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + return result.exit_code, payload + + +def _local_branches(root: Path) -> dict[str, Any]: + result = CliRunner().invoke( + app, ["agent", "local-branches", "-C", str(root), "--json", "--no-fetch"] + ) + return json.loads(result.stdout) + + +def _member(payload: dict[str, Any], name: str) -> dict[str, Any]: + return next(m for m in payload["members"] if m["name"] == name) + + +def _names(items: list[dict[str, Any]]) -> list[str]: + return [str(item["name"]) for item in items] + + +# --------------------------------------------------------------------------- +# classify-only +# --------------------------------------------------------------------------- + + +def test_buckets_match_agent_local_branches_per_member(workspace: Path) -> None: + """AC1: per-member buckets identical to ``agent local-branches``.""" + code, payload = _run(workspace) + assert code == 0, payload + assert payload["apply"] is False + for name in ("alpha", "beta"): + member = _member(payload, name) + assert member["ok"] is True and member["skipped"] is False + expected = _local_branches(workspace / name) + for bucket in ( + "reachable", + "squash_landed", + "merged_pr_divergent", + "unique_work", + "skipped", + ): + assert member["buckets"][bucket] == expected[bucket], (name, bucket) + alpha = _member(payload, "alpha") + assert _names(alpha["buckets"]["reachable"]) == ["ff-merged"] + assert _names(alpha["buckets"]["squash_landed"]) == ["squashed"] + assert _names(alpha["buckets"]["unique_work"]) == ["in-progress"] + assert payload["totals"]["reachable"] == 2 + assert payload["totals"]["unique_work"] == 1 + + +def test_plan_never_lists_unique_work(workspace: Path) -> None: + """AC4: ``unique_work`` never shows up in any delete list.""" + _, payload = _run(workspace) + alpha = _member(payload, "alpha") + plan = alpha["plan"] + assert plan["a1"]["branch_d"] == ["ff-merged"] + assert _names(plan["a2"]["branch_D"]) == ["squashed"] + assert "in-progress" not in plan["a1"]["branch_d"] + assert "in-progress" not in _names(plan["a2"]["branch_D"]) + entry = plan["a2"]["branch_D"][0] + assert entry["tip"] + assert entry["recover"] == f"git branch squashed {entry['tip']}" + + +def test_classify_only_changes_nothing(workspace: Path) -> None: + before = { + name: gitutils.list_local_branches(workspace / name) + for name in ("alpha", "beta") + } + code, _ = _run(workspace) + assert code == 0 + for name in ("alpha", "beta"): + assert gitutils.list_local_branches(workspace / name) == before[name] + + +def test_non_ff_member_skips_pull_but_others_proceed(workspace: Path) -> None: + """AC3: a member whose default cannot fast-forward is never pulled.""" + beta = workspace / "beta" + (beta / "extra.py").write_text("EXTRA = 1\n", encoding="utf-8") + _git(beta, "add", ".") + _git(beta, "commit", "-m", "Local-only commit on main") + + code, payload = _run(workspace) + assert code == 0 + member = _member(payload, "beta") + assert member["skipped"] is False + assert member["default_sync"]["ahead"] == 1 + assert member["default_sync"]["action"] not in ("even", "ff_only") + assert member["plan"]["a1"]["pull_ff_only"] is False + assert ( + member["plan"]["a1"]["pull_skipped_reason"] == member["default_sync"]["action"] + ) + # Classification still happened for the non-ff member and for alpha. + assert _names(member["buckets"]["reachable"]) == ["ff-merged"] + assert _names(_member(payload, "alpha")["buckets"]["reachable"]) == ["ff-merged"] + + +def test_refuse_to_loop_cases_skip_member_and_continue(tmp_path: Path) -> None: + remotes = tmp_path / "remotes" + remotes.mkdir() + for name in ("clean", "dirty", "detached", "noorigin"): + _make_upstream(remotes / name, squashed=False, unique=False) + _clone(tmp_path, remotes / name, name, ("ff-merged",)) + (tmp_path / "dirty" / "module.py").write_text("VALUE = 2\n", encoding="utf-8") + _git(tmp_path / "detached", "switch", "--detach", "HEAD") + _git(tmp_path / "noorigin", "remote", "remove", "origin") + (tmp_path / WORKSPACE_FILENAME).write_text( + '[workspace]\ndefault = "clean"\n' + 'members = ["clean", "dirty", "detached", "noorigin"]\n', + encoding="utf-8", + ) + + code, payload = _run(tmp_path) + assert code == 0, payload + assert payload["ok_count"] == 1 + assert payload["skip_count"] == 3 + assert payload["fail_count"] == 0 + assert _member(payload, "clean")["skipped"] is False + dirty = _member(payload, "dirty") + assert dirty["skipped"] is True + assert dirty["reason"] == "dirty product-code tree" + assert dirty["dirty_paths"] == ["module.py"] + assert _member(payload, "detached")["reason"] == "detached HEAD" + assert _member(payload, "noorigin")["reason"] == "missing origin remote" + + +def test_issueflows_only_dirt_blocks_switch_not_classification(workspace: Path) -> None: + alpha = workspace / "alpha" + _git(alpha, "switch", "in-progress") + (alpha / ".issueflows" / "01-current-issues" / "note.md").write_text( + "wip\n", encoding="utf-8" + ) + _, payload = _run(workspace) + member = _member(payload, "alpha") + assert member["skipped"] is False + assert member["dirty_class"] == "issueflows_only" + assert member["plan"]["a1"]["switch_default"] is False + assert "issueflows-only" in member["plan"]["a1"]["switch_blocked_reason"] + assert _names(member["buckets"]["reachable"]) == ["ff-merged"] + + +def test_unique_work_worktree_is_refused_not_member(workspace: Path) -> None: + alpha = workspace / "alpha" + wt = workspace / "alpha-wip" + _git(alpha, "worktree", "add", str(wt), "in-progress") + + _, payload = _run(workspace) + member = _member(payload, "alpha") + assert member["skipped"] is False + assert any("unique work" in line for line in member["refusals"]) + worktree = next(w for w in member["worktrees"] if w["branch"] == "in-progress") + assert worktree["bucket"] == "unique_work" + assert str(wt.resolve()) not in member["plan"]["a1"]["worktree_remove"] + assert str(wt.resolve()) not in member["plan"]["a2"]["worktree_remove"] + + +def test_extra_root_and_locked_member(workspace: Path, tmp_path: Path) -> None: + outside = tmp_path / "elsewhere" + outside.mkdir() + _make_upstream(tmp_path / "remotes" / "gamma", squashed=False, unique=False) + gamma = _clone(outside, tmp_path / "remotes" / "gamma", "gamma", ("ff-merged",)) + (workspace / "beta" / ".issueflows" / "config.toml").write_text( + "[issueflow]\nlocked = true\n", encoding="utf-8" + ) + + code, payload = _run(workspace, "--extra-root", str(gamma)) + assert code == 0, payload + names = [m["name"] for m in payload["members"]] + assert names == ["alpha", "beta", "gamma"] + assert _member(payload, "beta")["reason"] == "locked" + assert _names(_member(payload, "gamma")["buckets"]["reachable"]) == ["ff-merged"] + + +def test_missing_workspace_toml_errors(tmp_path: Path) -> None: + code, payload = _run(tmp_path) + assert code == 1 + assert payload["ok"] is False + assert WORKSPACE_FILENAME in payload["error"] + + +# --------------------------------------------------------------------------- +# --apply +# --------------------------------------------------------------------------- + + +def test_dry_run_with_apply_mutates_nothing(workspace: Path) -> None: + before = gitutils.list_local_branches(workspace / "alpha") + code, payload = _run( + workspace, "--apply", "--yes-delete-squash-landed", "--dry-run" + ) + assert code == 0 + assert payload["apply"] is False + assert payload["yes_delete_squash_landed"] is False + assert gitutils.list_local_branches(workspace / "alpha") == before + assert _member(payload, "alpha")["applied"] is None + + +def test_apply_deletes_reachable_only_without_a2_flag(workspace: Path) -> None: + alpha = workspace / "alpha" + # Merging a PR deletes the remote branch; the next fetch --prune drops the + # tracking ref — the state cleanup actually runs in (see #243). + _git(workspace / "remotes" / "alpha", "branch", "-D", "squashed") + _git(alpha, "update-ref", "-d", "refs/remotes/origin/squashed") + + code, payload = _run(workspace, "--apply") + assert code == 0, payload + assert payload["apply"] is True + member = _member(payload, "alpha") + a1 = member["applied"]["a1"] + assert [d["name"] for d in a1["deleted"]] == ["ff-merged"] + assert a1["deleted"][0]["flag"] == "-d" + assert a1["pulled"] is True + assert member["applied"]["a2"]["authorised"] is False + assert member["applied"]["a2"]["deleted"] == [] + remaining = gitutils.list_local_branches(alpha) or [] + assert "ff-merged" not in remaining + assert "squashed" in remaining + assert "in-progress" in remaining + assert any("--yes-delete-squash-landed" in note for note in member["notes"]) + beta = _member(payload, "beta") + assert [d["name"] for d in beta["applied"]["a1"]["deleted"]] == ["ff-merged"] + + +def test_apply_with_a2_flag_force_deletes_and_reports_tips(workspace: Path) -> None: + alpha = workspace / "alpha" + # The merged PR deleted the remote branch; the A1 pull's implicit fetch + # must not resurrect the tracking ref that would let plain -d succeed. + _git(workspace / "remotes" / "alpha", "branch", "-D", "squashed") + _git(alpha, "update-ref", "-d", "refs/remotes/origin/squashed") + tip = gitutils.branch_tip(alpha, "squashed") + + code, payload = _run(workspace, "--apply", "--yes-delete-squash-landed") + assert code == 0, payload + assert payload["yes_delete_squash_landed"] is True + member = _member(payload, "alpha") + a2 = member["applied"]["a2"] + assert a2["authorised"] is True + assert a2["deleted"] == [{"name": "squashed", "tip": tip, "flag": "-D"}] + remaining = gitutils.list_local_branches(alpha) or [] + assert "squashed" not in remaining + assert "ff-merged" not in remaining + assert "in-progress" in remaining # AC4: unique work survives -D runs + # Recovery line really works. + _git(alpha, "branch", "squashed", tip or "") + assert "squashed" in (gitutils.list_local_branches(alpha) or []) + + +def test_apply_switches_to_default_when_on_issue_branch(workspace: Path) -> None: + beta = workspace / "beta" + _git(beta, "switch", "-c", "7-some-issue") + + code, payload = _run(workspace, "--apply") + assert code == 0, payload + member = _member(payload, "beta") + assert member["plan"]["a1"]["switch_default"] is True + assert member["applied"]["a1"]["switched"] is True + assert gitutils.current_branch(beta) == "main" + # The issue branch was the current branch at classification time, so it + # was skipped — not deleted behind the user's back. + assert "7-some-issue" in (gitutils.list_local_branches(beta) or []) + + +def test_apply_never_pulls_non_ff_member(workspace: Path) -> None: + beta = workspace / "beta" + (beta / "extra.py").write_text("EXTRA = 1\n", encoding="utf-8") + _git(beta, "add", ".") + _git(beta, "commit", "-m", "Local-only commit on main") + head_before = gitutils.head_sha(beta) + + code, payload = _run(workspace, "--apply") + assert code == 0, payload + member = _member(payload, "beta") + assert member["applied"]["a1"]["pulled"] is False + assert gitutils.head_sha(beta) == head_before + # Reachable deletes still happen; the member is not refused outright. + assert [d["name"] for d in member["applied"]["a1"]["deleted"]] == ["ff-merged"] + + +def test_cli_help_lists_cleanup_and_flags() -> None: + result = CliRunner().invoke(app, ["workspace", "--help"]) + assert result.exit_code == 0 + assert "cleanup" in result.stdout + result = CliRunner().invoke(app, ["workspace", "cleanup", "--help"]) + assert result.exit_code == 0 + assert "--apply" in result.stdout + assert "--yes-delete-squash-landed" in result.stdout + assert "--extra-root" in result.stdout + + +def test_text_output_groups_by_member(workspace: Path) -> None: + result = CliRunner().invoke( + app, ["workspace", "cleanup", str(workspace), "--no-fetch"] + ) + assert result.exit_code == 0, result.stdout + out = result.stdout + assert "Workspace cleanup" in out + assert "alpha" in out and "beta" in out + assert "classify-only" in out + assert "squashed" in out + assert "-D" in out diff --git a/uv.lock b/uv.lock index 4756276..3f08080 100644 --- a/uv.lock +++ b/uv.lock @@ -52,7 +52,7 @@ wheels = [ [[package]] name = "issue-flow" -version = "0.5.16" +version = "0.5.17" source = { editable = "." } dependencies = [ { name = "jinja2" }, From f366501e88455239128d7d647d468461c0c57001 Mon Sep 17 00:00:00 2001 From: jepegit Date: Sat, 26 Sep 2026 22:47:56 +0200 Subject: [PATCH 2/2] Strip ANSI styling in the workspace cleanup help test so it passes when CI renders Rich help with colour (#392) Co-authored-by: Cursor --- tests/test_workspace_cleanup.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_workspace_cleanup.py b/tests/test_workspace_cleanup.py index d32d8d6..47b4eeb 100644 --- a/tests/test_workspace_cleanup.py +++ b/tests/test_workspace_cleanup.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re import subprocess from pathlib import Path from typing import Any @@ -23,6 +24,13 @@ not gitutils.git_available(), reason="git is not on PATH" ) +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _plain(text: str) -> str: + """Strip ANSI styling; CI renders Rich help with colour, splitting ``--flag``.""" + return _ANSI_RE.sub("", text) + def _git(cwd: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) @@ -394,12 +402,13 @@ def test_apply_never_pulls_non_ff_member(workspace: Path) -> None: def test_cli_help_lists_cleanup_and_flags() -> None: result = CliRunner().invoke(app, ["workspace", "--help"]) assert result.exit_code == 0 - assert "cleanup" in result.stdout + assert "cleanup" in _plain(result.stdout) result = CliRunner().invoke(app, ["workspace", "cleanup", "--help"]) assert result.exit_code == 0 - assert "--apply" in result.stdout - assert "--yes-delete-squash-landed" in result.stdout - assert "--extra-root" in result.stdout + plain = _plain(result.stdout) + assert "--apply" in plain + assert "--yes-delete-squash-landed" in plain + assert "--extra-root" in plain def test_text_output_groups_by_member(workspace: Path) -> None: