@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/CHANGELOG.md +38 -0
- package/README.md +75 -62
- package/dist/apply.d.ts +24 -7
- package/dist/apply.js +44 -13
- package/dist/branches.d.ts +2 -2
- package/dist/branches.js +24 -8
- package/dist/cli.d.ts +26 -7
- package/dist/cli.js +423 -183
- package/dist/devlink.d.ts +5 -0
- package/dist/devlink.js +3 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/plan.d.ts +7 -3
- package/dist/plan.js +24 -11
- package/dist/receipt.d.ts +2 -0
- package/dist/receipt.js +1 -1
- package/dist/render.d.ts +9 -0
- package/dist/render.js +63 -0
- package/dist/status-line.d.ts +24 -0
- package/dist/status-line.js +54 -0
- package/dist/sweep.d.ts +8 -1
- package/dist/sweep.js +17 -4
- package/dist/types.d.ts +15 -0
- package/dist/types.js +3 -1
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +1 -1
- package/dist/worktrees.js +18 -4
- package/package.json +4 -4
- package/dist/config.d.ts +0 -53
- package/dist/config.js +0 -67
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
|
-
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
95
|
-
|
|
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(
|
|
100
|
+
raw = JSON.parse(text);
|
|
99
101
|
}
|
|
100
102
|
catch (err) {
|
|
101
|
-
throw new PlanReadError(`cannot read plan ${
|
|
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 ${
|
|
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
|
|
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.
|
|
2
|
+
export declare const VERSION = "0.10.0";
|
package/dist/worktrees.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
57
|
-
"@osdlc/typed-git": "^0.
|
|
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",
|