@osdlc/repo-cleaner 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resolve.js CHANGED
@@ -12,11 +12,11 @@
12
12
  * repositories, and the CLI's bare invocation sweeps the checkout it is
13
13
  * standing in.
14
14
  */
15
- import { statSync } from 'node:fs';
16
15
  import { homedir } from 'node:os';
17
16
  import { isAbsolute, join, resolve as resolvePath } from 'node:path';
18
17
  import { checkoutRoot, scanForCheckouts } from '@osdlc/typed-git';
19
18
  import { emptyScan } from "./types.js";
19
+ import { isDir } from "./util.js";
20
20
  /** `~` and `~/x` against this user's home. Any other path is returned as-is. */
21
21
  export function expandTilde(path, home = homedir()) {
22
22
  if (path === '~')
@@ -25,14 +25,6 @@ export function expandTilde(path, home = homedir()) {
25
25
  return join(home, path.slice(2));
26
26
  return path;
27
27
  }
28
- function isDir(path) {
29
- try {
30
- return statSync(path).isDirectory();
31
- }
32
- catch {
33
- return false;
34
- }
35
- }
36
28
  /** True iff `dir` is itself the root of a checkout — main or linked worktree. */
37
29
  export function isCheckoutRoot(dir) {
38
30
  return checkoutRoot(dir) === resolvePath(dir);
@@ -12,7 +12,7 @@ export declare function progressBar(done: number, total: number, width?: number)
12
12
  * The path goes last, so a line cut to the terminal's width loses its tail first.
13
13
  */
14
14
  export declare function describeSweep(p: SweepProgress, home?: string): string;
15
- /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
15
+ /** A run's step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote remote:origin/feat/x`. */
16
16
  export declare function describeStep(index: number, total: number, step: Step): string;
17
17
  export interface StatusLine {
18
18
  /** Replace the line with `text`. */
@@ -9,9 +9,9 @@ const STEP_TEXT = {
9
9
  discover: 'reading the repository',
10
10
  fetch: 'fetching',
11
11
  prs: 'listing pull requests (gh)',
12
- worktrees: 'checking worktrees',
13
- 'local-branches': 'checking local branches',
14
- 'remote-branches': 'checking remote branches',
12
+ worktree: 'checking worktrees',
13
+ branch: 'checking local branches',
14
+ remote: 'checking remote branches',
15
15
  };
16
16
  /** `~/x` for a path under the home directory. */
17
17
  function tilde(path, home) {
@@ -36,9 +36,9 @@ export function describeSweep(p, home = homedir()) {
36
36
  : STEP_TEXT[p.step];
37
37
  return `${where}${text}${count} · ${tilde(p.repo, home)}`;
38
38
  }
39
- /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
39
+ /** A run's step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote remote:origin/feat/x`. */
40
40
  export function describeStep(index, total, step) {
41
- const what = step.row === null ? step.repo : step.row.id;
41
+ const what = step.row;
42
42
  return `deleting ${progressBar(index, total)} ${index + 1}/${total}: ${step.action} ${what}`;
43
43
  }
44
44
  /** A line drawn with `\r` and erase-line, controls stripped, cut to `columns() - 1` display columns so it never wraps. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `apply`'s steps as a vertical list redrawn in place on a terminal: one row
2
+ * A run's steps as a vertical list redrawn in place on a terminal: one row
3
3
  * per step with its own bar, empty while it waits, half full while it runs,
4
4
  * full and ticked when it is done, and a bar for the whole run under the
5
5
  * list. The run is synchronous, so the board moves between steps, not on a
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `apply`'s steps as a vertical list redrawn in place on a terminal: one row
2
+ * A run's steps as a vertical list redrawn in place on a terminal: one row
3
3
  * per step with its own bar, empty while it waits, half full while it runs,
4
4
  * full and ticked when it is done, and a bar for the whole run under the
5
5
  * list. The run is synchronous, so the board moves between steps, not on a
@@ -13,10 +13,7 @@ const BAR = 10;
13
13
  const TOTAL_BAR = 30;
14
14
  function label(step) {
15
15
  const verb = STEP_VERB[step.action];
16
- // The closing prune belongs to no row: it clears the repository's stale worktree records.
17
- if (step.row === null)
18
- return stripControls(`${verb.padEnd(6)} worktree records in ${step.repo}`);
19
- return stripControls(`${verb.padEnd(6)} ${step.row.category} ${step.row.id}`);
16
+ return stripControls(`${verb.padEnd(6)} ${step.row}`);
20
17
  }
21
18
  /**
22
19
  * One step's row, cut to `width` columns before it is colored, so the color
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Submodules: what removing a worktree would take with them.
3
+ *
4
+ * A linked worktree's submodules keep their git dirs under the worktree's own
5
+ * git dir (`<common>/worktrees/<id>/modules/<name>`), not the shared one. So
6
+ * `git worktree remove --force` and `git worktree prune` delete each
7
+ * submodule's history with the worktree: a commit made in a submodule and
8
+ * never pushed, a local branch, a stash. Plain `git worktree remove` refuses
9
+ * any worktree with a checked-out submodule, or with a submodule git dir left
10
+ * behind (a `git submodule deinit` leaves one), whatever state it is in, so
11
+ * removing one at all needs `--force`, which also skips git's own check for
12
+ * modified and untracked files.
13
+ *
14
+ * {@link inspectSubmodules} reads every submodule, nested ones included, and
15
+ * every left-over git dir, and reports what each holds that removal would
16
+ * lose. The sweep turns that into the same risks the worktree's own work
17
+ * carries (`discard-changes`, `abandon-unmerged`); `run` reads it again just
18
+ * before a forced remove and refuses if anything changed.
19
+ */
20
+ import type { Git } from '@osdlc/typed-git';
21
+ import type { SubmoduleEvidence } from './types.ts';
22
+ /** One submodule of a worktree, or a submodule git dir it keeps, and what removing the worktree would lose from it. */
23
+ export interface Submodule {
24
+ /**
25
+ * The path from the worktree root, `/`-separated, a nested submodule's
26
+ * through its parents (`deps/lib/inner`). For a git dir no checkout uses,
27
+ * its name under `modules/`, nested names joined the same way.
28
+ */
29
+ path: string;
30
+ /** The commit the enclosing repository's index records; null for a git dir no checkout uses. */
31
+ recorded: string | null;
32
+ /** Checked out: its directory holds a `.git`. */
33
+ populated: boolean;
34
+ /** The commit checked out; null when not populated or unreadable. */
35
+ head: string | null;
36
+ /** Its git dir, absolute; null when it has none (never initialized, or initialized elsewhere). */
37
+ gitDir: string | null;
38
+ /** `git status --porcelain` lines inside it, a nested submodule's change included. */
39
+ dirtyFiles: number;
40
+ /** Commits on HEAD or a local branch that no remote-tracking ref holds. */
41
+ unpushedCommits: number;
42
+ /** Stash entries. A linked worktree's submodule stashes live in its private git dir. */
43
+ stashes: number;
44
+ /** Files marked skip-worktree or assume-unchanged, whose edits status hides. */
45
+ hiddenIndexPaths: string[];
46
+ /** Untracked or ignored directories inside it that hold a repository of their own. */
47
+ nestedRepositories: string[];
48
+ /** Why it could not be read; null when it was. An unreadable submodule could hold anything. */
49
+ error: string | null;
50
+ }
51
+ /** Everything {@link inspectSubmodules} found in one worktree. */
52
+ export interface SubmoduleReport {
53
+ /** Every submodule, nested ones after their parent, then every left-over git dir. */
54
+ submodules: Submodule[];
55
+ /**
56
+ * True when plain `git worktree remove` refuses this worktree: a submodule
57
+ * is checked out, or the worktree's git dir keeps a `modules/` directory.
58
+ * Removing it then takes `--force`.
59
+ */
60
+ forceRequired: boolean;
61
+ /** Submodules with modified, staged or untracked files. Removal discards them. */
62
+ dirty: Submodule[];
63
+ /** Submodules holding commits or stashes no remote has. Removal deletes them. */
64
+ unpushed: Submodule[];
65
+ /** Submodules that could not be read, so nobody can say they are safe to lose. */
66
+ unreadable: Submodule[];
67
+ }
68
+ /**
69
+ * What `status` cannot show in the checkout at `dir`: files marked
70
+ * skip-worktree or assume-unchanged, whose edits status hides, and untracked
71
+ * or ignored directories that hold a repository of their own (no
72
+ * `excludeStandard`: an ignored directory can hold one too; only the top of
73
+ * each listed directory is checked). For a worktree and each submodule alike.
74
+ */
75
+ export declare function readIndexHazards(at: Git, dir: string): {
76
+ hiddenIndexPaths: string[];
77
+ nestedRepositories: string[];
78
+ };
79
+ /**
80
+ * The private git dir of each linked worktree of the repository whose shared
81
+ * git dir is `commonDir`, by the worktree's path; read once from every
82
+ * `<commonDir>/worktrees/<id>/gitdir`. Works for a worktree whose directory is
83
+ * gone, where `rev-parse` has nothing to run in.
84
+ */
85
+ export declare function worktreeGitDirs(commonDir: string): Map<string, string>;
86
+ /**
87
+ * Every submodule of the worktree at `worktree`, nested ones included, and
88
+ * every submodule git dir its private git dir keeps that no checkout uses.
89
+ *
90
+ * `gitDir` is the worktree's private git dir. Without it, it is asked of git
91
+ * when the directory is there, and nothing beyond the checkouts is read when
92
+ * it is not. A worktree whose directory is gone has no checkouts to read, but
93
+ * its git dir may still hold submodule history: pass {@link worktreeGitDir}.
94
+ *
95
+ * Never throws for a submodule it cannot read: that submodule comes back with
96
+ * `error` set, and in `unreadable`.
97
+ */
98
+ export declare function inspectSubmodules(git: Git, worktree: string, opts?: {
99
+ gitDir?: string | null | undefined;
100
+ }): SubmoduleReport;
101
+ /** The report as `evidence.submodules`, for a row and its plan fingerprint. */
102
+ export declare function submoduleFacts(report: SubmoduleReport): SubmoduleEvidence[];
103
+ /**
104
+ * Why a forced remove of a worktree with this report would lose something,
105
+ * one sentence each; empty when it would lose nothing. The grants accept what
106
+ * they accept in the worktree itself: `discard` (`discard-changes`) modified
107
+ * and untracked files, `abandon` (`abandon-unmerged`) commits and stashes no
108
+ * remote has. An unreadable submodule, hidden index flags and nested
109
+ * repositories are never accepted.
110
+ */
111
+ export declare function submoduleHazards(report: SubmoduleReport, opts?: {
112
+ discard?: boolean;
113
+ abandon?: boolean;
114
+ }): string[];
115
+ /** `submodule(s) sub (1 unpushed commit), deps/x (2 stashes)`. */
116
+ export declare function unpushedPhrase(subs: readonly Submodule[]): string;
117
+ /** `submodule(s) sub (<why it could not be read>)`. */
118
+ export declare function unreadablePhrase(subs: readonly Submodule[]): string;
119
+ /** `2 unpushed commits, 1 stash`. */
120
+ export declare function unsharedPhrase(s: Pick<Submodule, 'unpushedCommits' | 'stashes'>): string;
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Submodules: what removing a worktree would take with them.
3
+ *
4
+ * A linked worktree's submodules keep their git dirs under the worktree's own
5
+ * git dir (`<common>/worktrees/<id>/modules/<name>`), not the shared one. So
6
+ * `git worktree remove --force` and `git worktree prune` delete each
7
+ * submodule's history with the worktree: a commit made in a submodule and
8
+ * never pushed, a local branch, a stash. Plain `git worktree remove` refuses
9
+ * any worktree with a checked-out submodule, or with a submodule git dir left
10
+ * behind (a `git submodule deinit` leaves one), whatever state it is in, so
11
+ * removing one at all needs `--force`, which also skips git's own check for
12
+ * modified and untracked files.
13
+ *
14
+ * {@link inspectSubmodules} reads every submodule, nested ones included, and
15
+ * every left-over git dir, and reports what each holds that removal would
16
+ * lose. The sweep turns that into the same risks the worktree's own work
17
+ * carries (`discard-changes`, `abandon-unmerged`); `run` reads it again just
18
+ * before a forced remove and refuses if anything changed.
19
+ */
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
21
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
22
+ import { errorText, isDir, plural } from "./util.js";
23
+ /**
24
+ * What `status` cannot show in the checkout at `dir`: files marked
25
+ * skip-worktree or assume-unchanged, whose edits status hides, and untracked
26
+ * or ignored directories that hold a repository of their own (no
27
+ * `excludeStandard`: an ignored directory can hold one too; only the top of
28
+ * each listed directory is checked). For a worktree and each submodule alike.
29
+ */
30
+ export function readIndexHazards(at, dir) {
31
+ return {
32
+ hiddenIndexPaths: at
33
+ .lsFilesTagged()
34
+ .filter((row) => row.tag === 'S' || row.tag !== row.tag.toUpperCase())
35
+ .map((row) => row.path),
36
+ nestedRepositories: at
37
+ .lsFiles({ others: true, directory: true })
38
+ .filter((p) => p.endsWith('/') && existsSync(join(dir, p, '.git'))),
39
+ };
40
+ }
41
+ /**
42
+ * The private git dir of each linked worktree of the repository whose shared
43
+ * git dir is `commonDir`, by the worktree's path; read once from every
44
+ * `<commonDir>/worktrees/<id>/gitdir`. Works for a worktree whose directory is
45
+ * gone, where `rev-parse` has nothing to run in.
46
+ */
47
+ export function worktreeGitDirs(commonDir) {
48
+ const admin = join(commonDir, 'worktrees');
49
+ const out = new Map();
50
+ let ids;
51
+ try {
52
+ ids = readdirSync(admin);
53
+ }
54
+ catch {
55
+ return out;
56
+ }
57
+ for (const id of ids) {
58
+ try {
59
+ const pointer = readFileSync(join(admin, id, 'gitdir'), 'utf-8').trim();
60
+ const dotGit = isAbsolute(pointer) ? pointer : join(admin, id, pointer);
61
+ out.set(resolve(dirname(dotGit)), join(admin, id));
62
+ }
63
+ catch {
64
+ // Not a worktree's admin dir, or one being written; not one of ours either way.
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /** A repository's git dir: `HEAD` and `objects` side by side. */
70
+ const isGitDir = (dir) => existsSync(join(dir, 'HEAD')) && isDir(join(dir, 'objects'));
71
+ /** Every git dir under `modules`, a nested submodule's (`<name>/modules/<name>`) included. */
72
+ function moduleGitDirs(modules, prefix = '') {
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(modules);
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const out = [];
81
+ for (const entry of entries.sort()) {
82
+ const dir = join(modules, entry);
83
+ if (!isDir(dir))
84
+ continue;
85
+ const name = prefix === '' ? entry : `${prefix}/${entry}`;
86
+ if (isGitDir(dir)) {
87
+ out.push({ name, dir }, ...moduleGitDirs(join(dir, 'modules'), name));
88
+ }
89
+ else {
90
+ // A submodule name may hold slashes: `modules/vendor/lib`.
91
+ out.push(...moduleGitDirs(dir, name));
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ /** A `rev-list --count` argv's answer, exit code checked. */
97
+ function count(git, argv) {
98
+ const out = git.rawChecked(argv).stdout.trim();
99
+ const n = Number(out);
100
+ if (!Number.isInteger(n))
101
+ throw new Error(`rev-list printed ${JSON.stringify(out)}`);
102
+ return n;
103
+ }
104
+ /**
105
+ * Commits on HEAD or a local branch that no remote-tracking ref has, and the
106
+ * stash's entries. `prefix` goes before each git command: `--git-dir` and
107
+ * `--work-tree` for a git dir with no checkout. `hasHead` is asked of git when
108
+ * the caller does not know it already.
109
+ */
110
+ function unsharedWork(git, prefix, hasHead = git.raw([...prefix, 'rev-parse', '--verify', '--quiet', 'HEAD']).exitCode === 0) {
111
+ // An unborn HEAD (a git dir whose clone never finished) has no commits of its own.
112
+ const tips = [...(hasHead ? ['HEAD'] : []), '--branches'];
113
+ return {
114
+ unpushedCommits: count(git, [...prefix, 'rev-list', '--count', ...tips, '--not', '--remotes']),
115
+ stashes: stashCount(git, prefix),
116
+ };
117
+ }
118
+ /** Entries in `refs/stash`'s reflog: one per `git stash list` line. */
119
+ function stashCount(git, prefix) {
120
+ const listed = git.raw([...prefix, 'reflog', 'show', '--format=%H', 'refs/stash', '--']);
121
+ // No stash: the ref does not exist and git exits non-zero.
122
+ if (listed.exitCode !== 0)
123
+ return 0;
124
+ return listed.stdout.split('\n').filter((line) => line.trim() !== '').length;
125
+ }
126
+ function blank(path, recorded) {
127
+ return {
128
+ path,
129
+ recorded,
130
+ populated: false,
131
+ head: null,
132
+ gitDir: null,
133
+ dirtyFiles: 0,
134
+ unpushedCommits: 0,
135
+ stashes: 0,
136
+ hiddenIndexPaths: [],
137
+ nestedRepositories: [],
138
+ error: null,
139
+ };
140
+ }
141
+ /** Read the checked-out submodule at `dir`, then its own submodules. */
142
+ function readCheckout(git, dir, entry, out) {
143
+ const at = git.at(dir);
144
+ out.push(entry);
145
+ try {
146
+ entry.head = at.resolveRef('HEAD');
147
+ entry.gitDir = at.gitDir();
148
+ entry.dirtyFiles = at.statusPorcelain().length;
149
+ const work = unsharedWork(at, [], entry.head !== null);
150
+ entry.unpushedCommits = work.unpushedCommits;
151
+ entry.stashes = work.stashes;
152
+ Object.assign(entry, readIndexHazards(at, dir));
153
+ for (const child of at.submodules()) {
154
+ readSubmodule(git, dir, `${entry.path}/${child.path}`, child.path, child.commit, out);
155
+ }
156
+ }
157
+ catch (err) {
158
+ entry.error = errorText(err);
159
+ }
160
+ }
161
+ function readSubmodule(git, parentDir, label, path, recorded, out) {
162
+ const dir = join(parentDir, ...path.split('/'));
163
+ const entry = blank(label, recorded);
164
+ if (!existsSync(join(dir, '.git'))) {
165
+ out.push(entry);
166
+ return;
167
+ }
168
+ entry.populated = true;
169
+ readCheckout(git, dir, entry, out);
170
+ }
171
+ /**
172
+ * Every submodule of the worktree at `worktree`, nested ones included, and
173
+ * every submodule git dir its private git dir keeps that no checkout uses.
174
+ *
175
+ * `gitDir` is the worktree's private git dir. Without it, it is asked of git
176
+ * when the directory is there, and nothing beyond the checkouts is read when
177
+ * it is not. A worktree whose directory is gone has no checkouts to read, but
178
+ * its git dir may still hold submodule history: pass {@link worktreeGitDir}.
179
+ *
180
+ * Never throws for a submodule it cannot read: that submodule comes back with
181
+ * `error` set, and in `unreadable`.
182
+ */
183
+ export function inspectSubmodules(git, worktree, opts = {}) {
184
+ const out = [];
185
+ const onDisk = existsSync(join(worktree, '.git'));
186
+ let gitDir = opts.gitDir ?? null;
187
+ let topError = null;
188
+ if (onDisk) {
189
+ const at = git.at(worktree);
190
+ try {
191
+ gitDir ??= at.gitDir();
192
+ for (const sub of at.submodules()) {
193
+ readSubmodule(git, worktree, sub.path, sub.path, sub.commit, out);
194
+ }
195
+ }
196
+ catch (err) {
197
+ topError = errorText(err);
198
+ }
199
+ }
200
+ // A git dir under `modules/` that no checkout above uses: a deinit leaves
201
+ // one, and so does a worktree whose directory was deleted. Its history goes
202
+ // with the worktree's git dir all the same.
203
+ const modules = gitDir === null ? null : join(gitDir, 'modules');
204
+ const used = new Set(out.flatMap((s) => (s.gitDir === null ? [] : [resolve(s.gitDir)])));
205
+ const hasModules = modules !== null && isDir(modules);
206
+ if (hasModules) {
207
+ for (const { name, dir } of moduleGitDirs(modules)) {
208
+ if (used.has(resolve(dir)))
209
+ continue;
210
+ const entry = blank(name, null);
211
+ entry.gitDir = dir;
212
+ try {
213
+ // `--work-tree`: the git dir's `core.worktree` may name a directory
214
+ // that is gone, and git would fail to enter it. These reads never
215
+ // touch a working tree, so the git dir itself stands in.
216
+ const work = unsharedWork(git, [`--git-dir=${dir}`, `--work-tree=${dir}`]);
217
+ entry.unpushedCommits = work.unpushedCommits;
218
+ entry.stashes = work.stashes;
219
+ }
220
+ catch (err) {
221
+ entry.error = errorText(err);
222
+ }
223
+ out.push(entry);
224
+ }
225
+ }
226
+ if (topError !== null)
227
+ out.unshift({ ...blank('.', null), error: topError });
228
+ return {
229
+ submodules: out,
230
+ forceRequired: out.some((s) => s.populated) || hasModules,
231
+ dirty: out.filter((s) => s.dirtyFiles > 0),
232
+ unpushed: out.filter((s) => s.unpushedCommits > 0 || s.stashes > 0),
233
+ unreadable: out.filter((s) => s.error !== null),
234
+ };
235
+ }
236
+ /** The report as `evidence.submodules`, for a row and its plan fingerprint. */
237
+ export function submoduleFacts(report) {
238
+ return report.submodules.map((s) => ({
239
+ path: s.path,
240
+ populated: s.populated,
241
+ head: s.head,
242
+ recorded: s.recorded,
243
+ dirtyFiles: s.dirtyFiles,
244
+ unpushedCommits: s.unpushedCommits,
245
+ stashes: s.stashes,
246
+ ...(s.error === null ? {} : { error: s.error }),
247
+ }));
248
+ }
249
+ /**
250
+ * Why a forced remove of a worktree with this report would lose something,
251
+ * one sentence each; empty when it would lose nothing. The grants accept what
252
+ * they accept in the worktree itself: `discard` (`discard-changes`) modified
253
+ * and untracked files, `abandon` (`abandon-unmerged`) commits and stashes no
254
+ * remote has. An unreadable submodule, hidden index flags and nested
255
+ * repositories are never accepted.
256
+ */
257
+ export function submoduleHazards(report, opts = {}) {
258
+ const out = [];
259
+ const names = (subs) => subs.map((s) => s.path).join(', ');
260
+ if (report.unreadable.length > 0)
261
+ out.push(`could not read ${unreadablePhrase(report.unreadable)}`);
262
+ if (opts.abandon !== true && report.unpushed.length > 0) {
263
+ out.push(`${unpushedPhrase(report.unpushed)} hold work no remote has`);
264
+ }
265
+ const hidden = report.submodules.filter((s) => s.hiddenIndexPaths.length > 0);
266
+ if (hidden.length > 0)
267
+ out.push(`submodule(s) ${names(hidden)} hide edits behind index flags`);
268
+ const nested = report.submodules.filter((s) => s.nestedRepositories.length > 0);
269
+ if (nested.length > 0)
270
+ out.push(`submodule(s) ${names(nested)} hold nested repositories`);
271
+ if (opts.discard !== true && report.dirty.length > 0) {
272
+ out.push(`submodule(s) ${names(report.dirty)} have uncommitted changes`);
273
+ }
274
+ return out;
275
+ }
276
+ /** `submodule(s) sub (1 unpushed commit), deps/x (2 stashes)`. */
277
+ export function unpushedPhrase(subs) {
278
+ return `submodule(s) ${subs.map((s) => `${s.path} (${unsharedPhrase(s)})`).join(', ')}`;
279
+ }
280
+ /** `submodule(s) sub (<why it could not be read>)`. */
281
+ export function unreadablePhrase(subs) {
282
+ return `submodule(s) ${subs.map((s) => `${s.path} (${s.error})`).join(', ')}`;
283
+ }
284
+ /** `2 unpushed commits, 1 stash`. */
285
+ export function unsharedPhrase(s) {
286
+ const parts = [];
287
+ if (s.unpushedCommits > 0)
288
+ parts.push(plural(s.unpushedCommits, 'unpushed commit', 'unpushed commits'));
289
+ if (s.stashes > 0)
290
+ parts.push(plural(s.stashes, 'stash', 'stashes'));
291
+ return parts.join(', ');
292
+ }