Skip to content

The merged-branch sweep is a standalone tool; sdlc consumes it and supplies the repositories

State: open/accepted

  • The git-side sweep is its own package. Discovery, gathering and classification of branches, worktrees and remote branches moved out of solutions/ontological/lib/services/project/cleanup/ into packages/ts/repo-cleaner (@sksizer/repo-cleaner, binary repo-cleaner), depending only on @sksizer/easy-git, @sksizer/easy-gh, @sksizer/command-seam and @sksizer/fleet.
  • Task linkage stays in sdlc. The tool classifies on git and GitHub evidence alone. sdlc post-processes the returned inventory with what it knows about task files. There is no callback into the classifier.
  • The tool reads no sdlc config. Its library entry point takes resolved repository paths. sdlc project cleanup resolves them and passes them in.
  • repos: is the input, under explicit fan-out. No new configuration key. A bare invocation still sweeps only the repository you are standing in; a flag is what puts the configured repositories in scope.
  • repos: entries gain a second shape. An entry that is itself a checkout names that one repository. An entry that is not is scanned one level deep, as today.

sdlc project cleanup already answers the question “what has been merged and can go away” for one repository. It gathers local branches, worktrees and remote branches, classifies each as safe-to-delete, safe-to-keep or needs-investigation, and emits a markdown report plus the literal commands that would clean each item up. It is strictly read-only; the judgment seam lives in /sdlc:project-cleanup.

Two things are missing. It runs against one repository, and the capability is welded to sdlc even though almost none of it is about sdlc.

The second point is measurable. cleanup/index.ts imports six things. Three are node: builtins or already-standalone packages. The remaining three — scanEntities, extractTitle, splitFrontmatter — appear only inside loadTasks and the task-linkage scope. One more, formatUtcFields, backs a single timestamp. The engine is already close to dependency-free; nothing about git merge-base --is-ancestor needs to know what a task file is.

Extract the git-side sweep into packages/ts/repo-cleaner, published outward on the snapshot-mirror channel alongside its dependencies. It ships a library entry point and the repo-cleaner CLI.

sdlc project cleanup keeps its verb, its schema and its skill, and becomes a thin consumer: resolve repositories, call the library, annotate the returned rows with task linkage, render.

The repositories to sweep come from the existing repos: key in sdlc.yaml and the machine config. No second key is introduced. Fan-out is opt-in at the verb, so a bare invocation never walks the machine.

Extraction is cheap and the boundary is already drawn. The sdlc-specific code is confined to one function and one scope. Nothing has to be untangled.

The capability is general. Finding merged branches is not an SDLC concern. A tool that does it belongs where anyone can use it, and the repo’s placement gradient (D-0001-project-structure) already describes how a package earns its way outward.

Composition beats a hook. The verdict ladder in classifyLocalBranch puts every git-derived delete above every task-derived rule. So sdlc never needs to veto the tool. It only sharpens rows the tool left at keep or needs-investigation, which is ordinary post-processing over the returned inventory. An annotator port would add an extension point that nothing needs.

One configuration key is enough. repos: already answers “where are this machine’s checkouts”. A second key holding the same paths under a different name would be two lists to keep in step.

Option A: extend sdlc project cleanup in place

Section titled “Option A: extend sdlc project cleanup in place”

Add --repo and a worklist key to the existing op. Cheapest by far, and no package to create. Rejected because the capability stays welded to sdlc, and the thing most worth reusing is the part with no sdlc content in it.

The tool exposes a hook so a host can contribute facts and override verdicts during classification. Rejected: the ordering analysis shows no override is needed, and the port would be an extension point built for a case that does not arise. Post-processing achieves the same result with a smaller surface.

Option C: a separate worklist key in sdlc.yaml

Section titled “Option C: a separate worklist key in sdlc.yaml”

repo_sweep: { scan: [...] }, distinct from the search-path repos:. Clearer to read, at the cost of two keys holding overlapping paths. Rejected in favour of reusing repos: with opt-in fan-out, which preserves the invariant that matters — no implicit fan-out — without a second list.

Each repos: entry resolves by inspection. No syntax distinguishes the kinds.

EntryDetected byResolves to
~/Developer/devcheckoutRoot(d) === dthat repository
~/Developera directory that is not a checkoutevery checkout one level under it
unreadable or missing—an unresolved row in the report

Resolution order: expand ~, resolve a relative path against the project root, machine-config entries before project entries, one level deep, hidden children skipped, results sorted.

Candidates are then de-duplicated on dirname(gitCommonDir()). A linked worktree collapses into its main checkout, so a container holding a repository and three of its worktrees sweeps one repository, not four. Those worktrees still appear — as worktree rows in that repository’s own inventory.

An entry that resolves to nothing is reported rather than dropped. A worklist that silently sweeps four of five named repositories is the worse failure.

Terminal window
repo-cleaner # the repository you are standing in
repo-cleaner --all # every repository resolved from config
repo-cleaner ~/work/api ~/work/web
sdlc project cleanup # unchanged: this repository only
sdlc project cleanup --all # sweeps what repos: resolves to
sdlc project cleanup --repo acme/api

