@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/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({
@@ -95,10 +103,11 @@ export function remoteDeleteCommand(remote, name, sha) {
95
103
  ]);
96
104
  }
97
105
  // ── local branches ─────────────────────────────────────────────────────────
98
- export function gatherLocalBranches(repo, prs, worktreeBranches, git, only) {
106
+ export function gatherLocalBranches(repo, prs, worktreeBranches, git, only, tick) {
99
107
  const rows = git.at(repo.root).forEachRef('refs/heads/', { format: LOCAL_FORMAT });
100
108
  const items = [];
101
- for (const line of rows) {
109
+ for (const [i, line] of rows.entries()) {
110
+ tick?.(i, rows.length);
102
111
  const parts = line.split('\t');
103
112
  if (parts.length !== 5)
104
113
  continue;
@@ -140,10 +149,24 @@ export function classifyLocalBranch(opts) {
140
149
  const pinned = (reason) => keep(reason, { deletable: false });
141
150
  if (name === repo.mainBranch)
142
151
  return pinned('default branch');
143
- if (name === repo.currentBranch)
144
- return pinned('current HEAD');
145
- if (worktreeBranches.has(name))
146
- return pinned('checked out in a worktree (handle via that worktree)');
152
+ const held = name === repo.currentBranch
153
+ ? 'current HEAD'
154
+ : worktreeBranches.has(name)
155
+ ? 'checked out in a worktree (handle via that worktree)'
156
+ : null;
157
+ if (held !== null) {
158
+ // Never deletable here, but still counted as merged or not: by ancestry or
159
+ // an exact-head merged PR, without the patch checks a deletable row gets.
160
+ // `soft`: a missing default ref reads as not merged.
161
+ const pr = prs.get(name);
162
+ const { landed } = recordPr(facts, pr, sha, repo);
163
+ facts['merged'] = mergedFact({
164
+ landed,
165
+ ancestor: git.at(repo.root).soft.isAncestor(sha, landedRef(repo)),
166
+ patchLanded: false,
167
+ });
168
+ return pinned(held);
169
+ }
147
170
  // Work merged only into an unpushed local default has not landed. A missing
148
171
  // remote-tracking ref exits 128: ancestry unknown (null).
149
172
  const compareRef = landedRef(repo);
@@ -161,6 +184,7 @@ export function classifyLocalBranch(opts) {
161
184
  recordLanding(facts, patch);
162
185
  const patchLanded = patch !== null && patch.check !== null;
163
186
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
187
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
164
188
  if (!landed && !isAncestor) {
165
189
  facts['unique_commits'] = git.at(repo.root).aheadBehind(repo.mainBranch, sha).ahead;
166
190
  }
@@ -230,7 +254,7 @@ export function classifyLocalBranch(opts) {
230
254
  });
231
255
  }
232
256
  // ── remote branches ────────────────────────────────────────────────────────
233
- export function gatherRemoteBranches(repo, prs, localBranchNames, git, only) {
257
+ export function gatherRemoteBranches(repo, prs, localBranchNames, git, only, tick) {
234
258
  if (repo.remote === null)
235
259
  return [];
236
260
  // Default door: an unreachable remote throws rather than rendering as "the
@@ -238,7 +262,8 @@ export function gatherRemoteBranches(repo, prs, localBranchNames, git, only) {
238
262
  const pattern = only === undefined ? undefined : `refs/heads/${only}`;
239
263
  const rows = git.at(repo.root).lsRemote(repo.remote, pattern, { heads: true });
240
264
  const items = [];
241
- for (const { sha, ref } of rows) {
265
+ for (const [i, { sha, ref }] of rows.entries()) {
266
+ tick?.(i, rows.length);
242
267
  if (!ref.startsWith('refs/heads/'))
243
268
  continue;
244
269
  const name = ref.slice('refs/heads/'.length);
@@ -283,6 +308,7 @@ export function classifyRemoteBranch(opts) {
283
308
  recordLanding(facts, patch);
284
309
  const patchLanded = patch !== null && patch.check !== null;
285
310
  const unmerged = !landed && !isAncestor && !landingConfirmed(patch);
311
+ facts['merged'] = mergedFact({ landed, ancestor: isAncestor, patchLanded });
286
312
  if (!landed && !isAncestor) {
287
313
  // Unknown (null) when the clone lacks `sha` or `remoteMain`, as above.
288
314
  const counted = git.at(repo.root).try.aheadBehind(remoteMain, sha);
package/dist/cli.d.ts CHANGED
@@ -1,19 +1,38 @@
1
1
  /**
2
2
  * `repo-cleaner` — what has been merged, and what can go away.
3
3
  *
4
- * Two commands. A bare invocation sweeps: it inventories, fetching each
5
- * remote first unless `--no-fetch`, and writes nothing but an optional plan.
6
- * `repo-cleaner apply` deletes the rows of a plan that were selected and
7
- * authorized. Scope is opt-in: the sweep covers the repository you are
8
- * standing in unless `--all` or a path says otherwise.
4
+ * Four verbs over one set of sweep filters:
5
+ *
6
+ * status (the default) inventory and classify; read-only but for the fetch
7
+ * summary the same sweep as counts: totals, merged, delete, review, keep
8
+ * plan write the deletable rows as a plan, to stdout or a file
9
+ * apply re-check, show the steps, ask, then delete
10
+ *
11
+ * `apply` works from a saved plan (`--plan`) or sweeps first. Either way it
12
+ * re-checks every row, prints the steps it would run and asks before deleting;
13
+ * `--yes` skips the question. Without a terminal and without `--yes` it
14
+ * refuses rather than deleting. Scope is opt-in: the sweep covers the
15
+ * repository you are standing in unless a path says otherwise.
9
16
  *
10
17
  * Exit codes:
11
- * 0 swept; or every selected row was applied or dropped with a reason
18
+ * 0 swept; or apply ran, was declined, or had nothing to run
12
19
  * 1 failed against the state it found
13
20
  * 2 cannot run as typed
14
21
  * 10 nothing to sweep: no entry named a repository
15
22
  * 11 apply stopped at a failed step; the receipt says which
16
23
  */
17
24
  import { type CliContext } from '@osdlc/cli-tool';
25
+ /**
26
+ * Asks a yes/no question, or returns null when there is no one to ask. `main`
27
+ * takes it as a parameter so a test can answer.
28
+ */
29
+ export type Confirm = (question: string) => boolean | null;
30
+ export interface MainDeps {
31
+ confirm?: Confirm;
32
+ /** Reads `--plan -`. Defaults to stdin. */
33
+ readStdin?: () => string;
34
+ /** Whether stderr is a terminal, so a status line may be drawn. Defaults to `isatty(2)`. */
35
+ stderrIsTerminal?: boolean;
36
+ }
18
37
  /** `main(argv, ctx)`: returns an exit code, never exits. */
19
- export declare function main(argv: readonly string[], ctx: CliContext): number;
38
+ export declare function main(argv: readonly string[], ctx: CliContext, deps?: MainDeps): number;