@osdlc/repo-cleaner 0.10.0 → 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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - cde11b5: `apply` fixes:
8
+
9
+ - The run a confirmation approves keeps the safe-to-delete guard: a row that turned into a review row between the preview and the yes is dropped as `needs-review`, not run.
10
+ - Two clones of one remote no longer delete its branches twice. The second row is dropped as the new reason `duplicate`, naming the clone that deletes it; before, the second push was rejected and the run stopped with exit 11. Remotes are compared by the push URLs git uses (`url.*.insteadOf` applied), a network URL in any form (`git@host:o/r`, `ssh://`, `https://`) as `host/path`, and a local path as written.
11
+ - A swept `apply` re-checks once before asking, from the sweep it just ran, instead of sweeping again; the run that deletes still sweeps afresh.
12
+ - A run that deletes without a receipt file says so on stderr.
13
+ - Every run ends with a summary: `deleted: 1 worktree, 3 local branches, 2 remote branches`, the dropped rows by reason, and how many rows a failed run did not reach. A preview says `would delete:`. `--output json` adds a `summary` object.
14
+ - The prompt's `Delete N items?` counts rows, not steps: a stale-locked worktree is one item, not two.
15
+ - `--repo-root` naming no repository in the plan exits 1 instead of reporting nothing to delete.
16
+ - An empty plan's JSON receipt says `complete`, not `dry-run`, unless the run was `--dry-run`.
17
+
18
+ Library: `ApplyOptions.safeOnly` and `ApplyOptions.swept`, the `duplicate` drop reason, `RECEIPT_VERSION`, and `summarizeReceipt`, `renderApplySummary` and `ApplySummary`.
19
+
3
20
  ## 0.10.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -312,6 +312,20 @@ On yes it runs only the steps it showed: worktrees first, then local branches,
312
312
  remote branches and a prune, in that order. Branch deletes are
313
313
  compare-and-swap, every step is verified, and the first failure stops the run.
314
314
 
315
+ Every run ends with what it did:
316
+
317
+ ```text
318
+ deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
319
+ dropped 2: 1 needs-review, 1 duplicate
320
+ status: complete
321
+ ```
322
+
323
+ The preview says `would delete:`, and a run that stops at a failed step says
324
+ how many rows it did not reach. `--output json` carries the same counts as
325
+ `summary`. Two clones of one remote list the same remote branches; `apply`
326
+ deletes each once and drops the other clone's row as `duplicate`. Remotes are
327
+ compared by the push URL git uses, with `url.*.insteadOf` applied.
328
+
315
329
  A receipt records every step and is rewritten after each one. With
316
330
  `--plan plan.json` it goes beside the plan, as
317
331
  `plan.receipt-<timestamp>.json`. `--receipt <file>` puts it anywhere. A run
package/dist/apply.d.ts CHANGED
@@ -18,8 +18,8 @@ import type { CommandRunner } from '@osdlc/command-seam';
18
18
  import type { HostHooks } from './hooks.ts';
19
19
  import { type ProcessProbe } from './processes.ts';
20
20
  import { type Plan } from './plan.ts';
21
- import type { Dropped, Receipt, RowRef, Step } from './receipt.ts';
22
- import { type Authorization, type SweepProgress } from './types.ts';
21
+ import { type Dropped, type Receipt, type RowRef, type Step } from './receipt.ts';
22
+ import { type Authorization, type SweepItem, type SweepProgress } from './types.ts';
23
23
  /** An authorization the caller gave, for one row or (`row: null`) for all. */