--repo and --all REPLACE the default target rather than adding to it, matching sdlc pr survey: naming targets says what to sweep.

repos: remains a search path in its own right. The flag is what puts repositories in scope, which preserves the rule that a bare invocation never fans out across the machine.

# sdlc.yaml — committed
repos:
- ~/Developer # a container: every checkout one level under it
- ~/work/api # a single checkout
# <config-home>/sdlc/config.yaml — machine-local, never committed
repos:
- /Volumes/scratch/clones

Standalone, the tool reads its own list from @sksizer/toolconfig’s machine file — the host OS’s config home, so ~/Library/Application Support/repo-cleaner/config.yaml on macOS and ~/.config/repo-cleaner/config.yaml elsewhere. It never reads sdlc.yaml; when sdlc is the caller, sdlc resolves the list and passes paths in.

Default text mode renders one section per repository plus a roll-up. Each row carries a classification, a reason, the facts behind it, and the command that would act on it. Nothing is mutated.

Terminal window
$ repo-cleaner --all
/Users/me/work/api (acme/api, default main, origin)
local_branch fix/login-redirect delete PR #412 merged
git branch -d fix/login-redirect
local_branch spike/cache review mixed or absent signals
worktree wt/412 delete clean; PR #412 merged
git -C /Users/me/work/api worktree remove wt/412
remote_branch origin/fix/login-… delete PR #412 merged (auto-delete didn't fire)
git push origin --delete fix/login-redirect
/Users/me/work/web (acme/web, default main, origin)
local_branch chore/deps delete fully merged into main
git branch -d chore/deps
unresolved
~/work/old-thing not a directory
3 to delete, 1 to review, across 2 repositories (1 entry unresolved).

--output json returns the full inventory: per-repository info, rows with their facts, and the summary counts.

  • No gh, or gh not authenticated: classification proceeds on git evidence alone and the report says so. This is today’s hasGh / ghAuthenticated probe, unchanged.
  • A repository whose default branch cannot be determined becomes an error row for that repository. The sweep continues. Today discoverRepo throws, which is correct for one repository and wrong for a fan-out.

sdlc matches branch names to task files and rewrites rows it has an opinion about — a branch whose task is closed/done, a branch whose task is in-progress. It also contributes the task-linkage rows that have no git counterpart, such as a task with no branch anywhere.

For this to work the tool must emit complete facts on every row, including rows whose verdict was obvious, and must return investigation_prompt as composable parts rather than a joined string. Today that string contains the sentence “No matching task file under docs/planning/tasks/”, which has to leave the tool.

  • repos: serves two intents. A repository added for sweeping is also a repository sdlc pr review will find. Benign — locateRepo gains a candidate — but deliberate.
  • Three places state that repos: is a search path and never a worklist: the Zod description in lib/config/load.ts, the repos: section of conventions/sdlc-yaml.md, and the resolveTargets doc comment in pr/ops/survey.ts. Each is amended in place to say that a verb may fan out when explicitly asked. The invariant they protect is unchanged.
  • Accepting a direct checkout path in repos: improves locateRepo, which today cannot match an entry pointing straight at a checkout.
  • @sksizer/easy-gh joins the publish channel. Nothing on the channel has been released yet, so this is an entry in publish-libs.json, not a gate.
  • A new package costs its moon.yml, its tsconfigs, a publish-libs.json entry with needs, an npm bin name that clears, and a tier-1 row in docs/LIBRARIES.md.
  • Add @sksizer/easy-gh to publish-libs.json (with fleet, which it needed too).
  • Create the package: discoverRepo, the three gatherers, the classifiers and the renderers, with every matchedTask branch dropped and the date helper inlined.
  • Swap the hand-rolled gh pr list --json parse for @sksizer/easy-gh.
  • Add multi-repository resolution, per-repository error containment and the aggregate renderer. The fan-out is sequential; async is deferred.
  • Add the CLI and its standalone config.
  • Rewrite services/project/cleanup/index.ts as an adapter: resolve, call, annotate, render. The op’s existing output-shape tests are the regression net.
  • Extend sdlc project cleanup with --repo and --all.
  • Amend the three repos: descriptions and add the entry-kind table.
  • Add the tier-1 row to docs/LIBRARIES.md.
  • Read-only, or a --prune mode. Today’s op emits commands as strings and leaves execution to the skill’s approval flow. A standalone tool with no agent behind it may want to act, which changes the API.
  • One ordering rule changes under composition. Today a branch with a closed/done task and an open PR classifies as delete, because the task rule sits above the open-PR rule. Post-processing makes it keep-then-upgrade. The current precedence looks wrong — an open PR against a closed task is the definition of needs-investigation — but it should be decided, not inherited.
  • Whether sdlc project cleanup keeps ownership of the whole report or narrows to task linkage over the tool’s inventory. The proposal keeps the verb and narrows what it owns.

← Back to Decisions