@osdlc/repo-cleaner 0.10.0 → 0.11.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sweep a remote with no checkout of your own: a clone of it, kept in
3
+ * repo-cleaner's cache directory, stands in for the checkout.
4
+ *
5
+ * The clone is `--no-checkout`, so no files are written outside `.git`. It is
6
+ * not a partial (`--filter=blob:none`) clone: the patch comparison that
7
+ * finds squash-merged branches reads file contents, and a partial clone
8
+ * fetches them over the network one comparison at a time, which made a sweep
9
+ * of a few hundred branches take minutes instead of seconds. It is kept
10
+ * rather than thrown away so a plan written from it can be applied later: the
11
+ * plan names the clone's directory, and `apply` re-sweeps and pushes deletes
12
+ * from there. A later run reuses it, and the sweep's fetch brings it up to
13
+ * date.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { Git, parseRemoteRepo } from '@osdlc/typed-git';
19
+ /** A remote URL that cannot be mirrored, or a mirror that cannot be made or reused. */
20
+ export class RemoteMirrorError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'RemoteMirrorError';
24
+ }
25
+ }
26
+ /** Characters kept in a directory name; anything else becomes `-`. */
27
+ const UNSAFE = /[^A-Za-z0-9._-]+/g;
28
+ /**
29
+ * `text` with every URL's credentials removed: the user-info before `@` in a
30
+ * `scheme://` URL, and any query string or fragment. For a message or a name
31
+ * that must not carry a token.
32
+ */
33
+ export function redactUrl(text) {
34
+ return text
35
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
36
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#]\S*/gi, '$1');
37
+ }
38
+ /**
39
+ * The directory name for `url`'s mirror: readable `host-owner-repo`, then a
40
+ * hash of the whole URL so two URLs never share a mirror. The readable part
41
+ * is read from the URL with its credentials, query and fragment removed, so a
42
+ * token never reaches the name.
43
+ */
44
+ export function mirrorName(url) {
45
+ const hash = createHash('sha256').update(url).digest('hex').slice(0, 12);
46
+ const parsed = parseRemoteRepo(redactUrl(url));
47
+ const readable = parsed === null
48
+ ? 'remote'
49
+ : `${parsed.host}-${parsed.owner}-${parsed.repo}`.replace(UNSAFE, '-');
50
+ return `${readable.replace(/^[.-]+/, '')}-${hash}`;
51
+ }
52
+ /**
53
+ * Why `dir` is not a mirror of `url` — or of whatever it was cloned from, when
54
+ * `url` is omitted — or null when it is. A mirror's `origin` has one fetch URL
55
+ * and pushes to that URL alone, so a delete cannot be sent elsewhere.
56
+ */
57
+ export function mirrorProblem(dir, url, opts = {}) {
58
+ const git = new Git(dir, { runner: opts.git });
59
+ const fetch = git.try.remoteGetUrls('origin');
60
+ const push = git.try.remoteGetUrls('origin', { push: true });
61
+ if (!fetch.ok || !push.ok)
62
+ return `${dir} has no readable origin remote`;
63
+ const [from] = fetch.value;
64
+ if (fetch.value.length !== 1 || from === undefined) {
65
+ return `${dir} has ${fetch.value.length} origin URLs`;
66
+ }
67
+ if (url !== undefined && from !== url)
68
+ return `${dir} is a mirror of another URL`;
69
+ if (push.value.length !== 1 || push.value[0] !== from) {
70
+ return `${dir} pushes somewhere other than the URL it was cloned from`;
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * The directory of `url`'s mirror under `cacheDir`, cloned on first use. An
76
+ * existing mirror is reused as it is, once {@link mirrorProblem} finds
77
+ * nothing wrong with it; the sweep's own fetch brings it up to date. A first
78
+ * clone goes to a sibling directory and is renamed into place, so a clone
79
+ * that fails leaves nothing behind, and a run that loses a race to clone the
80
+ * same URL uses the winner's clone. Messages carry the URL with its
81
+ * credentials removed.
82
+ *
83
+ * @throws RemoteMirrorError when `url` is empty or looks like an option, when
84
+ * the clone fails, or when the directory is not a mirror of `url`.
85
+ */
86
+ export function ensureMirror(url, cacheDir, opts = {}) {
87
+ if (url === '' || url.startsWith('-')) {
88
+ throw new RemoteMirrorError(`not a remote URL: ${JSON.stringify(redactUrl(url))}`);
89
+ }
90
+ const shown = redactUrl(url);
91
+ const dir = join(cacheDir, mirrorName(url));
92
+ if (existsSync(dir)) {
93
+ const problem = mirrorProblem(dir, url, opts);
94
+ if (problem !== null) {
95
+ throw new RemoteMirrorError(`${problem}, not a mirror of ${shown}; remove the directory and run again`);
96
+ }
97
+ return dir;
98
+ }
99
+ mkdirSync(dirname(dir), { recursive: true });
100
+ const staging = `${dir}.partial-${process.pid}`;
101
+ rmSync(staging, { recursive: true, force: true });
102
+ try {
103
+ new Git(dirname(dir), { runner: opts.git }).rawChecked([
104
+ 'clone',
105
+ '--quiet',
106
+ '--no-checkout',
107
+ '--',
108
+ url,
109
+ staging,
110
+ ]);
111
+ renameSync(staging, dir);
112
+ }
113
+ catch (err) {
114
+ rmSync(staging, { recursive: true, force: true });
115
+ // Another run cloned the same URL first and won the rename: use its clone.
116
+ if (existsSync(dir) && mirrorProblem(dir, url, opts) === null)
117
+ return dir;
118
+ const detail = err.failure?.detail ??
119
+ (err instanceof Error ? err.message : String(err));
120
+ throw new RemoteMirrorError(`cannot clone ${shown}: ${redactUrl(detail.split(url).join(shown)).trim()}`);
121
+ }
122
+ return dir;
123
+ }
package/dist/render.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare function renderScan(result: SweepResult): string | null;
30
30
  export declare function renderSummary(result: SweepResult): string;
31
31
  /**
32
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.
33
+ * merged, and how many the sweep would delete, prune, review or keep.
34
34
  */
35
35
  export declare function renderCounts(sweep: RepoSweep): string;
36
36
  /** The whole run as counts: a table per repository, then the one-line summary. */
@@ -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
@@ -6,7 +6,7 @@
6
6
  * find. JSON carries everything the text report elides, including the facts
7
7
  * behind every verdict.
8
8
  */
9
- import { emptyBucket, isUnfetched, summarize, } from "./types.js";
9
+ import { emptyBucket, isPrune, isUnfetched, summarize, } from "./types.js";
10
10
  import { hasBlockers } from "./worktrees.js";
11
11
  /** Order categories consistently, whatever order they were gathered in. */
12
12
  const CATEGORY_ORDER = ['local_branch', 'worktree', 'remote_branch'];
@@ -69,7 +69,9 @@ export function renderRepo(sweep, opts = {}) {
69
69
  const idWidth = Math.min(38, Math.max(...shown.map((item) => ellipsize(item.id, 38).length)));
70
70
  const categoryWidth = Math.max(...shown.map((item) => item.category.length));
71
71
  for (const item of shown) {
72
- const action = ACTION_LABEL[item.classification] ?? item.classification;
72
+ const action = isPrune(item)
73
+ ? 'prune'
74
+ : (ACTION_LABEL[item.classification] ?? item.classification);
73
75
  const note = requiresNote(item);
74
76
  lines.push(` ${pad(item.category, categoryWidth)} ${pad(ellipsize(item.id, 38), idWidth)} ${pad(action, 6)} ${item.reason}${note ? `; ${note}` : ''}`);
75
77
  // A blocked row has no commands; what to do about each blocker goes there instead.
@@ -86,6 +88,7 @@ function totals(result) {
86
88
  out.total += bucket.total;
87
89
  out.merged += bucket.merged;
88
90
  out['safe-to-delete'] += bucket['safe-to-delete'];
91
+ out.prune += bucket.prune;
89
92
  out['safe-to-keep'] += bucket['safe-to-keep'];
90
93
  out['needs-investigation'] += bucket['needs-investigation'];
91
94
  }
@@ -116,6 +119,10 @@ export function renderScan(result) {
116
119
  return line;
117
120
  return `${line} ${count(scan.depthLimited)} left unopened at the depth limit.`;
118
121
  }
122
+ /** The `safe-to-delete` rows that delete something, not the ones that only prune a stale link. */
123
+ function deleteOnly(bucket) {
124
+ return bucket['safe-to-delete'] - bucket.prune;
125
+ }
119
126
  /** The one-line verdict a person reads first, and often only. */
120
127
  export function renderSummary(result) {
121
128
  const counts = totals(result);
@@ -128,24 +135,26 @@ export function renderSummary(result) {
128
135
  tail.push(`${result.unresolved.length} ${result.unresolved.length === 1 ? 'entry' : 'entries'} unresolved`);
129
136
  }
130
137
  const suffix = tail.length > 0 ? ` (${tail.join(', ')})` : '';
131
- return `${counts['safe-to-delete']} to delete, ${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
138
+ const prune = counts.prune > 0 ? `${counts.prune} to prune, ` : '';
139
+ return `${deleteOnly(counts)} to delete, ${prune}${counts['needs-investigation']} to review, across ${repos}${suffix}.`;
132
140
  }
133
141
  const COUNT_LABEL = {
134
142
  local_branch: 'local branches',
135
143
  worktree: 'worktrees',
136
144
  remote_branch: 'remote branches',
137
145
  };
146
+ /** A count table's columns. `delete` leaves out the rows `prune` counts, so each row is in one of delete, prune, review and keep. */
138
147
  const COUNT_COLUMNS = [
139
- 'total',
140
- 'merged',
141
- 'safe-to-delete',
142
- 'needs-investigation',
143
- 'safe-to-keep',
148
+ { heading: 'total', of: (b) => b.total },
149
+ { heading: 'merged', of: (b) => b.merged },
150
+ { heading: 'delete', of: deleteOnly },
151
+ { heading: 'prune', of: (b) => b.prune },
152
+ { heading: 'review', of: (b) => b['needs-investigation'] },
153
+ { heading: 'keep', of: (b) => b['safe-to-keep'] },
144
154
  ];
145
- const COUNT_HEADINGS = ['total', 'merged', 'delete', 'review', 'keep'];
146
155
  /**
147
156
  * 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.
157
+ * merged, and how many the sweep would delete, prune, review or keep.
149
158
  */
150
159
  export function renderCounts(sweep) {
151
160
  const lines = [repoHeader(sweep)];
@@ -156,10 +165,10 @@ export function renderCounts(sweep) {
156
165
  const buckets = summarize(sweep.items);
157
166
  const labelWidth = Math.max(...Object.values(COUNT_LABEL).map((l) => l.length));
158
167
  const cell = (text, heading) => text.padStart(Math.max(heading.length, 5));
159
- lines.push(` ${' '.repeat(labelWidth)} ${COUNT_HEADINGS.map((h) => cell(h, h)).join(' ')}`);
168
+ lines.push(` ${' '.repeat(labelWidth)} ${COUNT_COLUMNS.map(({ heading }) => cell(heading, heading)).join(' ')}`);
160
169
  for (const category of CATEGORY_ORDER) {
161
170
  const bucket = buckets[category] ?? emptyBucket();
162
- const cells = COUNT_COLUMNS.map((col, i) => cell(count(bucket[col]), COUNT_HEADINGS[i]));
171
+ const cells = COUNT_COLUMNS.map(({ heading, of }) => cell(count(of(bucket)), heading));
163
172
  lines.push(` ${pad(COUNT_LABEL[category], labelWidth)} ${cells.join(' ')}`);
164
173
  }
165
174
  return lines.join('\n');
@@ -264,7 +273,74 @@ export function proposedCommands(result) {
264
273
  }
265
274
  return out;
266
275
  }
267
- /** An apply run as text: dropped rows, each step, then where the receipt is. */
276
+ /** Count a receipt's outcome. A dry run's `would-run` steps count as done. */
277
+ export function summarizeReceipt(receipt) {
278
+ const out = {
279
+ deleted: { worktree: 0, local_branch: 0, remote_branch: 0 },
280
+ pruned: 0,
281
+ dropped: {},
282
+ failed: 0,
283
+ notRun: 0,
284
+ };
285
+ for (const d of receipt.dropped)
286
+ out.dropped[d.reason] = (out.dropped[d.reason] ?? 0) + 1;
287
+ for (const s of receipt.steps) {
288
+ if (s.result === 'failed')
289
+ out.failed += 1;
290
+ if (s.row === null)
291
+ continue;
292
+ if (s.result === 'pending') {
293
+ if (s.action !== 'unlock-worktree')
294
+ out.notRun += 1;
295
+ continue;
296
+ }
297
+ if (s.result !== 'ok' && s.result !== 'would-run')
298
+ continue;
299
+ if (s.action === 'remove-worktree')
300
+ out.deleted.worktree += 1;
301
+ else if (s.action === 'prune-worktrees')
302
+ out.pruned += 1;
303
+ else if (s.action === 'delete-local')
304
+ out.deleted.local_branch += 1;
305
+ else if (s.action === 'delete-remote')
306
+ out.deleted.remote_branch += 1;
307
+ }
308
+ return out;
309
+ }
310
+ /** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
311
+ export function rowsActedOn(summary) {
312
+ const d = summary.deleted;
313
+ return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
314
+ }
315
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
316
+ /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
317
+ export function renderApplySummary(receipt) {
318
+ const s = summarizeReceipt(receipt);
319
+ const dry = receipt.status === 'dry-run';
320
+ const parts = [
321
+ s.deleted.worktree > 0 ? plural(s.deleted.worktree, 'worktree', 'worktrees') : null,
322
+ s.deleted.local_branch > 0
323
+ ? plural(s.deleted.local_branch, 'local branch', 'local branches')
324
+ : null,
325
+ s.deleted.remote_branch > 0
326
+ ? plural(s.deleted.remote_branch, 'remote branch', 'remote branches')
327
+ : null,
328
+ s.pruned > 0 ? `${plural(s.pruned, 'stale worktree', 'stale worktrees')} pruned` : null,
329
+ ].filter((p) => p !== null);
330
+ const lines = [
331
+ `${dry ? 'would delete' : 'deleted'}: ${parts.length > 0 ? parts.join(', ') : 'nothing'}`,
332
+ ];
333
+ const dropped = Object.entries(s.dropped);
334
+ if (dropped.length > 0) {
335
+ const total = dropped.reduce((n, [, c]) => n + c, 0);
336
+ lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
337
+ }
338
+ if (s.failed > 0) {
339
+ lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
340
+ }
341
+ return lines;
342
+ }
343
+ /** An apply run as text: dropped rows, each step, a summary, then where the receipt is. */
268
344
  export function renderReceipt(receipt, receiptPath) {
269
345
  const lines = [];
270
346
  for (const d of receipt.dropped) {
@@ -275,6 +351,9 @@ export function renderReceipt(receipt, receiptPath) {
275
351
  const tail = s.error ?? s.verification;
276
352
  lines.push(`${s.result.padEnd(8)} ${what}${tail ? ` (${tail})` : ''}`);
277
353
  }
354
+ if (lines.length > 0)
355
+ lines.push('');
356
+ lines.push(...renderApplySummary(receipt));
278
357
  lines.push(receiptPath === null
279
358
  ? `status: ${receipt.status}`
280
359
  : `receipt: ${receiptPath} (${receipt.status})`);
package/dist/types.d.ts CHANGED
@@ -155,12 +155,16 @@ export interface SummaryBucket {
155
155
  /** Rows whose work is on the default branch (`facts.merged`). Rows kept for structure are not judged. */
156
156
  merged: number;
157
157
  'safe-to-delete': number;
158
+ /** The `safe-to-delete` rows that only prune a stale worktree link (`action: 'prune'`); the directory, if any, stays. */
159
+ prune: number;
158
160
  'safe-to-keep': number;
159
161
  'needs-investigation': number;
160
162
  }
161
163
  export declare function emptyBucket(): SummaryBucket;
162
164
  /** Per-category counts over `items`. */
163
165
  export declare function summarize(items: readonly SweepItem[]): Record<string, SummaryBucket>;
166
+ /** A row whose deletion only prunes a stale worktree link. */
167
+ export declare function isPrune(item: Pick<SweepItem, 'classification' | 'action'>): boolean;
164
168
  type Defaulted = 'facts' | 'commands' | 'investigationParts' | 'requires' | 'deletable';
165
169
  /** Build an item, defaulting the collection fields and `deletable: true`. */
166
170
  export declare function makeItem(partial: Omit<SweepItem, Defaulted> & Partial<Pick<SweepItem, Defaulted>>): SweepItem;
package/dist/types.js CHANGED
@@ -48,7 +48,14 @@ 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, merged: 0, 'safe-to-delete': 0, 'safe-to-keep': 0, 'needs-investigation': 0 };
51
+ return {
52
+ total: 0,
53
+ merged: 0,
54
+ 'safe-to-delete': 0,
55
+ prune: 0,
56
+ 'safe-to-keep': 0,
57
+ 'needs-investigation': 0,
58
+ };
52
59
  }
53
60
  /** Per-category counts over `items`. */
54
61
  export function summarize(items) {
@@ -58,10 +65,16 @@ export function summarize(items) {
58
65
  bucket.total += 1;
59
66
  if (item.facts['merged'] === true)
60
67
  bucket.merged += 1;
68
+ if (isPrune(item))
69
+ bucket.prune += 1;
61
70
  bucket[item.classification] += 1;
62
71
  }
63
72
  return out;
64
73
  }
74
+ /** A row whose deletion only prunes a stale worktree link. */
75
+ export function isPrune(item) {
76
+ return item.classification === 'safe-to-delete' && item.action === 'prune';
77
+ }
65
78
  /** Build an item, defaulting the collection fields and `deletable: true`. */
66
79
  export function makeItem(partial) {
67
80
  return {
@@ -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.11.0";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.10.0';
3
+ export const VERSION = '0.11.0';
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,
@@ -291,7 +293,7 @@ export function classifyWorktree(opts) {
291
293
  category: 'worktree',
292
294
  id: path,
293
295
  classification: 'safe-to-delete',
294
- action: 'remove',
296
+ action: 'prune',
295
297
  reason: dirPresent
296
298
  ? 'directory is no longer a checkout (no .git); prune the stale link, the directory stays'
297
299
  : 'worktree directory no longer on disk; prune the stale link',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "zod": "4.4.3",
52
- "@osdlc/cli-tool": "^0.12.0",
52
+ "@osdlc/cli-tool": "^0.13.0",
53
53
  "@osdlc/command-seam": "^0.14.0",
54
54
  "@osdlc/path-guard": "^0.3.1",
55
55
  "@osdlc/typed-gh": "^0.14.1",