24
24
  export interface Grant {
25
25
  name: Authorization;
@@ -45,6 +45,20 @@ export interface ApplyOptions extends HostHooks {
45
45
  /** Limit the run to the plan repository rooted here. */
46
46
  repoRoot?: string | undefined;
47
47
  now?: () => Date;
48
+ /**
49
+ * Drop each selected row the re-check does not judge `safe-to-delete`.
50
+ * Defaults to true when no rows are requested, so a row to review runs only
51
+ * when it is named. Pass it to keep that guard on a run whose rows came
52
+ * from such a selection, such as the run a preview confirmed.
53
+ */
54
+ safeOnly?: boolean | undefined;
55
+ /**
56
+ * Items to re-check against instead of sweeping again, keyed by repository
57
+ * root, already annotated: the hook is not applied to them again. For a
58
+ * preview straight after a sweep; the run that deletes should always sweep
59
+ * again.
60
+ */
61
+ swept?: ReadonlyMap<string, readonly SweepItem[]> | undefined;
48
62
  /** Rows dropped before this run, such as by a preview; recorded first in the receipt. */
49
63
  dropped?: readonly Dropped[] | undefined;
50
64
  /** Passed to the re-sweep's `onProgress`, for a status line. */
@@ -66,6 +80,13 @@ export declare function parseGrant(text: string): Grant;
66
80
  export type RowSelection = RowRef & {
67
81
  repo?: string;
68
82
  };
83
+ /**
84
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
85
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
86
+ * nested groups stay apart. Null for a path on this machine, including
87
+ * `file://` and a Windows drive path.
88
+ */
89
+ export declare function networkRemoteKey(url: string): string | null;
69
90
  /**
70
91
  * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
71
92
  * selects every row of the plan (of `repoRoot`'s repository, when given) and
package/dist/apply.js CHANGED
@@ -20,6 +20,7 @@ import { CommandFailed, Git, PUSH_REJECTIONS } from '@osdlc/typed-git';
20
20
  import { localDeleteCommand, remoteDeleteCommand } from "./branches.js";
21
21
  import { busyHolders, defaultProcessProbe, parseLockOwner } from "./processes.js";
22
22
  import { PLAN_CATEGORIES, fingerprint, isPlanCategory, sameFingerprint, } from "./plan.js";
23
+ import { RECEIPT_VERSION } from "./receipt.js";
23
24
  import { sweepRepo } from "./sweep.js";
24
25
  import { AUTHORIZATIONS } from "./types.js";
25
26
  import { worktreePruneCommand, worktreeRemoveCommand, worktreeUnlockCommand } from "./worktrees.js";
@@ -78,6 +79,10 @@ function granted(grants, ref, name) {
78
79
  }
79
80
  /** The plan repository's rows as they stand now, with the host's overlay applied. */
80
81
  function resweep(repo, opts) {
82
+ const given = opts.swept?.get(repo.root);
83
+ if (given !== undefined) {
84
+ return [...given];
85
+ }
81
86
  const sweep = sweepRepo(repo.root, {
82
87
  remote: repo.remote,
83
88
  noGh: repo.no_gh,
@@ -257,6 +262,73 @@ function execute(step, row, git, probe) {
257
262
  }
258
263
  }
259
264
  }
265
+ /**
266
+ * A network URL as `host[:port]/path`, the same for `git@host:o/r.git`,
267
+ * `ssh://git@host/o/r` and `https://host/o/r/`; the whole path is kept, so
268
+ * nested groups stay apart. Null for a path on this machine, including
269
+ * `file://` and a Windows drive path.
270
+ */
271
+ export function networkRemoteKey(url) {
272
+ const u = url.trim();
273
+ if (/^[a-z]:[\\/]/i.test(u) || /^file:/i.test(u))
274
+ return null;
275
+ const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::(\d+))?(\/.*)?$/i.exec(u);
276
+ const scp = uri === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(u) : null;
277
+ const host = uri?.[1] ?? scp?.[1];
278
+ if (host === undefined)
279
+ return null;
280
+ const port = uri?.[2] === undefined ? '' : `:${uri[2]}`;
281
+ const path = (uri?.[3] ?? scp?.[2] ?? '').replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
282
+ return `${host.toLowerCase()}${port}/${path}`;
283
+ }
284
+ /**
285
+ * One string per remote repository, so two clones that push to the same one
286
+ * agree. Built from the push URLs git would use (`remote get-url --push
287
+ * --all`, which applies `url.*.insteadOf` and `pushInsteadOf`): a network URL
288
+ * by {@link networkRemoteKey}, a local path resolved against the repository
289
+ * and kept as written, `.git` and all. Null when git reports no URL; such a
290
+ * row is never treated as a duplicate.
291
+ */
292
+ function remoteKey(git, repo, remote) {
293
+ const urls = git.at(repo).try.remoteGetUrls(remote, { push: true });
294
+ if (!urls.ok || urls.value === null || urls.value.length === 0)
295
+ return null;
296
+ const keys = urls.value.map((url) => networkRemoteKey(url) ?? resolve(repo, url.trim().replace(/^file:\/\/(localhost)?/i, '')));
297
+ return [...new Set(keys)].sort().join('\n');
298
+ }
299
+ /**
300
+ * Keep the first row for each remote branch. Two clones of one remote each
301
+ * list its branches; deleting one twice fails the second push and stops the
302
+ * run, so the later row is dropped as `duplicate`.
303
+ */
304
+ function onePerRemoteBranch({ accepted, dropped }, git) {
305
+ const seen = new Map();
306
+ const kept = [];
307
+ const extra = [];
308
+ for (const row of accepted) {
309
+ if (row.item.category !== 'remote_branch') {
310
+ kept.push(row);
311
+ continue;
312
+ }
313
+ const remote = remoteKey(git, row.repo, String(row.item.facts['remote']));
314
+ const key = remote === null ? null : `${remote}\0${String(row.item.facts['branch'])}`;
315
+ const first = key === null ? undefined : seen.get(key);
316
+ if (first !== undefined) {
317
+ extra.push({
318
+ repo: row.repo,
319
+ category: row.item.category,
320
+ id: row.item.id,
321
+ reason: 'duplicate',
322
+ detail: `the same remote branch is deleted from ${first}`,
323
+ });
324
+ continue;
325
+ }
326
+ if (key !== null)
327
+ seen.set(key, row.repo);
328
+ kept.push(row);
329
+ }
330
+ return { accepted: kept, dropped: [...dropped, ...extra] };
331
+ }
260
332
  /**
261
333
  * Apply the `requested` rows of `plan` under `grants`. An empty `requested`
262
334
  * selects every row of the plan (of `repoRoot`'s repository, when given) and
@@ -286,7 +358,7 @@ export function applyPlan(plan, requested, grants, opts) {
286
358
  }
287
359
  }
288
360
  const receipt = {
289
- version: 1,
361
+ version: RECEIPT_VERSION,
290
362
  plan: opts.planPath,
291
363
  status: opts.dryRun ? 'dry-run' : 'applying',
292
364
  started_at: now().toISOString(),
@@ -294,7 +366,8 @@ export function applyPlan(plan, requested, grants, opts) {
294
366
  dropped: [],
295
367
  steps: [],
296
368
  };
297
- const { accepted, dropped } = recheck(selected, grants, requested.length === 0, opts);
369
+ const rechecked = recheck(selected, grants, opts.safeOnly ?? requested.length === 0, opts);
370
+ const { accepted, dropped } = onePerRemoteBranch(rechecked, new Git(opts.callerCwd, { runner: opts.git }));
298
371
  receipt.dropped = [...(opts.dropped ?? []), ...dropped];
299
372
  const ordered = PLAN_CATEGORIES.flatMap((category) => accepted.filter((row) => row.item.category === category));
300
373
  const plannedSteps = ordered.flatMap((row) => stepsFor(row).map((step) => [step, row]));
@@ -27,6 +27,16 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
27
27
  };
28
28
  /** One sentence on why a merged PR did not count, for `investigationParts`. */
29
29
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
30
+ /**
31
+ * `facts.merged`: the work is on the default branch, by an exact-head merged
32
+ * PR, by ancestry or by a patch match. Every category records it this way, so
33
+ * `summary` counts one definition.
34
+ */
35
+ export declare function mergedFact(e: {
36
+ landed: boolean;
37
+ ancestor: boolean;
38
+ patchLanded: boolean;
39
+ }): boolean;
30
40
  /** `HEAD` as the branch checked out at `git`'s directory; any other name as given. */
31
41
  export declare function resolveBranch(value: string, git: Git): string;
32
42
  /**
package/dist/branches.js CHANGED
@@ -59,6 +59,14 @@ export function gapSentence(pr, gap, repo) {
59
59
  ? `PR #${pr.number} merged into \`${pr.baseRef}\`, not \`${repo.mainBranch}\`, so it is not landing proof (other-base).`
60
60
  : `PR #${pr.number} merged at head ${pr.headOid ?? '(unknown)'}, which is not this tip, so it is not landing proof (head-moved).`;
61
61
  }
62
+ /**
63
+ * `facts.merged`: the work is on the default branch, by an exact-head merged
64
+ * PR, by ancestry or by a patch match. Every category records it this way, so
65
+ * `summary` counts one definition.
66
+ */
67
+ export function mergedFact(e) {
68
+ return e.landed || e.ancestor || e.patchLanded;
69
+ }
62
70
  /** What deleting a branch needs: its unlanded commits, and its open PR. */
63
71
  function branchRequires(unmerged, pr) {
64
72
  return authorizationsFor({
@@ -152,7 +160,11 @@ export function classifyLocalBranch(opts) {
152
160
  // `soft`: a missing default ref reads as not merged.
153
161
  const pr = prs.get(name);
154
162
  const { landed } = recordPr(facts, pr, sha, repo);
155
- facts['merged'] = landed || git.at(repo.root).soft.isAncestor(sha, landedRef(repo));
163
+ facts['merged'] = mergedFact({
164
+ landed,
165
+ ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
166
+ patchLanded: false,
167
+ });
156
168
  return pinned(held);
157
169
  }
158
170
  // Work merged only into an unpushed local default has not landed. A missing
@@ -172,8 +184,7 @@ export function classifyLocalBranch(opts) {
172
184
  recordLanding(facts, patch);
173
185
  const patchLanded = patch !== null && patch.check !== null;
174
186
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
175
- // Merged: the work is on the default branch, by ancestry, an exact-head merged PR or a patch match.
176
- facts['merged'] = landed || isAncestor || patchLanded;
187
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
177
188
  if (!landed && !isAncestor) {
178
189
  facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
179
190
  }
@@ -297,8 +308,7 @@ export function classifyRemoteBranch(opts) {
297
308
  recordLanding(facts, patch);
298
309
  const patchLanded = patch !== null && patch.check !== null;
299
310
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
300
- // Merged: the work is on the default branch, by ancestry, an exact-head merged PR or a patch match.
301
- facts['merged'] = landed || isAncestor || patchLanded;
311
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
302
312
  if (!landed && !isAncestor) {
303
313
  // Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
304
314
  const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
package/dist/cli.js CHANGED
@@ -29,7 +29,8 @@ import { Git } from '@osdlc/typed-git';
29
29
  import { resolveBranch } from "./branches.js";
30
30
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
31
31
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
32
- import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, } from "./render.js";
32
+ import { RECEIPT_VERSION } from "./receipt.js";
33
+ import { proposedCommands, renderCountsJson, renderCountsText, renderJson, renderReceipt, renderSummary, renderText, rowsActedOn, summarizeReceipt, } from "./render.js";
33
34
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
34
35
  import { sweepRepos } from "./sweep.js";
35
36
  const EXIT_NOTHING_TO_SWEEP = 10;
@@ -160,7 +161,7 @@ const applyCli = () => defineCli({
160
161
  repoRoot: {
161
162
  kind: 'string',
162
163
  valueName: 'path',
163
- help: 'apply only the plan repository rooted here',
164
+ help: 'with a plan of several repositories: apply only the one rooted here',
164
165
  },
165
166
  receipt: {
166
167
  kind: 'string',
@@ -209,27 +210,23 @@ function failure(ctx, err) {
209
210
  return EXIT.error;
210
211
  }
211
212
  /** Validate the sweep flags and run the sweep, or answer with the exit code. */
212
- function sweep(name, v, paths, ctx, render, status) {
213
+ function sweep(name, v, paths, ctx, status) {
213
214
  const only = v.only?.filter((s) => s !== '');
214
215
  const unknown = (only ?? []).filter((s) => !(s in ONLY));
215
216
  if (unknown.length > 0) {
216
- return {
217
- ok: false,
218
- code: usageError(ctx, name, `unknown --only value(s): ${unknown.join(', ')}`),
219
- };
217
+ const message = `unknown --only value(s): ${unknown.join(', ')}`;
218
+ return { kind: 'exit', code: usageError(ctx, name, message) };
220
219
  }
221
220
  if (v.branch !== undefined && paths.length > 0) {
222
- return {
223
- ok: false,
224
- code: usageError(ctx, name, '--branch sweeps this repository only; drop the paths'),
225
- };
221
+ const message = '--branch sweeps this repository only; drop the paths';
222
+ return { kind: 'exit', code: usageError(ctx, name, message) };
226
223
  }
227
224
  let branch;
228
225
  try {
229
226
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
230
227
  }
231
228
  catch (err) {
232
- return { ok: false, code: failure(ctx, err) };
229
+ return { kind: 'exit', code: failure(ctx, err) };
233
230
  }
234
231
  const options = {
235
232
  baseDir: ctx.cwd,
@@ -250,16 +247,14 @@ function sweep(name, v, paths, ctx, render, status) {
250
247
  status.clear();
251
248
  if (branch !== undefined && result.repos.length > 1) {
252
249
  const err = new Error(`--branch sweeps one repository; ${result.repos.length} found under ${ctx.cwd}`);
253
- return { ok: false, code: failure(ctx, err) };
250
+ return { kind: 'exit', code: failure(ctx, err) };
254
251
  }
255
252
  const refused = branch === undefined ? null : (result.repos[0]?.error ?? null);
256
253
  if (refused !== null)
257
- return { ok: false, code: failure(ctx, new Error(refused)) };
258
- if (result.repos.length === 0) {
259
- ctx.io.stdout(render(result));
260
- return { ok: false, code: EXIT_NOTHING_TO_SWEEP };
261
- }
262
- return { ok: true, result, branch, values: v };
254
+ return { kind: 'exit', code: failure(ctx, new Error(refused)) };
255
+ if (result.repos.length === 0)
256
+ return { kind: 'nothing', result };
257
+ return { kind: 'ok', result, branch, values: v };
263
258
  }
264
259
  /**
265
260
  * Name each repository the sweep could not read, on stderr. True when there
@@ -292,9 +287,13 @@ function runStatus(argv, ctx, status) {
292
287
  return usageError(ctx, NAME, parsed.message);
293
288
  const v = parsed.values;
294
289
  const render = (r) => v.output === 'json' ? renderJson(r) : renderText(r, { keep: v.keep });
295
- const swept = sweep(NAME, v, parsed.positionals, ctx, render, status);
296
- if (!swept.ok)
290
+ const swept = sweep(NAME, v, parsed.positionals, ctx, status);
291
+ if (swept.kind === 'exit')
297
292
  return swept.code;
293
+ if (swept.kind === 'nothing') {
294
+ ctx.io.stdout(render(swept.result));
295
+ return EXIT_NOTHING_TO_SWEEP;
296
+ }
298
297
  if (v.commands) {
299
298
  const commands = proposedCommands(swept.result);
300
299
  if (commands.length > 0)
@@ -314,11 +313,11 @@ function runSummary(argv, ctx, status) {
314
313
  return usageError(ctx, name, parsed.message);
315
314
  const v = parsed.values;
316
315
  const render = (r) => v.output === 'json' ? renderCountsJson(r) : renderCountsText(r);
317
- const swept = sweep(name, v, parsed.positionals, ctx, render, status);
318
- if (!swept.ok)
316
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
317
+ if (swept.kind === 'exit')
319
318
  return swept.code;
320
319
  ctx.io.stdout(render(swept.result));
321
- return EXIT.ok;
320
+ return swept.kind === 'nothing' ? EXIT_NOTHING_TO_SWEEP : EXIT.ok;
322
321
  }
323
322
  function runPlan(argv, ctx, status) {
324
323
  const name = `${NAME} plan`;
@@ -328,12 +327,14 @@ function runPlan(argv, ctx, status) {
328
327
  if (parsed.status === 'error')
329
328
  return usageError(ctx, name, parsed.message);
330
329
  const v = parsed.values;
331
- const swept = sweep(name, v, parsed.positionals, ctx, (r) => {
332
- ctx.io.stderr(renderSummary(r) + '\n');
333
- return '';
334
- }, status);
335
- if (!swept.ok)
330
+ const swept = sweep(name, v, parsed.positionals, ctx, status);
331
+ if (swept.kind === 'exit')
336
332
  return swept.code;
333
+ // stdout carries only the plan, so the summary goes to stderr either way.
334
+ if (swept.kind === 'nothing') {
335
+ ctx.io.stderr(renderSummary(swept.result) + '\n');
336
+ return EXIT_NOTHING_TO_SWEEP;
337
+ }
337
338
  const plan = planOf(swept, new Date());
338
339
  const unreadable = reportUnreadable(ctx, name, swept.result);
339
340
  ctx.io.stderr(renderSummary(swept.result) + '\n');
@@ -352,8 +353,68 @@ function receiptBeside(planPath, now) {
352
353
  const stem = basename(planPath).replace(/\.json$/i, '');
353
354
  return join(dirname(planPath), `${stem}.receipt-${fileStamp(now)}.json`);
354
355
  }
356
+ /** Rows the run would delete or prune: one each, however many steps (an unlock, a remove) it takes. */
355
357
  function wouldRun(receipt) {
356
- return receipt.steps.filter((s) => s.row !== null).length;
358
+ return rowsActedOn(summarizeReceipt(receipt));
359
+ }
360
+ /** Sweep and plan in memory, or read `--plan`; or answer with the exit code. */
361
+ function loadPlan(name, v, paths, ctx, deps, status, now) {
362
+ const receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
363
+ if (v.plan === undefined) {
364
+ const swept = sweep(name, v, paths, ctx, status);
365
+ if (swept.kind === 'exit')
366
+ return swept.code;
367
+ if (swept.kind === 'nothing') {
368
+ ctx.io.stdout(v.output === 'json' ? renderJson(swept.result) : renderText(swept.result));
369
+ return EXIT_NOTHING_TO_SWEEP;
370
+ }
371
+ const items = new Map();
372
+ for (const r of swept.result.repos)
373
+ if (r.repo !== null)
374
+ items.set(r.repo.root, r.items);
375
+ return {
376
+ plan: planOf(swept, now),
377
+ source: '(swept)',
378
+ receiptPath,
379
+ unreadable: reportUnreadable(ctx, name, swept.result),
380
+ swept: items,
381
+ };
382
+ }
383
+ try {
384
+ if (v.plan === '-') {
385
+ const plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
386
+ return { plan, source: '(stdin)', receiptPath, unreadable: false, swept: undefined };
387
+ }
388
+ const source = resolve(ctx.cwd, v.plan);
389
+ const plan = readPlan(source);
390
+ return {
391
+ plan,
392
+ source,
393
+ receiptPath: receiptPath ?? receiptBeside(source, now),
394
+ unreadable: false,
395
+ swept: undefined,
396
+ };
397
+ }
398
+ catch (err) {
399
+ return failure(ctx, err);
400
+ }
401
+ }
402
+ /** The run's flags that contradict each other, as a usage message, or null. */
403
+ function applyConflict(v, paths) {
404
+ if (v.plan !== undefined) {
405
+ const sweepFlags = [
406
+ paths.length > 0 ? 'paths' : null,
407
+ v.only !== undefined ? '--only' : null,
408
+ v.branch !== undefined ? '--branch' : null,
409
+ v.remote !== undefined ? '--remote' : null,
410
+ ].filter((f) => f !== null);
411
+ if (sweepFlags.length > 0)
412
+ return `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`;
413
+ }
414
+ if (v.plan === '-' && !v.yes && !v.dryRun) {
415
+ return '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run';
416
+ }
417
+ return null;
357
418
  }
358
419
  function runApply(argv, ctx, deps, status) {
359
420
  const name = `${NAME} apply`;
@@ -375,57 +436,39 @@ function runApply(argv, ctx, deps, status) {
375
436
  return usageError(ctx, name, err.message);
376
437
  throw err;
377
438
  }
378
- const fromStdin = v.plan === '-';
379
- if (v.plan !== undefined) {
380
- const sweepFlags = [
381
- paths.length > 0 ? 'paths' : null,
382
- v.only !== undefined ? '--only' : null,
383
- v.branch !== undefined ? '--branch' : null,
384
- v.remote !== undefined ? '--remote' : null,
385
- ].filter((f) => f !== null);
386
- if (sweepFlags.length > 0) {
387
- return usageError(ctx, name, `--plan fixes what is swept; drop ${sweepFlags.join(', ')}`);
388
- }
389
- }
390
- if (fromStdin && !v.yes && !v.dryRun) {
391
- return usageError(ctx, name, '--plan - reads the plan from stdin, so it cannot ask; pass --yes or --dry-run');
392
- }
439
+ const conflict = applyConflict(v, paths);
440
+ if (conflict !== null)
441
+ return usageError(ctx, name, conflict);
393
442
  const now = new Date();
394
- let plan;
395
- let planSource;
396
- // A repository the sweep could not read contributes no rows; the run says so and exits 1.
397
- let unreadable = false;
398
- const ok = () => (unreadable ? EXIT.error : EXIT.ok);
399
- let receiptPath = v.receipt === undefined ? null : resolve(ctx.cwd, v.receipt);
400
- if (v.plan === undefined) {
401
- const swept = sweep(name, v, paths, ctx, (r) => (v.output === 'json' ? renderJson(r) : renderText(r)), status);
402
- if (!swept.ok)
403
- return swept.code;
404
- unreadable = reportUnreadable(ctx, name, swept.result);
405
- plan = planOf(swept, now);
406
- planSource = '(swept)';
443
+ const loaded = loadPlan(name, v, paths, ctx, deps, status, now);
444
+ if (typeof loaded === 'number')
445
+ return loaded;
446
+ const { plan, source, receiptPath } = loaded;
447
+ const ok = () => (loaded.unreadable ? EXIT.error : EXIT.ok);
448
+ const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
449
+ const print = (receipt, shownPath) => {
450
+ ctx.io.stdout(v.output === 'json'
451
+ ? `${JSON.stringify({ receiptPath: shownPath, summary: summarizeReceipt(receipt), receipt }, null, 2)}\n`
452
+ : renderReceipt(receipt, shownPath));
453
+ };
454
+ const planned = plan.repos.filter((r) => repoRoot === undefined || resolve(r.root) === resolve(repoRoot));
455
+ if (repoRoot !== undefined && planned.length === 0) {
456
+ return failure(ctx, new Error(`no plan repository at ${repoRoot}`));
407
457
  }
408
- else {
409
- try {
410
- if (fromStdin) {
411
- plan = parsePlan((deps.readStdin ?? readAllStdin)(), 'stdin');
412
- planSource = '(stdin)';
413
- }
414
- else {
415
- planSource = resolve(ctx.cwd, v.plan);
416
- plan = readPlan(planSource);
417
- receiptPath ??= receiptBeside(planSource, now);
418
- }
419
- }
420
- catch (err) {
421
- return failure(ctx, err);
422
- }
458
+ if (rows.length === 0 && planned.every((r) => r.rows.length === 0)) {
459
+ if (v.output === 'json')
460
+ print(emptyReceipt(source, now, v.dryRun), null);
461
+ else
462
+ ctx.io.stdout('nothing to delete\n');
463
+ return ok();
423
464
  }
424
- const repoRoot = v.repoRoot === undefined ? undefined : resolve(ctx.cwd, v.repoRoot);
425
465
  const base = {
426
466
  callerCwd: ctx.cwd,
427
- planPath: planSource,
467
+ planPath: source,
428
468
  repoRoot,
469
+ // An implicit selection runs only safe-to-delete rows, in the preview and
470
+ // in the run it confirms, whose rows are passed explicitly.
471
+ safeOnly: rows.length === 0,
429
472
  ...(v.progress
430
473
  ? {
431
474
  onSweepProgress: (p) => status.update(`re-checking ${describeSweep(p)}`),
@@ -433,25 +476,17 @@ function runApply(argv, ctx, deps, status) {
433
476
  }
434
477
  : {}),
435
478
  };
436
- const print = (receipt, shownPath) => {
437
- ctx.io.stdout(v.output === 'json'
438
- ? `${JSON.stringify({ receiptPath: shownPath, receipt }, null, 2)}\n`
439
- : renderReceipt(receipt, shownPath));
440
- };
441
479
  let preview;
442
480
  try {
443
- preview = applyPlan(plan, rows, grants, { ...base, dryRun: true, receiptPath: null });
481
+ // A sweep that just ran is the preview's re-check; the run that deletes sweeps again.
482
+ preview = applyPlan(plan, rows, grants, {
483
+ ...base,
484
+ dryRun: true,
485
+ receiptPath: null,
486
+ swept: loaded.swept,
487
+ });
444
488
  }
445
489
  catch (err) {
446
- if (err instanceof ApplyArgumentError) {
447
- if (rows.length === 0 && plan.repos.every((r) => r.rows.length === 0)) {
448
- if (v.output === 'json')
449
- print(emptyReceipt(planSource, now), null);
450
- else
451
- ctx.io.stdout('nothing to delete\n');
452
- return ok();
453
- }
454
- }
455
490
  return failure(ctx, err);
456
491
  }
457
492
  const count = wouldRun(preview);
@@ -460,17 +495,9 @@ function runApply(argv, ctx, deps, status) {
460
495
  return ok();
461
496
  }
462
497
  if (!v.yes) {
463
- // The preview is for the person answering, so it goes where the question does.
464
- const show = v.output === 'json' ? ctx.io.stderr : ctx.io.stdout;
465
- show(renderReceipt(preview, null));
466
- const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
467
- if (answer === null) {
468
- return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
469
- }
470
- if (!answer) {
471
- ctx.io.stderr('nothing deleted\n');
472
- return EXIT.ok;
473
- }
498
+ const declined = confirm(ctx, name, preview, count, v.output === 'json', deps);
499
+ if (declined !== null)
500
+ return declined;
474
501
  }
475
502
  // Only what the preview showed: rows it dropped stay dropped.
476
503
  // Pinned to their repositories: a branch name two repositories share stays two rows.
@@ -489,14 +516,35 @@ function runApply(argv, ctx, deps, status) {
489
516
  return failure(ctx, err);
490
517
  }
491
518
  print(receipt, receiptPath);
519
+ if (receiptPath === null) {
520
+ ctx.io.stderr(`${name}: no receipt written; pass --receipt <file> to keep one\n`);
521
+ }
492
522
  return receipt.status === 'failed' ? EXIT_STEP_FAILED : ok();
493
523
  }
494
- function emptyReceipt(plan, now) {
524
+ /**
525
+ * Show the preview and ask. Null to go ahead; otherwise the exit code: 0 for
526
+ * a no, 2 when there is no one to ask.
527
+ */
528
+ function confirm(ctx, name, preview, count, json, deps) {
529
+ // The preview is for the person answering, so it goes where the question does.
530
+ const show = json ? ctx.io.stderr : ctx.io.stdout;
531
+ show(renderReceipt(preview, null));
532
+ const answer = (deps.confirm ?? terminalConfirm)(`Delete ${count} item${count === 1 ? '' : 's'}?`);
533
+ if (answer === null) {
534
+ return usageError(ctx, name, 'refusing to delete without a terminal to ask; pass --yes');
535
+ }
536
+ if (!answer) {
537
+ ctx.io.stderr('nothing deleted\n');
538
+ return EXIT.ok;
539
+ }
540
+ return null;
541
+ }
542
+ function emptyReceipt(plan, now, dryRun) {
495
543
  const at = now.toISOString();
496
544
  return {
497
- version: 1,
545
+ version: RECEIPT_VERSION,
498
546
  plan,
499
- status: 'dry-run',
547
+ status: dryRun ? 'dry-run' : 'complete',
500
548
  started_at: at,
501
549
  finished_at: at,
502
550
  dropped: [],
package/dist/index.d.ts CHANGED
@@ -21,10 +21,10 @@ export { assessLanding, patchEquivalent, type Landing, type LandingCheck } from
21
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
23
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, type Plan, type PlanRepo, type PlannableRow, } from './plan.ts';
24
- export { ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step } from './receipt.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';
29
+ export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
30
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
@@ -20,10 +20,10 @@ 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
22
  export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan, } from "./plan.js";
23
- export { ReceiptSchema } from "./receipt.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";
28
+ export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
29
29
  export { ALL_SCOPES, AUTHORIZATIONS, authorizationsFor, emptyBucket, emptyScan, findDuplicateCheckouts, isUnfetched, makeItem, summarize, } from "./types.js";
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";
@@ -23,6 +25,7 @@ export declare const DroppedSchema: z.ZodObject<{
23
25
  "needs-review": "needs-review";
24
26
  "facts-changed": "facts-changed";
25
27
  "not-authorized": "not-authorized";
28
+ duplicate: "duplicate";
26
29
  }>;
27
30
  detail: z.ZodString;
28
31
  }, z.core.$strip>;
@@ -81,6 +84,7 @@ export declare const ReceiptSchema: z.ZodObject<{
81
84
  "needs-review": "needs-review";
82
85
  "facts-changed": "facts-changed";
83
86
  "not-authorized": "not-authorized";
87
+ duplicate: "duplicate";
84
88
  }>;
85
89
  detail: z.ZodString;
86
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(['gone', 'not-deletable', 'needs-review', 'facts-changed', 'not-authorized']),
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(1),
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
@@ -48,5 +48,28 @@ export declare function renderText(result: SweepResult, opts?: {
48
48
  export declare function renderJson(result: SweepResult, extra?: Record<string, unknown>): string;
49
49
  /** Every command a `safe-to-delete` row that needs no authorization proposes, in report order. */
50
50
  export declare function proposedCommands(result: SweepResult): string[];
51
- /** An apply run as text: dropped rows, each step, then where the receipt is. */
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. */
52
75
  export declare function renderReceipt(receipt: Receipt, receiptPath: string | null): string;
package/dist/render.js CHANGED
@@ -264,7 +264,74 @@ export function proposedCommands(result) {
264
264
  }
265
265
  return out;
266
266
  }
267
- /** An apply run as text: dropped rows, each step, then where the receipt is. */
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. */
268
335
  export function renderReceipt(receipt, receiptPath) {
269
336
  const lines = [];
270
337
  for (const d of receipt.dropped) {
@@ -275,6 +342,9 @@ export function renderReceipt(receipt, receiptPath) {
275
342
  const tail = s.error ?? s.verification;
276
343
  lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
277
344
  }
345
+ if (lines.length > 0)
346
+ lines.push('');
347
+ lines.push(...renderApplySummary(receipt));
278
348
  lines.push(receiptPath === null
279
349
  ? `status: ${receipt.status}`
280
350
  : `receipt: ${receiptPath} (${receipt.status})`);
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.10.0";
2
+ export declare const VERSION = "0.10.1";
@@ -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.10.0';
3
+ export const VERSION = '0.10.1';
package/dist/worktrees.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs';
9
9
  import { isAbsolute, join, sep } from 'node:path';
10
10
  import { shellJoin } from '@osdlc/command-seam';
11
11
  import { nodeGuard } from '@osdlc/path-guard/node';
12
- import { gapSentence, recordPr } from "./branches.js";
12
+ import { gapSentence, mergedFact, recordPr } from "./branches.js";
13
13
  import { confirmationNote, judgeLanding, landingConfirmed, recordLanding, unmatchedSentence, } from "./landing.js";
14
14
  import { busyHolders, isIdleHolder, parseLockOwner } from "./processes.js";
15
15
  import { authorizationsFor, landedRef, makeItem, } from "./types.js";
@@ -251,10 +251,12 @@ export function classifyWorktree(opts) {
251
251
  const patchLanded = patch !== null && patch.check !== null;
252
252
  const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
253
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));
254
+ // `soft`: a missing default ref reads as not merged.
255
+ facts['merged'] = mergedFact({
256
+ landed,
257
+ patchLanded,
258
+ ancestor: git.at(repo.root).soft.isAncestor(head, landedRef(repo)),
259
+ });
258
260
  }
259
261
  const requires = authorizationsFor({
260
262
  'abandon-unmerged': unlanded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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",
@@ -50,10 +50,10 @@
50
50
  "dependencies": {
51
51
  "zod": "4.4.3",
52
52
  "@osdlc/cli-tool": "^0.12.0",
53
- "@osdlc/command-seam": "^0.14.0",
54
53
  "@osdlc/path-guard": "^0.3.1",
55
- "@osdlc/typed-gh": "^0.14.1",
54
+ "@osdlc/command-seam": "^0.14.0",
56
55
  "@osdlc/typed-git": "^0.12.0",
56
+ "@osdlc/typed-gh": "^0.14.1",
57
57
  "@osdlc/ux": "^0.1.1"
58
58
  },
59
59
  "devDependencies": {
@@ -63,8 +63,8 @@
63
63
  "prettier": "3.9.6",
64
64
  "typescript": "~6.0.3",
65
65
  "vitest": "^5.0.5",
66
- "@osdlc/prettier-config": "^0.3.1",
67
- "@osdlc/tsconfig": "^0.4.1"
66
+ "@osdlc/tsconfig": "^0.4.1",
67
+ "@osdlc/prettier-config": "^0.3.1"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "cli-tool-stamp-version && tsc -p tsconfig.build.json",