@osdlc/repo-cleaner 0.9.2 → 0.10.1
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 +55 -0
- package/README.md +89 -62
- package/dist/apply.d.ts +45 -7
- package/dist/apply.js +118 -14
- package/dist/branches.d.ts +12 -2
- package/dist/branches.js +34 -8
- package/dist/cli.d.ts +26 -7
- package/dist/cli.js +472 -184
- package/dist/devlink.d.ts +5 -0
- package/dist/devlink.js +3 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.js +3 -4
- package/dist/plan.d.ts +7 -3
- package/dist/plan.js +24 -11
- package/dist/receipt.d.ts +6 -0
- package/dist/receipt.js +11 -2
- package/dist/render.d.ts +33 -1
- package/dist/render.js +134 -1
- 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 +21 -5
- package/package.json +7 -7
- 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,
|
|
24
|
-
export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
|
|
23
|
+
export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
|
|
24
|
+
export { RECEIPT_VERSION, 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
|
-
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';
|
|
29
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.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,
|
|
23
|
-
export { ReceiptSchema } from "./receipt.js";
|
|
22
|
+
export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
|
|
23
|
+
export { RECEIPT_VERSION, 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
|
-
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";
|
|
28
|
+
export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
/** The receipt format's version, written into every receipt. */
|
|
4
|
+
export declare const RECEIPT_VERSION = 1;
|
|
3
5
|
export declare const RowRefSchema: z.ZodObject<{
|
|
4
6
|
category: z.ZodEnum<{
|
|
5
7
|
local_branch: "local_branch";
|
|
@@ -20,8 +22,10 @@ export declare const DroppedSchema: z.ZodObject<{
|
|
|
20
22
|
reason: z.ZodEnum<{
|
|
21
23
|
gone: "gone";
|
|
22
24
|
"not-deletable": "not-deletable";
|
|
25
|
+
"needs-review": "needs-review";
|
|
23
26
|
"facts-changed": "facts-changed";
|
|
24
27
|
"not-authorized": "not-authorized";
|
|
28
|
+
duplicate: "duplicate";
|
|
25
29
|
}>;
|
|
26
30
|
detail: z.ZodString;
|
|
27
31
|
}, z.core.$strip>;
|
|
@@ -77,8 +81,10 @@ export declare const ReceiptSchema: z.ZodObject<{
|
|
|
77
81
|
reason: z.ZodEnum<{
|
|
78
82
|
gone: "gone";
|
|
79
83
|
"not-deletable": "not-deletable";
|
|
84
|
+
"needs-review": "needs-review";
|
|
80
85
|
"facts-changed": "facts-changed";
|
|
81
86
|
"not-authorized": "not-authorized";
|
|
87
|
+
duplicate: "duplicate";
|
|
82
88
|
}>;
|
|
83
89
|
detail: z.ZodString;
|
|
84
90
|
}, z.core.$strip>>;
|
package/dist/receipt.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/** What `applyPlan` did, step by step, rewritten after every step. */
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { PLAN_CATEGORIES } from "./plan.js";
|
|
4
|
+
/** The receipt format's version, written into every receipt. */
|
|
5
|
+
export const RECEIPT_VERSION = 1;
|
|
4
6
|
export const RowRefSchema = z.object({ category: z.enum(PLAN_CATEGORIES), id: z.string() });
|
|
5
7
|
export const DroppedSchema = RowRefSchema.extend({
|
|
6
8
|
repo: z.string(),
|
|
7
|
-
reason: z.enum([
|
|
9
|
+
reason: z.enum([
|
|
10
|
+
'gone',
|
|
11
|
+
'not-deletable',
|
|
12
|
+
'needs-review',
|
|
13
|
+
'facts-changed',
|
|
14
|
+
'not-authorized',
|
|
15
|
+
'duplicate',
|
|
16
|
+
]),
|
|
8
17
|
detail: z.string(),
|
|
9
18
|
});
|
|
10
19
|
export const StepSchema = z.object({
|
|
@@ -25,7 +34,7 @@ export const StepSchema = z.object({
|
|
|
25
34
|
verification: z.string().nullable(),
|
|
26
35
|
});
|
|
27
36
|
export const ReceiptSchema = z.object({
|
|
28
|
-
version: z.literal(
|
|
37
|
+
version: z.literal(RECEIPT_VERSION),
|
|
29
38
|
plan: z.string(),
|
|
30
39
|
status: z.enum(['dry-run', 'applying', 'complete', 'failed']),
|
|
31
40
|
started_at: z.string(),
|
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;
|
|
@@ -39,5 +48,28 @@ export declare function renderText(result: SweepResult, opts?: {
|
|
|
39
48
|
export declare function renderJson(result: SweepResult, extra?: Record<string, unknown>): string;
|
|
40
49
|
/** Every command a `safe-to-delete` row that needs no authorization proposes, in report order. */
|
|
41
50
|
export declare function proposedCommands(result: SweepResult): string[];
|
|
42
|
-
/**
|
|
51
|
+
/** What an apply run did, or would do, counted by kind. */
|
|
52
|
+
export interface ApplySummary {
|
|
53
|
+
/** Rows deleted (or, in a dry run, that would be), by category. Pruned worktrees are not counted here. */
|
|
54
|
+
deleted: {
|
|
55
|
+
worktree: number;
|
|
56
|
+
local_branch: number;
|
|
57
|
+
remote_branch: number;
|
|
58
|
+
};
|
|
59
|
+
/** Worktrees whose stale registration was pruned; their directories were gone or left alone. */
|
|
60
|
+
pruned: number;
|
|
61
|
+
/** Dropped rows by reason. */
|
|
62
|
+
dropped: Record<string, number>;
|
|
63
|
+
/** Steps that failed: 0 or 1, since the first failure stops the run. */
|
|
64
|
+
failed: number;
|
|
65
|
+
/** Row steps never reached because a step failed. */
|
|
66
|
+
notRun: number;
|
|
67
|
+
}
|
|
68
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
69
|
+
export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
|
|
70
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
71
|
+
export declare function rowsActedOn(summary: ApplySummary): number;
|
|
72
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
73
|
+
export declare function renderApplySummary(receipt: Receipt): string[];
|
|
74
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
43
75
|
export declare function renderReceipt(receipt: Receipt, receiptPath: string | null): string;
|
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)
|
|
@@ -201,7 +264,74 @@ export function proposedCommands(result) {
|
|
|
201
264
|
}
|
|
202
265
|
return out;
|
|
203
266
|
}
|
|
204
|
-
/**
|
|
267
|
+
/** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
|
|
268
|
+
export function summarizeReceipt(receipt) {
|
|
269
|
+
const out = {
|
|
270
|
+
deleted: { worktree: 0, local_branch: 0, remote_branch: 0 },
|
|
271
|
+
pruned: 0,
|
|
272
|
+
dropped: {},
|
|
273
|
+
failed: 0,
|
|
274
|
+
notRun: 0,
|
|
275
|
+
};
|
|
276
|
+
for (const d of receipt.dropped)
|
|
277
|
+
out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
|
|
278
|
+
for (const s of receipt.steps) {
|
|
279
|
+
if (s.result === 'failed')
|
|
280
|
+
out.failed += 1;
|
|
281
|
+
if (s.row === null)
|
|
282
|
+
continue;
|
|
283
|
+
if (s.result === 'pending') {
|
|
284
|
+
if (s.action !== 'unlock-worktree')
|
|
285
|
+
out.notRun += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (s.result !== 'ok' && s.result !== 'would-run')
|
|
289
|
+
continue;
|
|
290
|
+
if (s.action === 'remove-worktree')
|
|
291
|
+
out.deleted.worktree += 1;
|
|
292
|
+
else if (s.action === 'prune-worktrees')
|
|
293
|
+
out.pruned += 1;
|
|
294
|
+
else if (s.action === 'delete-local')
|
|
295
|
+
out.deleted.local_branch += 1;
|
|
296
|
+
else if (s.action === 'delete-remote')
|
|
297
|
+
out.deleted.remote_branch += 1;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
|
|
302
|
+
export function rowsActedOn(summary) {
|
|
303
|
+
const d = summary.deleted;
|
|
304
|
+
return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
|
|
305
|
+
}
|
|
306
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
307
|
+
/** The closing lines of an apply run: what it deleted, dropped and could not finish. */
|
|
308
|
+
export function renderApplySummary(receipt) {
|
|
309
|
+
const s = summarizeReceipt(receipt);
|
|
310
|
+
const dry = receipt.status === 'dry-run';
|
|
311
|
+
const parts = [
|
|
312
|
+
s.deleted.worktree > 0 ? plural(s.deleted.worktree, 'worktree', 'worktrees') : null,
|
|
313
|
+
s.deleted.local_branch > 0
|
|
314
|
+
? plural(s.deleted.local_branch, 'local branch', 'local branches')
|
|
315
|
+
: null,
|
|
316
|
+
s.deleted.remote_branch > 0
|
|
317
|
+
? plural(s.deleted.remote_branch, 'remote branch', 'remote branches')
|
|
318
|
+
: null,
|
|
319
|
+
s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
|
|
320
|
+
].filter((p) => p !== null);
|
|
321
|
+
const lines = [
|
|
322
|
+
`${dry ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
|
|
323
|
+
];
|
|
324
|
+
const dropped = Object.entries(s.dropped);
|
|
325
|
+
if (dropped.length > 0) {
|
|
326
|
+
const total = dropped.reduce((n, [, c]) => n + c, 0);
|
|
327
|
+
lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
|
|
328
|
+
}
|
|
329
|
+
if (s.failed > 0) {
|
|
330
|
+
lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
/** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
|
|
205
335
|
export function renderReceipt(receipt, receiptPath) {
|
|
206
336
|
const lines = [];
|
|
207
337
|
for (const d of receipt.dropped) {
|
|
@@ -212,6 +342,9 @@ export function renderReceipt(receipt, receiptPath) {
|
|
|
212
342
|
const tail = s.error ?? s.verification;
|
|
213
343
|
lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
|
|
214
344
|
}
|
|
345
|
+
if (lines.length > 0)
|
|
346
|
+
lines.push('');
|
|
347
|
+
lines.push(...renderApplySummary(receipt));
|
|
215
348
|
lines.push(receiptPath === null
|
|
216
349
|
? `status: ${receipt.status}`
|
|
217
350
|
: `receipt: ${receiptPath} (${receipt.status})`);
|
|
@@ -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;
|