@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/CHANGELOG.md +18 -0
- package/README.md +497 -329
- package/dist/branches.d.ts +9 -36
- package/dist/branches.js +92 -172
- package/dist/clean.d.ts +162 -0
- package/dist/clean.js +755 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +340 -479
- package/dist/hooks.d.ts +8 -12
- package/dist/hooks.js +2 -2
- package/dist/index.d.ts +18 -25
- package/dist/index.js +16 -23
- package/dist/landing.d.ts +3 -3
- package/dist/landing.js +7 -7
- package/dist/plan.d.ts +76 -70
- package/dist/plan.js +82 -77
- package/dist/receipt.d.ts +44 -57
- package/dist/receipt.js +46 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +148 -0
- package/dist/remote-mirror.d.ts +8 -0
- package/dist/remote-mirror.js +18 -0
- package/dist/render.d.ts +61 -61
- package/dist/render.js +195 -254
- package/dist/resolve.js +1 -9
- package/dist/status-line.d.ts +1 -1
- package/dist/status-line.js +5 -5
- package/dist/step-board.d.ts +1 -1
- package/dist/step-board.js +2 -5
- package/dist/submodules.d.ts +120 -0
- package/dist/submodules.js +292 -0
- package/dist/sweep.d.ts +97 -67
- package/dist/sweep.js +272 -118
- package/dist/types.d.ts +276 -105
- package/dist/types.js +100 -54
- package/dist/util.d.ts +7 -0
- package/dist/util.js +20 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worktrees.d.ts +21 -37
- package/dist/worktrees.js +198 -165
- package/package.json +4 -4
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
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);
|
package/dist/status-line.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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`. */
|
package/dist/status-line.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/**
|
|
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
|
|
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. */
|
package/dist/step-board.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
package/dist/step-board.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|