@osdlc/repo-cleaner 0.0.0-stage → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,302 @@
1
- # Temporary Holding Version
1
+ # @osdlc/repo-cleaner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What has been merged into the default branch, and what can go away.
4
+
5
+ Point it at a repository and it inventories the local branches, worktrees and
6
+ remote branches, classifying each as `safe-to-delete`, `safe-to-keep` or
7
+ `needs-investigation` — with the evidence behind the verdict and the literal
8
+ command that would act on it.
9
+
10
+ The sweep never deletes anything. It emits commands, and can write the
11
+ deletable rows as a plan. `repo-cleaner apply` deletes the plan rows you
12
+ select, after checking each one again.
13
+
14
+ ```console
15
+ $ repo-cleaner
16
+ /Users/me/work/api (acme/api, default main, origin)
17
+ local_branch fix/login-redirect delete PR #412 merged
18
+ git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d
19
+ local_branch spike/cache review mixed or absent signals
20
+ worktree wt/412 delete clean; PR #412 merged
21
+ git -C /Users/me/work/api worktree remove wt/412
22
+ remote_branch origin/fix/login-… delete PR #412 merged (auto-delete didn't fire)
23
+ git push --force-with-lease=refs/heads/fix/login-redirect:3f9c2e1d8b7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d origin :refs/heads/fix/login-redirect
24
+
25
+ 1 to delete, 1 to review, across 1 repository.
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm install @osdlc/repo-cleaner
32
+ ```
33
+
34
+ ESM only. Compiled JavaScript with type declarations, for Node 22 or newer.
35
+
36
+ ## How it decides
37
+
38
+ Two signals settle most items, and either alone is enough:
39
+
40
+ | Signal | Source |
41
+ | --- | --- |
42
+ | A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list`, one listing per repository |
43
+ | The branch tip is reachable from the default branch | `git merge-base --is-ancestor` |
44
+
45
+ Ancestry is measured against `<remote>/<default>`, so work merged only into an
46
+ unpushed local default branch does not count. Without a remote, the local
47
+ default branch is used.
48
+
49
+ A merged PR that fails the head or base check is kept in `facts.pr` with
50
+ `evidence` set to `head-moved` or `other-base`. Fork PRs are ignored.
51
+
52
+ Everything else is held back. A branch that is neither merged nor an ancestor
53
+ is `needs-investigation` with the reasons spelled out, not a guess. A worktree
54
+ holding uncommitted or unpushed work is never proposed for removal whatever
55
+ else is true of it — losing an hour of someone's work costs more than any
56
+ amount of clutter.
57
+
58
+ Some worktrees are held back whatever their branch says. Each lands in
59
+ `facts.blockers`, and the row carries no command and has `deletable: false`:
60
+
61
+ | Blocker | Why |
62
+ | --- | --- |
63
+ | `locked-worktree` | Someone ran `git worktree lock` and the lock's owner is alive or unknown; the reason is in `facts.lock_reason`. A lock whose owner is dead is not a blocker (see below). |
64
+ | `live-process` | A process other than an idle shell has its working directory in the worktree (`facts.live_processes`), lock or not. |
65
+ | `process-probe-failed` | The process table could not be read (no `lsof`, a timeout, an `lsof` that failed outright), so nobody can say the directory is idle. `facts.process_probe_error` has the message. |
66
+ | `hidden-index-flags` | Files marked skip-worktree or assume-unchanged hide edits from `status`. |
67
+ | `nested-repository` | An untracked or ignored directory holds a repository with its own history. |
68
+ | `branch-in-other-worktree` | Another worktree has the same branch checked out (`facts.other_worktrees`). |
69
+
70
+ ### Locks and running processes
71
+
72
+ A crashed coding agent leaves its worktree locked. The sweep reads one generic
73
+ token from the lock reason, `pid <N>`, optionally followed by `start <time>`,
74
+ and asks whether that process is still running. It reads nothing else of the
75
+ reason, so a free-form reason such as `keep, build running` always blocks.
76
+
77
+ - The owner is dead: the lock is not a blocker. `facts.stale_lock` is `true`,
78
+ `facts.lock_owner` holds the pid and start time, and the row's commands begin
79
+ with `git worktree unlock`. `apply` unlocks, removes and verifies it, and the
80
+ receipt records the `unlock-worktree` step.
81
+ - The owner is alive, or cannot be ruled out: `locked-worktree`.
82
+ - A lock writes `pid <N>` and, to guard against pid reuse, `start <time>`. The
83
+ time must be precise to the second: a full ISO-8601 timestamp with a time of
84
+ day and a zone (`2026-10-25T18:22:19Z`; one with no zone is not accepted), epoch seconds (9-10 digits), epoch milliseconds
85
+ (12-13 digits), or the `ps -o lstart` form (`Sat Oct 25 18:22:19 2026`). With
86
+ one of those, a live pid whose own start time differs by more than two
87
+ seconds is a reused pid and counts as dead. Anything else, a date alone, a
88
+ short number or a partial date, cannot rule the owner out and the lock stays
89
+ a blocker.
90
+ - Only `ps` exiting 1 with no output and no error text means "no such pid".
91
+ Any other `ps` failure counts as alive.
92
+ - The `pid` token is trusted as a local pid. A pid from another container or
93
+ host that shares the filesystem is looked up here, will not be found, and
94
+ reads as dead; do not sweep a repository whose locks were written elsewhere.
95
+ - Before the unlock and again before the remove, `apply` asks again, and reads
96
+ the process table afresh. If the owner is alive by then, or a process other
97
+ than an idle shell works in the directory, or the table cannot be read, the
98
+ step fails and the run stops. A row dropped at the re-sweep touches nothing;
99
+ a step that fails at the unlock leaves the lock in place. `stale_lock` is part
100
+ of a worktree row's fingerprint, so a lock taken after the plan was written
101
+ drops the row too.
102
+ - If the remove fails after the unlock succeeded, the lock stays cleared:
103
+ `@osdlc/typed-git` has no `worktree lock` verb to put it back. The failed
104
+ step's receipt error says so.
105
+ - Separately, any process whose working directory is inside the worktree blocks
106
+ it as `live-process`, lock or not. A shell (`sh`, `bash`, `zsh`, `fish`) is
107
+ idle only when it has no child processes; it is then listed in
108
+ `facts.idle_shells` and does not block. A shell with a child, or whose
109
+ children are unknown, counts as a live process.
110
+
111
+ Both questions go to a `ProcessProbe` (`alive(pid, startedAt?)` and
112
+ `holders(path, { fresh? })`), passed as `probe` in the options of `sweepRepo`,
113
+ `sweepRepos` and `applyPlan`. The default, `defaultProcessProbe()`, runs
114
+ `ps -o lstart=`, `ps -A -o pid=,ppid=` and `lsof -d cwd` through
115
+ `@osdlc/command-seam`, and needs `ps` and `lsof` on `PATH`. It reads the
116
+ process table once per probe and reuses it for every worktree: `sweepRepo`
117
+ makes one probe per call, and `applyPlan` uses one probe for its re-sweep and
118
+ reads the table afresh for each pre-unlock and pre-remove check. Where the
119
+ table cannot be read the worktree is held back as `process-probe-failed`. A
120
+ host or test supplies its own probe.
121
+ Nothing is ever killed.
122
+
123
+ Without `gh`, or without authentication, classification proceeds on git
124
+ evidence alone and the report says so.
125
+
126
+ Every branch delete names the full sha the sweep judged. `update-ref -d` and
127
+ the pinned `--force-with-lease` push both refuse when the branch has moved
128
+ since, so a commit pushed while you read the plan survives it.
129
+ `localDeleteCommand` and `remoteDeleteCommand` build them for hosts that add
130
+ their own rows.
131
+
132
+ Every row names, in `requires`, the risks its delete carries, so a person
133
+ approves them by name:
134
+
135
+ | Authorization | When a row needs it |
136
+ | --- | --- |
137
+ | `abandon-unmerged` | Commits not on the default branch and not captured by an exact-head merged PR; a worktree's unpushed commits. `facts.unique_commits` counts them. |
138
+ | `discard-changes` | A worktree has modified, staged or untracked files. |
139
+ | `confirm-open-pr` | The branch has an open PR. |
140
+
141
+ A row kept for structure (default branch, current HEAD, a branch held by a
142
+ worktree, the primary or caller's worktree) has `deletable: false`.
143
+
144
+ ## Scope is opt-in
145
+
146
+ ```bash
147
+ repo-cleaner # the repository you are standing in
148
+ repo-cleaner ~/work/api ~/work/web # named repositories
149
+ repo-cleaner ~/Developer # every checkout under it
150
+ repo-cleaner --all # every repository in the config file
151
+ ```
152
+
153
+ A bare invocation never fans out. What an argument names is read off the disk
154
+ rather than from how it is written: a path that is itself a checkout names
155
+ that repository, and a path that is not is searched for the checkouts under
156
+ it, descending through directories that are not checkouts — an owner
157
+ directory, a folder per client — and stopping at the ones that are.
158
+
159
+ A linked worktree collapses into the repository it belongs to, so a directory
160
+ holding a checkout and three of its worktrees sweeps one repository, not four.
161
+ Those worktrees still appear — as worktree rows in that repository's own
162
+ inventory.
163
+
164
+ The report ends with what the search cost — `searched 1 entry: 214
165
+ directories read, 12 checkouts found.` — because those numbers are how an
166
+ entry pointing at the wrong directory gets caught. A search that passes
167
+ 10,000 directories says so on stderr while it is still running, and the walk
168
+ stops descending three levels below the entry.
169
+
170
+ An entry that resolves to nothing is reported rather than dropped. A worklist
171
+ that silently sweeps four of the five repositories it was given is worse than
172
+ one that says which it could not find.
173
+
174
+ ## Options
175
+
176
+ | Flag | Effect |
177
+ | --- | --- |
178
+ | `--all` | Sweep the repositories named in the config file |
179
+ | `--config <path>` | Config file to read |
180
+ | `--scope <list>` | `local-branches`, `worktrees`, `remote-branches` |
181
+ | `--remote <name>` | Remote to measure against (default `origin`) |
182
+ | `--no-gh` | Classify on git evidence alone |
183
+ | `--no-fetch` | Skip the fetch; the header then says `not fetched` |
184
+ | `--keep` | Include `safe-to-keep` rows in the report |
185
+ | `--commands` | Print the proposed commands of rows that need no authorization, and nothing else |
186
+ | `--output json` | The full inventory, facts included |
187
+ | `--branch <name\|HEAD>` | Sweep one branch of this repository: its worktree, local and remote branch. Refuses the default branch |
188
+ | `--write-plan` | Write the deletable rows as a plan for `apply` |
189
+ | `--plan-out <path>` | Write the plan here; implies `--write-plan` |
190
+
191
+ A checkout directory named `apply` is swept as `./apply`.
192
+
193
+ ## Plan and apply
194
+
195
+ ```bash
196
+ repo-cleaner --branch HEAD --plan-out plan.json
197
+ repo-cleaner apply --plan plan.json --row worktree:/path/to/wt --row local_branch:feat/x
198
+ ```
199
+
200
+ A plan holds each deletable row with a fingerprint of the facts behind its
201
+ verdict. `apply` sweeps again and drops a selected row that is gone, no longer
202
+ deletable, whose facts moved, or whose `requires` was not granted with
203
+ `--authorize <name>` (every row) or `--authorize <name>=<category>:<id>` (one
204
+ row). It then removes worktrees, deletes local branches, deletes remote
205
+ branches and prunes, in that order. Branch deletes are compare-and-swap, every
206
+ step is verified, and the first failure stops the run.
207
+
208
+ A receipt records every step and is rewritten after each one. Plans and receipts go under
209
+ `repo-cleaner/plans/` and `repo-cleaner/receipts/` in
210
+ [`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone durable home
211
+ (`repo-cleaner` directory, `REPO_CLEANER_*` override variables), unless `--plan-out` or `--receipt-dir` says
212
+ otherwise. `--dry-run` checks again and prints the steps without writing anything.
213
+
214
+ ## Exit codes
215
+
216
+ | Code | Meaning |
217
+ | --- | --- |
218
+ | `0` | Swept; or every selected row was applied or dropped with a reason |
219
+ | `1` | Failed against the state it found: a `{name}` token nothing defines, an unreadable plan, a row not in the plan |
220
+ | `2` | Cannot run as typed: an unknown flag, a malformed `--row`, `--branch` with `--all` or a path |
221
+ | `10` | Nothing to sweep: no entry named a repository |
222
+ | `11` | `apply` stopped at a failed step; the receipt says which |
223
+
224
+ ## Configuration
225
+
226
+ Only the CLI reads a config file. The library takes repositories as an
227
+ argument, so a host that already knows where its repositories are passes them
228
+ in.
229
+
230
+ ```yaml
231
+ paths:
232
+ dev: ~/Developer
233
+ repos:
234
+ - '{dev}'
235
+ - ~/work/api
236
+ ```
237
+
238
+ `paths:` names a directory once; any `repos:` entry then refers to it as
239
+ `{name}`. A token nothing defines is an error, not an empty path.
240
+
241
+ The file is [`@osdlc/config-layers`](https://www.npmjs.com/package/@osdlc/config-layers)'s machine config — `config.yaml` in the host
242
+ OS's config home, resolved through
243
+ [`@osdlc/tiered-dirs`](https://www.npmjs.com/package/@osdlc/tiered-dirs)' standalone tier (`repo-cleaner`
244
+ directory, `REPO_CLEANER_*` override variables):
245
+
246
+ | OS | Path |
247
+ | --- | --- |
248
+ | macOS | `~/Library/Application Support/repo-cleaner/config.yaml` |
249
+ | Linux and other | `$XDG_CONFIG_HOME/repo-cleaner/config.yaml`, else `~/.config/repo-cleaner/config.yaml` |
250
+ | Windows | `%APPDATA%\repo-cleaner\config.yaml` |
251
+
252
+ `REPO_CLEANER_MACHINE_CONFIG`, or `--config <path>`, relocates it. A missing,
253
+ unreadable or malformed file degrades to no repositories rather than failing
254
+ the run.
255
+
256
+ ## As a library
257
+
258
+ ```ts
259
+ import { sweepRepo, sweepRepos } from '@osdlc/repo-cleaner'
260
+
261
+ const sweep = sweepRepo('/Users/me/work/api', { noGh: true })
262
+ for (const item of sweep.items) {
263
+ console.log(item.classification, item.id, item.reason)
264
+ }
265
+
266
+ const result = sweepRepos(['~/Developer'], { cwd: process.cwd() })
267
+ ```
268
+
269
+ `sweepRepo` never throws for a repository it cannot read: the failure comes
270
+ back as `error`, because a fan-out must not end on its first bad entry.
271
+
272
+ `fetch: true` runs `git fetch --prune --quiet --no-write-fetch-head <remote>`
273
+ before classifying, so ancestry is read against fresh remote-tracking refs.
274
+ That fetch is the only write. A repository whose fetch fails comes back with `error` starting
275
+ `fetch failed:` and no items. `repo.fetched` says which case applied. The
276
+ library default is off; the CLI fetches unless given `--no-fetch`.
277
+
278
+ ### Adding knowledge the library does not have
279
+
280
+ Every row carries the facts behind its verdict, and `investigationParts` is a
281
+ list of sentences rather than a finished string. That is deliberate: a host
282
+ that knows something git does not — an issue tracker, a task corpus — re-ranks
283
+ the returned rows and appends its own sentence, instead of reaching into
284
+ classification. `applyPlan` takes the same re-ranking as its `annotate` hook,
285
+ so the rows it checks again are the rows the host planned from.
286
+
287
+ This works because of how the ladder is ordered. Every verdict the sweep
288
+ reaches on its own evidence outranks anything a host could contribute, so a
289
+ host never has to veto one. It only sharpens the rows left at `safe-to-keep`
290
+ or `needs-investigation`.
291
+
292
+ An issue tracker's linkage, applied this way, is a host's own layer over the
293
+ sweep and `applyPlan`.
294
+
295
+ ## Runtime dependencies
296
+
297
+ `git` on `PATH`, and `gh` when PR state should count. Authentication is `gh`'s
298
+ own — this package reads no token.
299
+
300
+ ## Licence
301
+
302
+ `MIT OR Apache-2.0`, at your option. See [LICENSE-MIT](./LICENSE-MIT) and [LICENSE-APACHE](./LICENSE-APACHE).
package/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report vulnerabilities privately. Do not open a public issue.
6
+
7
+ Use GitHub private vulnerability reporting on the repository: open the
8
+ [Security tab](https://github.com/osdlc/osdlc/security) and choose
9
+ "Report a vulnerability".
10
+
11
+ Include the version, a short description, and steps to reproduce.
12
+
13
+ ## Supported versions
14
+
15
+ Only the latest release gets security fixes. While the version is 0.x, that
16
+ is the latest minor release.
17
+
18
+ ## What to expect
19
+
20
+ This is a small project. We aim to reply within a week, tell you whether we
21
+ accept the report, and credit you in the release notes if you want that.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // The published entry point: `npx @osdlc/repo-cleaner …` and a plain
3
+ // `node ./bin/repo-cleaner.js …` both land here. Requires the built dist/cli.js
4
+ // (`pnpm run build`).
5
+ import { runMain } from '@osdlc/cli-tool'
6
+ import { main } from '../dist/cli.js'
7
+
8
+ await runMain(main, { name: 'repo-cleaner' })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Apply an approved cleanup plan.
3
+ *
4
+ * Re-sweeps each repository and drops selected rows that are gone, no longer
5
+ * deletable, changed, or lack a grant. Deletes the rest in a fixed order and
6
+ * stops at the first failure:
7
+ *
8
+ * 1. worktrees, each unlocked first when its lock's owner is dead, and each
9
+ * checked again for live processes just before it is unlocked or removed;
10
+ * 2. local branches;
11
+ * 3. remote branches;
12
+ * 4. one prune per repository.
13
+ *
14
+ * Branch deletes are compare-and-swap on the planned sha. Every step is
15
+ * verified, and the receipt is rewritten after each.
16
+ */
17
+ import type { CommandRunner } from '@osdlc/command-seam';
18
+ import type { HostHooks } from './hooks.ts';
19
+ import { type ProcessProbe } from './processes.ts';
20
+ import { type Plan } from './plan.ts';
21
+ import type { Receipt, RowRef } from './receipt.ts';
22
+ import { type Authorization } from './types.ts';
23
+ /** An authorization the caller gave, for one row or (`row: null`) for all. */
24
+ export interface Grant {
25
+ name: Authorization;
26
+ row: RowRef | null;
27
+ }
28
+ export interface ApplyOptions extends HostHooks {
29
+ /** Injected runners. Defaults spawn the real `git` and `gh`. */
30
+ git?: CommandRunner | undefined;
31
+ gh?: CommandRunner | undefined;
32
+ /**
33
+ * Answers lock-owner and working-directory questions, for the re-sweep and
34
+ * for the checks just before an unlock and a remove. Defaults to `ps` and
35
+ * `lsof`.
36
+ */
37
+ probe?: ProcessProbe | undefined;
38
+ /** The caller's directory; the sweep never offers the worktree holding it. */
39
+ callerCwd: string;
40
+ dryRun: boolean;
41
+ /** Where the receipt goes. Unused under `dryRun`. */
42
+ receiptPath: string;
43
+ /** Shown in the receipt. */
44
+ planPath: string;
45
+ /** Limit the run to the plan repository rooted here. */
46
+ repoRoot?: string | undefined;
47
+ now?: () => Date;
48
+ }
49
+ /** A selection or grant the plan cannot satisfy. Nothing has been touched. */
50
+ export declare class ApplyArgumentError extends Error {
51
+ }
52
+ /** `<category>:<id>`, split at the first colon. */
53
+ export declare function parseRowRef(text: string): RowRef;
54
+ /** `<name>` for every row, or `<name>=<category>:<id>` for one. */
55
+ export declare function parseGrant(text: string): Grant;
56
+ /**
57
+ * Apply the `requested` rows of `plan` under `grants`. A row named twice runs once.
58
+ *
59
+ * @throws ApplyArgumentError before touching anything, when a row or grant does
60
+ * not fit the plan.
61
+ */
62
+ export declare function applyPlan(plan: Plan, requested: readonly RowRef[], grants: readonly Grant[], opts: ApplyOptions): Receipt;