@osdlc/repo-cleaner 0.9.2 → 0.10.0

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/dist/devlink.d.ts CHANGED
@@ -7,4 +7,9 @@
7
7
  * `REPO_CLEANER_HOME` or `repo-cleaner dev link` hand off to a checkout.
8
8
  */
9
9
  import { type ToolSpec } from '@osdlc/cli-tool/devlink';
10
+ /** Whose directories devlink's state lives under: vendor `repo-cleaner`, moved by the `REPO_CLEANER_*` override variables. */
11
+ export declare const VENDOR_DIRS: {
12
+ vendor: string;
13
+ envVars: import("@osdlc/tiered-dirs").EnvVars;
14
+ };
10
15
  export declare const DEVLINK_SPEC: ToolSpec;
package/dist/devlink.js CHANGED
@@ -6,8 +6,10 @@
6
6
  * checkout runs `src/bin.ts` under Bun. The cwd layer stays off: only
7
7
  * `REPO_CLEANER_HOME` or `repo-cleaner dev link` hand off to a checkout.
8
8
  */
9
+ import { envVars } from '@osdlc/cli-tool';
9
10
  import { validateSpec } from '@osdlc/cli-tool/devlink';
10
- import { VENDOR_DIRS } from "./config.js";
11
+ /** Whose directories devlink's state lives under: vendor `repo-cleaner`, moved by the `REPO_CLEANER_*` override variables. */
12
+ export const VENDOR_DIRS = { vendor: 'repo-cleaner', envVars: envVars('REPO_CLEANER') };
11
13
  export const DEVLINK_SPEC = validateSpec({
12
14
  name: 'repo-cleaner',
13
15
  marker: 'src/cli.ts',
package/dist/index.d.ts CHANGED
@@ -18,14 +18,13 @@ export { classifyLocalBranch, classifyRemoteBranch, gatherLocalBranches, gatherR
18
18
  export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand, type WorktreeBlocker, } from './worktrees.ts';
19
19
  export { DiscoveryFailed, discoverRepo } from './discover.ts';
20
20
  export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from './landing.ts';
21
- export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
21
+ export { ApplyArgumentError, applyPlan, type RowSelection, parseGrant, parseRowRef, type ApplyOptions, type Grant, } from './apply.ts';
22
22
  export type { Annotate, HostHooks } from './hooks.ts';
23
- export { PLAN_VERSION, PlanReadError, planRepo, readPlan, stampedPath, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
23
+ export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
24
24
  export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
25
25
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
26
26
  export { fetchPrs } from './prs.ts';
27
27
  export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
28
28
  export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
29
29
  export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
30
- export { CONFIG_ENV, DEFAULT_CONFIG, RepoCleanerConfigSchema, TOOL, clearConfigCache, configPath, configuredRepos, loadConfig, type RepoCleanerConfig, } from './config.ts';
31
- export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepResult, type UnresolvedEntry, } from './types.ts';
30
+ export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, type Authorization, type Category, type Classification, type DuplicateCheckout, type PRInfo, type RepoInfo, type RepoSweep, type ScanStats, type Scope, type SummaryBucket, type SweepItem, type SweepProgress, type SweepResult, type UnresolvedEntry, } from './types.ts';
package/dist/index.js CHANGED
@@ -19,12 +19,11 @@ export { classifyWorktree, gatherWorktrees, hasBlockers, worktreePruneCommand, w
19
19
  export { DiscoveryFailed, discoverRepo } from "./discover.js";
20
20
  export { assessLanding, patchEquivalent } from "./landing.js";
21
21
  export { ApplyArgumentError, applyPlan, parseGrant, parseRowRef, } from "./apply.js";
22
- export { PLAN_VERSION, PlanReadError, planRepo, readPlan, stampedPath, writePlan, } from "./plan.js";
22
+ export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
23
23
  export { ReceiptSchema } from "./receipt.js";
24
24
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
25
25
  export { fetchPrs } from "./prs.js";
26
26
  export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
27
27
  export { sweepRepo, sweepRepos } from "./sweep.js";
28
28
  export { proposedCommands, renderJson, renderReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
29
- export { CONFIG_ENV, DEFAULT_CONFIG, RepoCleanerConfigSchema, TOOL, clearConfigCache, configPath, configuredRepos, loadConfig, } from "./config.js";
30
29
  export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
package/dist/plan.d.ts CHANGED
@@ -78,8 +78,8 @@ export declare function isPlanCategory(category: string): category is PlanCatego
78
78
  /**
79
79
  * The facts a row's verdict rests on. A branch: its sha and its PR's number
80
80
  * and state. A worktree: HEAD, branch, how much unlanded work it holds and
81
- * whether its lock was stale, so a lock taken or revived after planning drops
82
- * the row.
81
+ * whether its lock was stale and whether it is on disk, so a lock taken or
82
+ * revived after planning, or a directory that came back, drops the row.
83
83
  */
84
84
  export declare function fingerprint(item: PlannableRow): Record<string, unknown>;
85
85
  /** Equal fingerprints, treating a missing key as null. */
@@ -92,10 +92,14 @@ export declare function planRepo(root: string, opts: {
92
92
  }, items: readonly PlannableRow[]): PlanRepo;
93
93
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
94
94
  export declare function fileStamp(d: Date): string;
95
- export declare function stampedPath(dir: string, d: Date): string;
96
95
  export declare function writePlan(path: string, plan: Plan): void;
97
96
  /** A plan file that cannot be read or does not match {@link PlanSchema}. */
98
97
  export declare class PlanReadError extends Error {
99
98
  }
99
+ /**
100
+ * Parse a plan's JSON text. `source` names where it came from, for the error.
101
+ * @throws PlanReadError
102
+ */
103
+ export declare function parsePlan(text: string, source: string): Plan;
100
104
  /** @throws PlanReadError */
101
105
  export declare function readPlan(path: string): Plan;
package/dist/plan.js CHANGED
@@ -4,7 +4,6 @@
4
4
  * whose fingerprint no longer matches.
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
8
7
  import { z } from 'zod';
9
8
  import { AUTHORIZATIONS } from "./types.js";
10
9
  import { writeJsonAtomic } from "./write-json.js";
@@ -37,8 +36,8 @@ export function isPlanCategory(category) {
37
36
  /**
38
37
  * The facts a row's verdict rests on. A branch: its sha and its PR's number
39
38
  * and state. A worktree: HEAD, branch, how much unlanded work it holds and
40
- * whether its lock was stale, so a lock taken or revived after planning drops
41
- * the row.
39
+ * whether its lock was stale and whether it is on disk, so a lock taken or
40
+ * revived after planning, or a directory that came back, drops the row.
42
41
  */
43
42
  export function fingerprint(item) {
44
43
  const f = item.facts;
@@ -49,6 +48,9 @@ export function fingerprint(item) {
49
48
  dirty_files: f['dirty_files'] ?? null,
50
49
  unpushed_commits: f['unpushed_commits'] ?? null,
51
50
  stale_lock: f['stale_lock'] ?? null,
51
+ // Prune or remove: a directory that came back between plan and apply
52
+ // must not turn a prune into a remove.
53
+ on_disk: f['on_disk'] ?? null,
52
54
  };
53
55
  }
54
56
  const pr = f['pr'];
@@ -82,26 +84,37 @@ export function planRepo(root, opts, items) {
82
84
  export function fileStamp(d) {
83
85
  return d.toISOString().replace(/[:.]/g, '-');
84
86
  }
85
- export function stampedPath(dir, d) {
86
- return join(dir, `${fileStamp(d)}.json`);
87
- }
88
87
  export function writePlan(path, plan) {
89
88
  writeJsonAtomic(path, plan);
90
89
  }
91
90
  /** A plan file that cannot be read or does not match {@link PlanSchema}. */
92
91
  export class PlanReadError extends Error {
93
92
  }
94
- /** @throws PlanReadError */
95
- export function readPlan(path) {
93
+ /**
94
+ * Parse a plan's JSON text. `source` names where it came from, for the error.
95
+ * @throws PlanReadError
96
+ */
97
+ export function parsePlan(text, source) {
96
98
  let raw;
97
99
  try {
98
- raw = JSON.parse(readFileSync(path, 'utf-8'));
100
+ raw = JSON.parse(text);
99
101
  }
100
102
  catch (err) {
101
- throw new PlanReadError(`cannot read plan ${path}: ${err instanceof Error ? err.message : String(err)}`);
103
+ throw new PlanReadError(`cannot read plan ${source}: ${err instanceof Error ? err.message : String(err)}`);
102
104
  }
103
105
  const parsed = PlanSchema.safeParse(raw);
104
106
  if (!parsed.success)
105
- throw new PlanReadError(`bad plan ${path}: ${parsed.error.message}`);
107
+ throw new PlanReadError(`bad plan ${source}: ${parsed.error.message}`);
106
108
  return parsed.data;
107
109
  }
110
+ /** @throws PlanReadError */
111
+ export function readPlan(path) {
112
+ let text;
113
+ try {
114
+ text = readFileSync(path, 'utf-8');
115
+ }
116
+ catch (err) {
117
+ throw new PlanReadError(`cannot read plan ${path}: ${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ return parsePlan(text, path);
120
+ }
package/dist/receipt.d.ts CHANGED
@@ -20,6 +20,7 @@ export declare const DroppedSchema: z.ZodObject<{
20
20
  reason: z.ZodEnum<{
21
21
  gone: "gone";
22
22
  "not-deletable": "not-deletable";
23
+ "needs-review": "needs-review";
23
24
  "facts-changed": "facts-changed";
24
25
  "not-authorized": "not-authorized";
25
26
  }>;
@@ -77,6 +78,7 @@ export declare const ReceiptSchema: z.ZodObject<{
77
78
  reason: z.ZodEnum<{
78
79
  gone: "gone";
79
80
  "not-deletable": "not-deletable";
81
+ "needs-review": "needs-review";
80
82
  "facts-changed": "facts-changed";
81
83
  "not-authorized": "not-authorized";
82
84
  }>;
package/dist/receipt.js CHANGED
@@ -4,7 +4,7 @@ import { PLAN_CATEGORIES } from "./plan.js";
4
4
  export const RowRefSchema = z.object({ category: z.enum(PLAN_CATEGORIES), id: z.string() });
5
5
  export const DroppedSchema = RowRefSchema.extend({
6
6
  repo: z.string(),
7
- reason: z.enum(['gone', 'not-deletable', 'facts-changed', 'not-authorized']),
7
+ reason: z.enum(['gone', 'not-deletable', 'needs-review', 'facts-changed', 'not-authorized']),
8
8
  detail: z.string(),
9
9
  });
10
10
  export const StepSchema = z.object({
package/dist/render.d.ts CHANGED
@@ -28,6 +28,15 @@ export declare function renderRepo(sweep: RepoSweep, opts?: {
28
28
  export declare function renderScan(result: SweepResult): string | null;
29
29
  /** The one-line verdict a person reads first, and often only. */
30
30
  export declare function renderSummary(result: SweepResult): string;
31
+ /**
32
+ * One repository as counts: a row per category with its total, how many are
33
+ * merged, and how many the sweep would delete, review or keep.
34
+ */
35
+ export declare function renderCounts(sweep: RepoSweep): string;
36
+ /** The whole run as counts: a table per repository, then the one-line summary. */
37
+ export declare function renderCountsText(result: SweepResult): string;
38
+ /** The whole run's counts as JSON: no rows, no facts. */
39
+ export declare function renderCountsJson(result: SweepResult): string;
31
40
  /** The whole run as text: duplicate checkouts, a section per repository, then the summary. */
32
41
  export declare function renderText(result: SweepResult, opts?: {
33
42
  keep?: boolean;
package/dist/render.js CHANGED
@@ -84,6 +84,7 @@ function totals(result) {
84
84
  for (const sweep of result.repos) {
85
85
  for (const bucket of Object.values(summarize(sweep.items))) {
86
86
  out.total += bucket.total;
87
+ out.merged += bucket.merged;
87
88
  out['safe-to-delete'] += bucket['safe-to-delete'];
88
89
  out['safe-to-keep'] += bucket['safe-to-keep'];
89
90
  out['needs-investigation'] += bucket['needs-investigation'];
@@ -129,6 +130,68 @@ export function renderSummary(result) {
129
130
  const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
130
131
  return `${counts['safe-to-delete']} to delete, ${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
131
132
  }
133
+ const COUNT_LABEL = {
134
+ local_branch: 'local branches',
135
+ worktree: 'worktrees',
136
+ remote_branch: 'remote branches',
137
+ };
138
+ const COUNT_COLUMNS = [
139
+ 'total',
140
+ 'merged',
141
+ 'safe-to-delete',
142
+ 'needs-investigation',
143
+ 'safe-to-keep',
144
+ ];
145
+ const COUNT_HEADINGS = ['total', 'merged', 'delete', 'review', 'keep'];
146
+ /**
147
+ * One repository as counts: a row per category with its total, how many are
148
+ * merged, and how many the sweep would delete, review or keep.
149
+ */
150
+ export function renderCounts(sweep) {
151
+ const lines = [repoHeader(sweep)];
152
+ if (sweep.error !== null) {
153
+ lines.push(` error: ${sweep.error}`);
154
+ return lines.join('\n');
155
+ }
156
+ const buckets = summarize(sweep.items);
157
+ const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
158
+ const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
159
+ lines.push(` ${' '.repeat(labelWidth)} ${COUNT_HEADINGS.map((h) => cell(h, h)).join(' ')}`);
160
+ for (const category of CATEGORY_ORDER) {
161
+ const bucket = buckets[category] ?? emptyBucket();
162
+ const cells = COUNT_COLUMNS.map((col, i) => cell(count(bucket[col]), COUNT_HEADINGS[i]));
163
+ lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
164
+ }
165
+ return lines.join('\n');
166
+ }
167
+ /** The whole run as counts: a table per repository, then the one-line summary. */
168
+ export function renderCountsText(result) {
169
+ const blocks = result.repos.map(renderCounts);
170
+ if (result.unresolved.length > 0) {
171
+ const lines = ['unresolved'];
172
+ const width = Math.max(...result.unresolved.map((row) => row.entry.length));
173
+ for (const row of result.unresolved)
174
+ lines.push(` ${pad(row.entry, width)} ${row.reason}`);
175
+ blocks.push(lines.join('\n'));
176
+ }
177
+ const scan = renderScan(result);
178
+ blocks.push(scan === null ? renderSummary(result) : `${scan}\n${renderSummary(result)}`);
179
+ return blocks.join('\n\n') + '\n';
180
+ }
181
+ /** The whole run's counts as JSON: no rows, no facts. */
182
+ export function renderCountsJson(result) {
183
+ return (JSON.stringify({
184
+ repos: result.repos.map((sweep) => ({
185
+ path: sweep.path,
186
+ error: sweep.error,
187
+ repo: sweep.repo,
188
+ summary: summarize(sweep.items),
189
+ })),
190
+ unresolved: result.unresolved,
191
+ scan: result.scan,
192
+ summary: totals(result),
193
+ }, null, 2) + '\n');
194
+ }
132
195
  /** The "Duplicate checkouts" section, or null when there are none. */
133
196
  function renderDuplicates(duplicates) {
134
197
  if (duplicates.length === 0)
@@ -0,0 +1,24 @@
1
+ /**
2
+ * One self-overwriting line on stderr saying what a run is doing, for a
3
+ * terminal. The sweep is synchronous, so the line moves between steps and
4
+ * between the items of a long step, not on a timer.
5
+ */
6
+ import type { Step } from './receipt.ts';
7
+ import type { SweepProgress } from './types.ts';
8
+ /**
9
+ * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
10
+ * The path goes last, so a line cut to the terminal's width loses its tail first.
11
+ */
12
+ export declare function describeSweep(p: SweepProgress, home?: string): string;
13
+ /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
14
+ export declare function describeStep(index: number, total: number, step: Step): string;
15
+ export interface StatusLine {
16
+ /** Replace the line with `text`. */
17
+ update(text: string): void;
18
+ /** Erase the line, so other output starts on a clean row. Idempotent. */
19
+ clear(): void;
20
+ }
21
+ /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
22
+ export declare function statusLine(write: (text: string) => void, columns: () => number): StatusLine;
23
+ /** A status line that draws nothing. */
24
+ export declare const NO_STATUS_LINE: StatusLine;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One self-overwriting line on stderr saying what a run is doing, for a
3
+ * terminal. The sweep is synchronous, so the line moves between steps and
4
+ * between the items of a long step, not on a timer.
5
+ */
6
+ import { homedir } from 'node:os';
7
+ import { fitWidth, stripControls } from '@osdlc/ux';
8
+ const STEP_TEXT = {
9
+ discover: 'reading the repository',
10
+ fetch: 'fetching',
11
+ prs: 'listing pull requests (gh)',
12
+ worktrees: 'checking worktrees',
13
+ 'local-branches': 'checking local branches',
14
+ 'remote-branches': 'checking remote branches',
15
+ };
16
+ /** `~/x` for a path under the home directory. */
17
+ function tilde(path, home) {
18
+ return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
19
+ }
20
+ /**
21
+ * A sweep step as one line, e.g. `[2/5] checking remote branches 37/427 · ~/work/api`.
22
+ * The path goes last, so a line cut to the terminal's width loses its tail first.
23
+ */
24
+ export function describeSweep(p, home = homedir()) {
25
+ const where = p.of > 1 ? `[${p.index}/${p.of}] ` : '';
26
+ const count = p.done === undefined ? '' : ` ${p.done + 1}/${p.total}`;
27
+ return `${where}${STEP_TEXT[p.step]}${count} · ${tilde(p.repo, home)}`;
28
+ }
29
+ /** An apply step as one line, e.g. `deleting 3/7: delete-remote origin/feat/x`. */
30
+ export function describeStep(index, total, step) {
31
+ const what = step.row === null ? step.repo : step.row.id;
32
+ return `deleting ${index + 1}/${total}: ${step.action} ${what}`;
33
+ }
34
+ /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
35
+ export function statusLine(write, columns) {
36
+ let shown = false;
37
+ return {
38
+ update(text) {
39
+ // Paths come off the disk: a name holding an escape or a newline must not
40
+ // reach the terminal, and a wide character must not wrap the line.
41
+ const width = Math.max(10, columns() - 1);
42
+ write(`\r\x1b[2K${fitWidth(stripControls(text), width)}`);
43
+ shown = true;
44
+ },
45
+ clear() {
46
+ if (!shown)
47
+ return;
48
+ write('\r\x1b[2K');
49
+ shown = false;
50
+ },
51
+ };
52
+ }
53
+ /** A status line that draws nothing. */
54
+ export const NO_STATUS_LINE = { update() { }, clear() { } };
package/dist/sweep.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { CommandRunner } from '@osdlc/command-seam';
9
9
  import { type ProcessProbe } from './processes.ts';
10
- import { type RepoSweep, type Scope, type SweepResult } from './types.ts';
10
+ import { type RepoSweep, type Scope, type SweepProgress, type SweepResult } from './types.ts';
11
11
  export interface SweepOptions {
12
12
  /** Which categories to gather. Defaults to all of them. */
13
13
  scope?: Iterable<Scope | string>;
@@ -43,6 +43,13 @@ export interface SweepOptions {
43
43
  * repository, over `ps` and `lsof`.
44
44
  */
45
45
  probe?: ProcessProbe | undefined;
46
+ /** Told before each step, and before each item of a step that walks many. For a status line. */
47
+ onProgress?: ((progress: SweepProgress) => void) | undefined;
48
+ /** Where this repository sits in a run of several, for {@link onProgress}. Defaults to 1 of 1. */
49
+ position?: {
50
+ index: number;
51
+ of: number;
52
+ } | undefined;
46
53
  }
47
54
  /**
48
55
  * Inventory one repository.
package/dist/sweep.js CHANGED
@@ -34,6 +34,15 @@ export function sweepRepo(path, opts = {}) {
34
34
  const scope = scopeSet(opts.scope);
35
35
  const { git, gh } = clients(path, opts);
36
36
  const only = opts.branch;
37
+ const at = opts.position ?? { index: 1, of: 1 };
38
+ const report = (step, done, total) => opts.onProgress?.({
39
+ repo: path,
40
+ ...at,
41
+ step,
42
+ ...(done === undefined || total === undefined ? {} : { done, total }),
43
+ });
44
+ const ticker = (step) => (done, total) => report(step, done, total);
45
+ report('discover');
37
46
  let repo;
38
47
  try {
39
48
  repo = discoverRepo(path, {
@@ -53,6 +62,7 @@ export function sweepRepo(path, opts = {}) {
53
62
  }
54
63
  const cwd = only === undefined ? (opts.cwd ?? process.cwd()) : repo.root;
55
64
  if (opts.fetch === true && repo.remote !== null) {
65
+ report('fetch');
56
66
  const fetched = git
57
67
  .at(repo.root)
58
68
  .try.fetch(repo.remote, undefined, { prune: true, quiet: true, noWriteFetchHead: true });
@@ -67,6 +77,8 @@ export function sweepRepo(path, opts = {}) {
67
77
  repo = { ...repo, fetched: true };
68
78
  }
69
79
  try {
80
+ if (repo.hasGh && repo.ghAuthenticated)
81
+ report('prs');
70
82
  const prs = fetchPrs(repo, gh, only);
71
83
  const items = [];
72
84
  // Worktrees are gathered whenever local branches are, even if they are not
@@ -75,7 +87,7 @@ export function sweepRepo(path, opts = {}) {
75
87
  let worktreeBranches = new Set();
76
88
  if (scope.has('worktrees') || scope.has('local-branches')) {
77
89
  ;
78
- [worktreeItems, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, only, opts.probe ?? defaultProcessProbe());
90
+ [worktreeItems, worktreeBranches] = gatherWorktrees(repo, prs, cwd, git, only, opts.probe ?? defaultProcessProbe(), ticker('worktrees'));
79
91
  }
80
92
  if (scope.has('worktrees'))
81
93
  items.push(...worktreeItems);
@@ -86,13 +98,13 @@ export function sweepRepo(path, opts = {}) {
86
98
  : new Set(worktreeItems
87
99
  .filter((item) => !item.deletable)
88
100
  .map((item) => String(item.facts['branch'])));
89
- const localItems = gatherLocalBranches(repo, prs, pinned, git, only);
101
+ const localItems = gatherLocalBranches(repo, prs, pinned, git, only, ticker('local-branches'));
90
102
  localBranchNames = new Set(localItems.map((item) => item.id));
91
103
  if (scope.has('local-branches'))
92
104
  items.push(...localItems);
93
105
  }
94
106
  if (scope.has('remote-branches')) {
95
- items.push(...gatherRemoteBranches(repo, prs, localBranchNames, git, only));
107
+ items.push(...gatherRemoteBranches(repo, prs, localBranchNames, git, only, ticker('remote-branches')));
96
108
  }
97
109
  return { repo, path, items, error: null };
98
110
  }
@@ -119,7 +131,8 @@ export function sweepRepos(entries, opts = {}) {
119
131
  ...(opts.baseDir === undefined ? {} : { baseDir: opts.baseDir }),
120
132
  ...(opts.onWarn === undefined ? {} : { onWarn: opts.onWarn }),
121
133
  });
122
- const repos = resolution.repos.map((root) => sweepRepo(root, opts));
134
+ const of = resolution.repos.length;
135
+ const repos = resolution.repos.map((root, i) => sweepRepo(root, { ...opts, position: { index: i + 1, of } }));
123
136
  return {
124
137
  repos,
125
138
  unresolved: resolution.unresolved,
package/dist/types.d.ts CHANGED
@@ -9,6 +9,19 @@
9
9
  export type Classification = 'safe-to-delete' | 'safe-to-keep' | 'needs-investigation';
10
10
  /** The kinds of thing a sweep inventories. */
11
11
  export type Category = 'local_branch' | 'worktree' | 'remote_branch';
12
+ /**
13
+ * What a sweep is doing, for a status line. `repo` is the path being swept;
14
+ * `index` and `of` place it among the run's repositories. `done` and `total`
15
+ * count items within a step that walks many.
16
+ */
17
+ export interface SweepProgress {
18
+ repo: string;
19
+ index: number;
20
+ of: number;
21
+ step: 'discover' | 'fetch' | 'prs' | 'worktrees' | 'local-branches' | 'remote-branches';
22
+ done?: number;
23
+ total?: number;
24
+ }
12
25
  /** Which categories to gather. */
13
26
  export declare const ALL_SCOPES: readonly ["local-branches", "worktrees", "remote-branches"];
14
27
  export type Scope = (typeof ALL_SCOPES)[number];
@@ -139,6 +152,8 @@ export declare function findDuplicateCheckouts(repos: readonly ({
139
152
  export declare function emptyScan(): ScanStats;
140
153
  export interface SummaryBucket {
141
154
  total: number;
155
+ /** Rows whose work is on the default branch (`facts.merged`). Rows kept for structure are not judged. */
156
+ merged: number;
142
157
  'safe-to-delete': number;
143
158
  'safe-to-keep': number;
144
159
  'needs-investigation': number;
package/dist/types.js CHANGED
@@ -48,7 +48,7 @@ export function emptyScan() {
48
48
  return { containers: 0, directoriesScanned: 0, depthLimited: 0, checkoutsFound: 0 };
49
49
  }
50
50
  export function emptyBucket() {
51
- return { total: 0, 'safe-to-delete': 0, 'safe-to-keep': 0, 'needs-investigation': 0 };
51
+ return { total: 0, merged: 0, 'safe-to-delete': 0, 'safe-to-keep': 0, 'needs-investigation': 0 };
52
52
  }
53
53
  /** Per-category counts over `items`. */
54
54
  export function summarize(items) {
@@ -56,6 +56,8 @@ export function summarize(items) {
56
56
  for (const item of items) {
57
57
  const bucket = (out[item.category] ??= emptyBucket());
58
58
  bucket.total += 1;
59
+ if (item.facts['merged'] === true)
60
+ bucket.merged += 1;
59
61
  bucket[item.classification] += 1;
60
62
  }
61
63
  return out;
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.9.2";
2
+ export declare const VERSION = "0.10.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.9.2';
3
+ export const VERSION = '0.10.0';
@@ -26,7 +26,7 @@ export declare function hasBlockers(item: Pick<SweepItem, 'facts'>): boolean;
26
26
  * The branch set is returned because a local branch checked out in a worktree
27
27
  * must not be judged on its own: the worktree is what a person would act on.
28
28
  */
29
- export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only: string | undefined, probe: ProcessProbe): [SweepItem[], Set<string>];
29
+ export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only: string | undefined, probe: ProcessProbe, tick?: (done: number, total: number) => void): [SweepItem[], Set<string>];
30
30
  export declare function classifyWorktree(opts: {
31
31
  path: string;
32
32
  branch: string;
package/dist/worktrees.js CHANGED
@@ -141,7 +141,7 @@ function readBlockers(opts) {
141
141
  * The branch set is returned because a local branch checked out in a worktree
142
142
  * must not be judged on its own: the worktree is what a person would act on.
143
143
  */
144
- export function gatherWorktrees(repo, prs, cwd, git, only, probe) {
144
+ export function gatherWorktrees(repo, prs, cwd, git, only, probe, tick) {
145
145
  const all = git.at(repo.root).worktrees();
146
146
  const pathsByBranch = new Map();
147
147
  for (const entry of all) {
@@ -151,7 +151,8 @@ export function gatherWorktrees(repo, prs, cwd, git, only, probe) {
151
151
  }
152
152
  const entries = only === undefined ? all : all.filter((entry) => entry.branch === only);
153
153
  const items = [];
154
- for (const entry of entries) {
154
+ for (const [i, entry] of entries.entries()) {
155
+ tick?.(i, entries.length);
155
156
  const branch = entry.branch ?? '';
156
157
  items.push(classifyWorktree({
157
158
  path: entry.path,
@@ -202,8 +203,13 @@ export function classifyWorktree(opts) {
202
203
  if (nodeGuard.contains(path, absCwd)) {
203
204
  return pinned("current session's worktree");
204
205
  }
205
- const onDisk = isDir(path);
206
+ // A directory whose `.git` is gone is no longer a checkout: git lists it as
207
+ // prunable and `git status` inside it fails. Treat it as gone; the prune
208
+ // that drops its registration leaves the directory alone.
209
+ const dirPresent = isDir(path);
210
+ const onDisk = dirPresent && existsSync(join(path, '.git'));
206
211
  facts['on_disk'] = onDisk;
212
+ facts['directory_left'] = dirPresent && !onDisk;
207
213
  let dirtyFiles = [];
208
214
  let unpushed = [];
209
215
  if (onDisk) {
@@ -244,6 +250,12 @@ export function classifyWorktree(opts) {
244
250
  recordLanding(facts, patch);
245
251
  const patchLanded = patch !== null && patch.check !== null;
246
252
  const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
253
+ if (onDisk && head !== null) {
254
+ // Merged: HEAD's work is on the default branch. `soft`: a missing default
255
+ // ref reads as not merged.
256
+ facts['merged'] =
257
+ landed || patchLanded || git.at(repo.root).soft.isAncestor(head, landedRef(repo));
258
+ }
247
259
  const requires = authorizationsFor({
248
260
  'abandon-unmerged': unlanded,
249
261
  'discard-changes': dirtyFiles.length > 0,
@@ -280,7 +292,9 @@ export function classifyWorktree(opts) {
280
292
  id: path,
281
293
  classification: 'safe-to-delete',
282
294
  action: 'remove',
283
- reason: 'worktree directory no longer on disk; prune the stale link',
295
+ reason: dirPresent
296
+ ? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
297
+ : 'worktree directory no longer on disk; prune the stale link',
284
298
  facts,
285
299
  commands: pruneOnly,
286
300
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "What has been merged into the default branch, and what can go away. Inventories local branches, worktrees and remote branches across one repository or many, classifying each with the evidence behind the verdict and the command that would act on it. Plans and applies approved deletes, compare-and-swap.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "sksizer",
@@ -51,10 +51,10 @@
51
51
  "zod": "4.4.3",
52
52
  "@osdlc/cli-tool": "^0.12.0",
53
53
  "@osdlc/command-seam": "^0.14.0",
54
- "@osdlc/config-layers": "^0.8.0",
55
54
  "@osdlc/path-guard": "^0.3.1",
56
- "@osdlc/typed-gh": "^0.14.0",
57
- "@osdlc/typed-git": "^0.11.1"
55
+ "@osdlc/typed-gh": "^0.14.1",
56
+ "@osdlc/typed-git": "^0.12.0",
57
+ "@osdlc/ux": "^0.1.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^26.1.0",