@osdlc/repo-cleaner 0.17.2 → 0.19.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 +31 -0
- package/README.md +691 -305
- package/dist/branches.d.ts +37 -36
- package/dist/branches.js +199 -196
- package/dist/clean.d.ts +179 -0
- package/dist/clean.js +824 -0
- package/dist/cli.d.ts +12 -16
- package/dist/cli.js +367 -478
- 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/providers/github.d.ts +2 -1
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.d.ts +3 -2
- package/dist/providers/gitlab.js +14 -8
- package/dist/providers/index.d.ts +5 -4
- package/dist/providers/index.js +8 -5
- package/dist/providers/types.d.ts +8 -2
- package/dist/prs.d.ts +35 -7
- package/dist/prs.js +45 -12
- package/dist/receipt.d.ts +46 -57
- package/dist/receipt.js +52 -28
- package/dist/reference.d.ts +12 -0
- package/dist/reference.js +152 -0
- package/dist/remote-mirror.d.ts +9 -1
- package/dist/remote-mirror.js +19 -1
- package/dist/render.d.ts +66 -61
- package/dist/render.js +206 -255
- package/dist/resolve.d.ts +3 -3
- package/dist/resolve.js +4 -12
- 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 +107 -64
- package/dist/sweep.js +333 -119
- package/dist/types.d.ts +294 -105
- package/dist/types.js +106 -54
- package/dist/util.d.ts +13 -0
- package/dist/util.js +36 -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 +5 -5
- package/dist/apply.d.ts +0 -131
- package/dist/apply.js +0 -541
package/dist/util.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Small helpers every module of the package shares. */
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { failureOf } from '@osdlc/cli-tool';
|
|
4
|
+
import { CommandFailed } from '@osdlc/typed-git';
|
|
5
|
+
/** True when `path` is a directory; false for anything else, or when it cannot be read. */
|
|
6
|
+
export function isDir(path) {
|
|
7
|
+
try {
|
|
8
|
+
return statSync(path).isDirectory();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* An error as one line for a person: a failed git call's own output, or the
|
|
16
|
+
* message. Takes a thrown error or a `try` door's failure, which is a
|
|
17
|
+
* `Failure` rather than an `Error`; a failure's classified message reads
|
|
18
|
+
* better than the raw output it was classified from. The text goes into
|
|
19
|
+
* notes and report lines, so line breaks are folded into spaces.
|
|
20
|
+
*/
|
|
21
|
+
export function errorText(err) {
|
|
22
|
+
return oneLine(rawErrorText(err));
|
|
23
|
+
}
|
|
24
|
+
function rawErrorText(err) {
|
|
25
|
+
if (err instanceof CommandFailed)
|
|
26
|
+
return err.detail.trim() || err.message;
|
|
27
|
+
if (err instanceof Error)
|
|
28
|
+
return err.message;
|
|
29
|
+
const failure = failureOf(err);
|
|
30
|
+
if (failure !== null)
|
|
31
|
+
return failure.message || failure.detail?.trim() || '';
|
|
32
|
+
return String(err);
|
|
33
|
+
}
|
|
34
|
+
const oneLine = (text) => text.replace(/\s*\n\s*/g, ' ').trim();
|
|
35
|
+
/** `1 commit`, `3 commits`. */
|
|
36
|
+
export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This package's version, from its package.json when it was built. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.19.0";
|
package/dist/worktrees.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Git } from '@osdlc/typed-git';
|
|
9
9
|
import { type ProcessProbe } from './processes.ts';
|
|
10
|
-
import { type PRInfo, type RepoInfo, type
|
|
10
|
+
import { type PRInfo, type RepoInfo, type WorktreeEvidence, type WorktreeRow } from './types.ts';
|
|
11
11
|
/** The command line, not run, that removes the worktree at `path`. */
|
|
12
12
|
export declare function worktreeRemoveCommand(root: string, path: string, opts?: {
|
|
13
13
|
force?: boolean;
|
|
@@ -16,45 +16,29 @@ export declare function worktreeRemoveCommand(root: string, path: string, opts?:
|
|
|
16
16
|
export declare function worktreeUnlockCommand(root: string, path: string): string;
|
|
17
17
|
/** The command line, not run, that drops registrations of worktrees whose directories are gone. */
|
|
18
18
|
export declare function worktreePruneCommand(root: string): string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Whether removing this worktree takes `git worktree remove --force`: its
|
|
21
|
+
* submodules make plain remove refuse, it has modified or untracked files
|
|
22
|
+
* (which only `discard-changes` lets go), or its branch is gone. The force
|
|
23
|
+
* skips git's own cleanliness check, so a run checks again itself first.
|
|
24
|
+
*/
|
|
25
|
+
export declare function removeIsForced(e: WorktreeEvidence): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The commands that carry out `action` on the worktree whose evidence is `e`:
|
|
28
|
+
* an unlock first for a stale lock. A prune of a worktree whose directory is
|
|
29
|
+
* gone removes that worktree's record alone (`git worktree remove` takes a
|
|
30
|
+
* missing directory); one whose directory is left without its `.git` needs
|
|
31
|
+
* the repository-wide `git worktree prune`, which `git worktree remove`
|
|
32
|
+
* refuses to stand in for.
|
|
33
|
+
*/
|
|
34
|
+
export declare function worktreeCommands(root: string, e: WorktreeEvidence, action: 'remove' | 'prune'): string[];
|
|
23
35
|
/**
|
|
24
36
|
* Every worktree of `repo`, and the set of branches they hold.
|
|
25
37
|
*
|
|
26
38
|
* The branch set is returned because a local branch checked out in a worktree
|
|
27
39
|
* must not be judged on its own: the worktree is what a person would act on.
|
|
28
40
|
*/
|
|
29
|
-
export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only:
|
|
30
|
-
|
|
31
|
-
path
|
|
32
|
-
|
|
33
|
-
/** The worktree's HEAD commit; null when git reported none. */
|
|
34
|
-
head: string | null;
|
|
35
|
-
isBare: boolean;
|
|
36
|
-
isDetached: boolean;
|
|
37
|
-
isPrimary: boolean;
|
|
38
|
-
locked: boolean;
|
|
39
|
-
lockReason: string | null;
|
|
40
|
-
/** Other worktrees with the same branch checked out. */
|
|
41
|
-
otherWorktrees: string[];
|
|
42
|
-
repo: RepoInfo;
|
|
43
|
-
prs: Map<string, PRInfo>;
|
|
44
|
-
cwd: string;
|
|
45
|
-
git: Git;
|
|
46
|
-
/** Answers lock-owner and working-directory questions. One per sweep: it caches the process table. */
|
|
47
|
-
probe: ProcessProbe;
|
|
48
|
-
}): SweepItem;
|
|
49
|
-
/** How a branch shows it was worked on, for a worktree merged by ancestry alone. */
|
|
50
|
-
export type BranchUse = 'commits' | 'upstream';
|
|
51
|
-
/**
|
|
52
|
-
* The sign that `branch` was worked on, or null for a branch that looks
|
|
53
|
-
* freshly made. `commits`: its reflog records work (see {@link isWorkEntry}). `upstream`: it tracks a branch on a remote, and not
|
|
54
|
-
* the default branch (`mainBranch`) of any remote, so someone pushed it as
|
|
55
|
-
* its own branch. A branch with neither, such as one `git worktree add -b`
|
|
56
|
-
* just made from the default branch, with or without `--track`, reads as
|
|
57
|
-
* null: removing its worktree could lose ignored files from work about to
|
|
58
|
-
* start.
|
|
59
|
-
*/
|
|
60
|
-
export declare function branchUse(git: Git, branch: string, mainBranch: string): BranchUse | null;
|
|
41
|
+
export declare function gatherWorktrees(repo: RepoInfo, prs: Map<string, PRInfo>, cwd: string, git: Git, only: {
|
|
42
|
+
branch?: string | undefined;
|
|
43
|
+
path?: string | undefined;
|
|
44
|
+
}, probe: ProcessProbe, tick?: (done: number, total: number) => void): [WorktreeRow[], Set<string>];
|
package/dist/worktrees.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* than a branch. Anything uncommitted or unpushed stops the verdict cold —
|
|
6
6
|
* losing an hour of someone's work costs more than any amount of clutter.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync,
|
|
9
|
-
import { isAbsolute, join, sep } from 'node:path';
|
|
8
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
10
10
|
import { shellJoin } from '@osdlc/command-seam';
|
|
11
11
|
import { nodeGuard } from '@osdlc/path-guard/node';
|
|
12
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
|
-
import {
|
|
15
|
+
import { inspectSubmodules, readIndexHazards, submoduleFacts, unpushedPhrase, unreadablePhrase, worktreeGitDirs, } from "./submodules.js";
|
|
16
|
+
import { landedRef, makeRow, risksFor, } from "./types.js";
|
|
17
|
+
import { isDir } from "./util.js";
|
|
16
18
|
/** The command line, not run, that removes the worktree at `path`. */
|
|
17
19
|
export function worktreeRemoveCommand(root, path, opts = {}) {
|
|
18
20
|
return shellJoin([
|
|
@@ -33,36 +35,57 @@ export function worktreeUnlockCommand(root, path) {
|
|
|
33
35
|
export function worktreePruneCommand(root) {
|
|
34
36
|
return shellJoin(['git', '-C', root, 'worktree', 'prune']);
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Whether removing this worktree takes `git worktree remove --force`: its
|
|
40
|
+
* submodules make plain remove refuse, it has modified or untracked files
|
|
41
|
+
* (which only `discard-changes` lets go), or its branch is gone. The force
|
|
42
|
+
* skips git's own cleanliness check, so a run checks again itself first.
|
|
43
|
+
*/
|
|
44
|
+
export function removeIsForced(e) {
|
|
45
|
+
return (e.forcedForSubmodules === true ||
|
|
46
|
+
(e.dirtyFiles ?? 0) > 0 ||
|
|
47
|
+
(e.submoduleDirtyFiles ?? 0) > 0 ||
|
|
48
|
+
(e.branch !== null && e.branchExists === false));
|
|
43
49
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The commands that carry out `action` on the worktree whose evidence is `e`:
|
|
52
|
+
* an unlock first for a stale lock. A prune of a worktree whose directory is
|
|
53
|
+
* gone removes that worktree's record alone (`git worktree remove` takes a
|
|
54
|
+
* missing directory); one whose directory is left without its `.git` needs
|
|
55
|
+
* the repository-wide `git worktree prune`, which `git worktree remove`
|
|
56
|
+
* refuses to stand in for.
|
|
57
|
+
*/
|
|
58
|
+
export function worktreeCommands(root, e, action) {
|
|
59
|
+
// `git worktree remove` and `prune` both skip a locked worktree, so a stale
|
|
60
|
+
// lock is cleared first.
|
|
61
|
+
const unlock = e.staleLock === true ? [worktreeUnlockCommand(root, e.path)] : [];
|
|
62
|
+
return [
|
|
63
|
+
...unlock,
|
|
64
|
+
action === 'remove'
|
|
65
|
+
? worktreeRemoveCommand(root, e.path, { force: removeIsForced(e) })
|
|
66
|
+
: e.directoryLeft === true
|
|
67
|
+
? worktreePruneCommand(root)
|
|
68
|
+
: worktreeRemoveCommand(root, e.path),
|
|
69
|
+
];
|
|
48
70
|
}
|
|
49
|
-
/** Cap on `
|
|
71
|
+
/** Cap on `evidence.hiddenIndexPaths`; `evidence.hiddenIndexFlags` is the full count. */
|
|
50
72
|
const HIDDEN_PATHS_SHOWN = 10;
|
|
51
73
|
/**
|
|
52
74
|
* The blockers on one worktree, each with the sentence telling a person what
|
|
53
|
-
* to do about it. Writes the evidence for each into `
|
|
75
|
+
* to do about it. Writes the evidence for each into `evidence`.
|
|
54
76
|
*/
|
|
55
77
|
function readBlockers(opts) {
|
|
56
|
-
const { path, branch, onDisk, locked, lockReason, otherWorktrees,
|
|
78
|
+
const { path, branch, onDisk, locked, lockReason, otherWorktrees, evidence, git, probe } = opts;
|
|
79
|
+
const { submodules } = opts;
|
|
57
80
|
const out = [];
|
|
58
81
|
if (locked) {
|
|
59
|
-
|
|
82
|
+
evidence.lockReason = lockReason;
|
|
60
83
|
// A lock whose owner is dead holds nothing. A lock that names no owner, or
|
|
61
84
|
// one that cannot be ruled out, stays a blocker.
|
|
62
85
|
const owner = parseLockOwner(lockReason);
|
|
63
86
|
if (owner !== null && !probe.alive(owner.pid, owner.startedAt)) {
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
evidence.staleLock = true;
|
|
88
|
+
evidence.lockOwner = { pid: owner.pid, startedAt: owner.startedAt ?? null };
|
|
66
89
|
}
|
|
67
90
|
else {
|
|
68
91
|
out.push([
|
|
@@ -80,9 +103,9 @@ function readBlockers(opts) {
|
|
|
80
103
|
const shells = live.filter(isIdleHolder);
|
|
81
104
|
const busy = busyHolders(live);
|
|
82
105
|
if (shells.length > 0)
|
|
83
|
-
|
|
106
|
+
evidence.idleShells = shells;
|
|
84
107
|
if (busy.length > 0) {
|
|
85
|
-
|
|
108
|
+
evidence.liveProcesses = busy;
|
|
86
109
|
out.push([
|
|
87
110
|
'live-process',
|
|
88
111
|
`Running in the worktree: ${busy.map((h) => `${h.command} (pid ${h.pid})`).join(', ')}; stop them or wait for them to finish.`,
|
|
@@ -93,41 +116,43 @@ function readBlockers(opts) {
|
|
|
93
116
|
// No process table to read (a host without `lsof`, a timeout): nobody
|
|
94
117
|
// can say the directory is idle, so the worktree is held back.
|
|
95
118
|
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
-
|
|
119
|
+
evidence.processProbeError = message;
|
|
97
120
|
out.push([
|
|
98
121
|
'process-probe-failed',
|
|
99
122
|
`Could not tell whether a process is running in the worktree (${message}); check by hand, or fix the probe and sweep again.`,
|
|
100
123
|
]);
|
|
101
124
|
}
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
// Status hides edits to flagged files, so "clean" cannot be trusted, and a
|
|
126
|
+
// nested repository has its own history. A submodule's count too: `--force`
|
|
127
|
+
// removes its files with the rest.
|
|
128
|
+
const own = readIndexHazards(git.at(path), path);
|
|
129
|
+
const inSubmodules = (pick) => submodules.submodules.flatMap((s) => pick(s).map((p) => `${s.path}/${p}`));
|
|
130
|
+
const hidden = [...own.hiddenIndexPaths, ...inSubmodules((s) => s.hiddenIndexPaths)];
|
|
108
131
|
if (hidden.length > 0) {
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
evidence.hiddenIndexFlags = hidden.length;
|
|
133
|
+
evidence.hiddenIndexPaths = hidden.slice(0, HIDDEN_PATHS_SHOWN);
|
|
111
134
|
out.push([
|
|
112
135
|
'hidden-index-flags',
|
|
113
136
|
`${hidden.length} file(s) are marked skip-worktree or assume-unchanged, so status may hide edits; clear the flags with \`git update-index --no-skip-worktree --no-assume-unchanged <path>\` and check again.`,
|
|
114
137
|
]);
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
// Only the top of each listed directory is checked.
|
|
118
|
-
const nested = at
|
|
119
|
-
.lsFiles({ others: true, directory: true })
|
|
120
|
-
.filter((entry) => entry.endsWith('/') && existsSync(join(path, entry, '.git')));
|
|
139
|
+
const nested = [...own.nestedRepositories, ...inSubmodules((s) => s.nestedRepositories)];
|
|
121
140
|
if (nested.length > 0) {
|
|
122
|
-
|
|
141
|
+
evidence.nestedRepositories = nested;
|
|
123
142
|
out.push([
|
|
124
143
|
'nested-repository',
|
|
125
144
|
`Nested repositories (${nested.join(', ')}) have their own history; move them out before removing the worktree.`,
|
|
126
145
|
]);
|
|
127
146
|
}
|
|
128
147
|
}
|
|
148
|
+
if (submodules.unreadable.length > 0) {
|
|
149
|
+
out.push([
|
|
150
|
+
'submodule-unreadable',
|
|
151
|
+
`Could not read ${unreadablePhrase(submodules.unreadable)}; check them by hand, then sweep again.`,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
129
154
|
if (otherWorktrees.length > 0) {
|
|
130
|
-
|
|
155
|
+
evidence.otherWorktrees = otherWorktrees;
|
|
131
156
|
out.push([
|
|
132
157
|
'branch-in-other-worktree',
|
|
133
158
|
`Branch \`${branch}\` is also checked out at ${otherWorktrees.join(', ')}; deleting the branch along with this worktree would pull it from under the other.`,
|
|
@@ -149,7 +174,22 @@ export function gatherWorktrees(repo, prs, cwd, git, only, probe, tick) {
|
|
|
149
174
|
pathsByBranch.set(entry.branch, [...(pathsByBranch.get(entry.branch) ?? []), entry.path]);
|
|
150
175
|
}
|
|
151
176
|
}
|
|
152
|
-
|
|
177
|
+
// A path matches however git spelled it: as listed, resolved, or through symlinks.
|
|
178
|
+
const samePath = (listed, wanted) => listed === wanted ||
|
|
179
|
+
resolve(listed) === wanted ||
|
|
180
|
+
(existsSync(listed) && realpathSync(listed) === wanted);
|
|
181
|
+
const entries = all.filter((entry) => (only.branch === undefined || entry.branch === only.branch) &&
|
|
182
|
+
(only.path === undefined || samePath(entry.path, only.path)));
|
|
183
|
+
// Read once, and only when a worktree is gone: its git dir may still hold
|
|
184
|
+
// submodule history that the prune would delete.
|
|
185
|
+
let gitDirs;
|
|
186
|
+
const gitDirOf = (path) => {
|
|
187
|
+
if (gitDirs === undefined) {
|
|
188
|
+
const common = git.at(repo.root).soft.gitCommonDir();
|
|
189
|
+
gitDirs = common === null ? new Map() : worktreeGitDirs(common);
|
|
190
|
+
}
|
|
191
|
+
return gitDirs.get(resolve(path)) ?? null;
|
|
192
|
+
};
|
|
153
193
|
const items = [];
|
|
154
194
|
for (const [i, entry] of entries.entries()) {
|
|
155
195
|
tick?.(i, entries.length);
|
|
@@ -169,13 +209,14 @@ export function gatherWorktrees(repo, prs, cwd, git, only, probe, tick) {
|
|
|
169
209
|
cwd,
|
|
170
210
|
git,
|
|
171
211
|
probe,
|
|
212
|
+
gitDirOf,
|
|
172
213
|
}));
|
|
173
214
|
}
|
|
174
215
|
return [items, new Set(pathsByBranch.keys())];
|
|
175
216
|
}
|
|
176
|
-
|
|
217
|
+
function classifyWorktree(opts) {
|
|
177
218
|
const { path, branch, head, isBare, isDetached, isPrimary, repo, prs, cwd, git } = opts;
|
|
178
|
-
const
|
|
219
|
+
const evidence = {
|
|
179
220
|
path,
|
|
180
221
|
branch: branch || null,
|
|
181
222
|
head,
|
|
@@ -183,16 +224,8 @@ export function classifyWorktree(opts) {
|
|
|
183
224
|
detached: isDetached,
|
|
184
225
|
primary: isPrimary,
|
|
185
226
|
};
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
id: path,
|
|
189
|
-
classification: 'safe-to-keep',
|
|
190
|
-
action: 'keep',
|
|
191
|
-
reason,
|
|
192
|
-
facts,
|
|
193
|
-
...extra,
|
|
194
|
-
});
|
|
195
|
-
const pinned = (reason) => keep(reason, { deletable: false });
|
|
227
|
+
const row = (r) => makeRow({ kind: 'worktree', name: path, evidence, ...r });
|
|
228
|
+
const pinned = (why) => row({ verdict: 'keep', action: null, why });
|
|
196
229
|
if (isPrimary)
|
|
197
230
|
return pinned('primary worktree (repo root)');
|
|
198
231
|
// The caller is standing in this worktree. Proposing its removal would
|
|
@@ -208,8 +241,23 @@ export function classifyWorktree(opts) {
|
|
|
208
241
|
// that drops its registration leaves the directory alone.
|
|
209
242
|
const dirPresent = isDir(path);
|
|
210
243
|
const onDisk = dirPresent && existsSync(join(path, '.git'));
|
|
211
|
-
|
|
212
|
-
|
|
244
|
+
evidence.onDisk = onDisk;
|
|
245
|
+
evidence.directoryLeft = dirPresent && !onDisk;
|
|
246
|
+
// The worktree's git dir from the map read once per repository; a gone
|
|
247
|
+
// worktree has no checkout to ask, and its git dir may still hold
|
|
248
|
+
// submodule history that removing it would delete.
|
|
249
|
+
const submodules = inspectSubmodules(git, path, { gitDir: opts.gitDirOf(path) });
|
|
250
|
+
const submoduleDirty = submodules.dirty.reduce((n, s) => n + s.dirtyFiles, 0);
|
|
251
|
+
if (submodules.submodules.length > 0) {
|
|
252
|
+
evidence.submodules = submoduleFacts(submodules);
|
|
253
|
+
evidence.submoduleDirtyFiles = submoduleDirty;
|
|
254
|
+
}
|
|
255
|
+
// Plain `worktree remove` refuses a worktree with submodules whatever their
|
|
256
|
+
// state, so the remove is forced. The force also skips git's own check for
|
|
257
|
+
// modified and untracked files, which the sweep makes below and `run`
|
|
258
|
+
// makes again just before it removes.
|
|
259
|
+
if (onDisk && submodules.forceRequired)
|
|
260
|
+
evidence.forcedForSubmodules = true;
|
|
213
261
|
let dirtyFiles = [];
|
|
214
262
|
let unpushed = [];
|
|
215
263
|
if (onDisk) {
|
|
@@ -230,89 +278,105 @@ export function classifyWorktree(opts) {
|
|
|
230
278
|
const range = git.at(path).try.logLines({ range: `${compareAgainst}..HEAD`, format: '%h %s' });
|
|
231
279
|
if (range.ok)
|
|
232
280
|
unpushed = range.value;
|
|
233
|
-
|
|
281
|
+
evidence.compareAgainst = compareAgainst;
|
|
234
282
|
}
|
|
235
|
-
|
|
236
|
-
|
|
283
|
+
evidence.dirtyFiles = dirtyFiles.length;
|
|
284
|
+
evidence.unpushedCommits = unpushed.length;
|
|
237
285
|
const branchExists = !!branch && git.at(repo.root).resolveRef(branch) !== null;
|
|
238
|
-
|
|
286
|
+
evidence.branchExists = branchExists;
|
|
239
287
|
const pr = branch ? prs.get(branch) : undefined;
|
|
240
|
-
const { landed, gap } = recordPr(
|
|
288
|
+
const { landed, gap } = recordPr(evidence, pr, head ?? '', repo);
|
|
241
289
|
if (unpushed.length > 0)
|
|
242
|
-
|
|
290
|
+
evidence.uniqueCommits = unpushed.length;
|
|
243
291
|
// A merged PR at HEAD lands every commit, even ones the fallback
|
|
244
|
-
// `
|
|
292
|
+
// `compareAgainst` (a squash merge, a deleted upstream) counts as ahead.
|
|
245
293
|
// A rebase merge, cherry-pick or squash merge leaves HEAD off the default
|
|
246
294
|
// branch; the patches say whether the work is there anyway. Judged from HEAD,
|
|
247
295
|
// so a dirty tree is still caught by the dirty-file check below.
|
|
248
296
|
const patch = landed || !onDisk || head === null ? null : judgeLanding(repo, head, pr, gap, git);
|
|
249
297
|
if (patch !== null)
|
|
250
|
-
recordLanding(
|
|
298
|
+
recordLanding(evidence, patch);
|
|
251
299
|
const patchLanded = patch !== null && patch.check !== null;
|
|
252
300
|
const unlanded = unpushed.length > 0 && !landed && !landingConfirmed(patch);
|
|
253
301
|
if (onDisk && head !== null) {
|
|
254
302
|
// `soft`: a missing default ref reads as not merged.
|
|
255
|
-
|
|
303
|
+
evidence.merged = mergedFact({
|
|
256
304
|
landed,
|
|
257
305
|
patchLanded,
|
|
258
306
|
ancestor: git.at(repo.root).soft.isAncestor(head, landedRef(repo)),
|
|
259
307
|
});
|
|
260
308
|
}
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
309
|
+
const dirty = dirtyFiles.length > 0 || submoduleDirty > 0;
|
|
310
|
+
// A submodule's git dir lives in the worktree's own git dir, so removing
|
|
311
|
+
// the worktree, or pruning it once its directory is gone, deletes the
|
|
312
|
+
// submodule's commits and stashes with it. The same risk as the worktree's
|
|
313
|
+
// own unpushed commits covers them.
|
|
314
|
+
const submoduleWork = submodules.unpushed.length > 0;
|
|
315
|
+
const submoduleWorkText = submoduleWork ? unpushedPhrase(submodules.unpushed) : '';
|
|
316
|
+
const risks = risksFor({
|
|
317
|
+
'abandon-unmerged': unlanded || submoduleWork,
|
|
318
|
+
'discard-changes': dirty,
|
|
264
319
|
'confirm-open-pr': pr?.state === 'OPEN',
|
|
265
320
|
});
|
|
266
|
-
const blockers = readBlockers({
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
321
|
+
const blockers = readBlockers({ ...opts, onDisk, evidence, submodules });
|
|
322
|
+
const removes = worktreeCommands(repo.root, evidence, 'remove');
|
|
323
|
+
const pruneOnly = worktreeCommands(repo.root, evidence, 'prune');
|
|
324
|
+
// Said in the reason of a row whose remove is forced for its submodules.
|
|
325
|
+
const forced = evidence.forcedForSubmodules === true
|
|
326
|
+
? '; its submodules are clean and pushed, so the remove is forced'
|
|
327
|
+
: '';
|
|
328
|
+
if (blockers.length > 0) {
|
|
329
|
+
const kinds = blockers.map(([kind]) => kind);
|
|
330
|
+
return row({
|
|
331
|
+
verdict: 'blocked',
|
|
332
|
+
action: null,
|
|
333
|
+
why: `blocked: ${kinds.join(', ')}`,
|
|
334
|
+
blockers: kinds,
|
|
335
|
+
notes: blockers.map(([, advice]) => advice),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!onDisk && submoduleWork) {
|
|
339
|
+
return row({
|
|
340
|
+
verdict: 'review',
|
|
341
|
+
action: 'prune',
|
|
342
|
+
why: `directory gone, but its git dir keeps work no remote has in ${submoduleWorkText}`,
|
|
343
|
+
commands: pruneOnly,
|
|
344
|
+
risks,
|
|
345
|
+
notes: [
|
|
346
|
+
`Worktree ${path} is gone, but its git dir keeps ${submoduleWorkText}; pruning it deletes them.`,
|
|
347
|
+
'Push the work from that git dir, or allow abandon-unmerged to prune it anyway.',
|
|
348
|
+
],
|
|
289
349
|
});
|
|
290
350
|
}
|
|
291
351
|
if (!onDisk) {
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
id: path,
|
|
295
|
-
classification: 'safe-to-delete',
|
|
352
|
+
return row({
|
|
353
|
+
verdict: 'delete',
|
|
296
354
|
action: 'prune',
|
|
297
|
-
|
|
298
|
-
? 'directory is no longer a checkout (no .git); prune the stale
|
|
299
|
-
: 'worktree directory no longer on disk; prune the stale
|
|
300
|
-
facts,
|
|
355
|
+
why: dirPresent
|
|
356
|
+
? 'directory is no longer a checkout (no .git); prune the stale record, the directory stays'
|
|
357
|
+
: 'worktree directory no longer on disk; prune the stale record',
|
|
301
358
|
commands: pruneOnly,
|
|
302
359
|
});
|
|
303
360
|
}
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
361
|
+
if (dirty || unlanded || submoduleWork) {
|
|
362
|
+
const inSubmodules = submoduleDirty > 0
|
|
363
|
+
? `, ${submoduleDirty} in submodule(s) ${submodules.dirty.map((s) => s.path).join(', ')}`
|
|
364
|
+
: '';
|
|
365
|
+
const workInSubmodules = submoduleWork ? `; unpushed work in ${submoduleWorkText}` : '';
|
|
366
|
+
return row({
|
|
367
|
+
verdict: 'review',
|
|
368
|
+
action: 'remove',
|
|
369
|
+
why: `${dirtyFiles.length} dirty file(s)${inSubmodules}, ${unpushed.length} commit(s) ahead of ${evidence.compareAgainst}${workInSubmodules}`,
|
|
370
|
+
commands: removes,
|
|
371
|
+
risks,
|
|
372
|
+
notes: [
|
|
314
373
|
`Worktree ${path} has uncommitted work or unpushed commits.`,
|
|
315
374
|
`Branch: ${branch || '(detached)'}.`,
|
|
375
|
+
...(submoduleWork
|
|
376
|
+
? [
|
|
377
|
+
`Removing it also deletes the work in ${submoduleWorkText}, whose git dirs live in the worktree's own.`,
|
|
378
|
+
]
|
|
379
|
+
: []),
|
|
316
380
|
...(patchLanded && unlanded
|
|
317
381
|
? [
|
|
318
382
|
`Its commits are patch-equivalent to commits on ${landedRef(repo)} (${patch.check}), but ${confirmationNote(patch)}.`,
|
|
@@ -322,80 +386,49 @@ export function classifyWorktree(opts) {
|
|
|
322
386
|
],
|
|
323
387
|
});
|
|
324
388
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
id: path,
|
|
329
|
-
classification: 'safe-to-delete',
|
|
330
|
-
action: 'remove',
|
|
331
|
-
reason: `clean; PR #${pr.number} merged`,
|
|
332
|
-
facts,
|
|
333
|
-
commands: safeRemove,
|
|
334
|
-
requires,
|
|
335
|
-
});
|
|
336
|
-
}
|
|
389
|
+
const deletes = (why) => row({ verdict: 'delete', action: 'remove', why, commands: removes, risks });
|
|
390
|
+
if (pr && landed)
|
|
391
|
+
return deletes(`clean; PR #${pr.number} merged${forced}`);
|
|
337
392
|
if (patchLanded) {
|
|
338
|
-
return
|
|
339
|
-
category: 'worktree',
|
|
340
|
-
id: path,
|
|
341
|
-
classification: 'safe-to-delete',
|
|
342
|
-
action: 'remove',
|
|
343
|
-
reason: `clean; patch-equivalent to commits on ${landedRef(repo)} (${patch.check}); ${confirmationNote(patch)}`,
|
|
344
|
-
facts,
|
|
345
|
-
commands: safeRemove,
|
|
346
|
-
requires,
|
|
347
|
-
});
|
|
393
|
+
return deletes(`clean; patch-equivalent to commits on ${landedRef(repo)} (${patch.check}); ${confirmationNote(patch)}${forced}`);
|
|
348
394
|
}
|
|
349
395
|
if (branch && !branchExists) {
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
396
|
+
return deletes(`branch \`${branch}\` no longer exists${forced}`);
|
|
397
|
+
}
|
|
398
|
+
if (pr && pr.state === 'OPEN') {
|
|
399
|
+
return row({
|
|
400
|
+
verdict: 'keep',
|
|
354
401
|
action: 'remove',
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
requires,
|
|
402
|
+
why: `open PR #${pr.number}`,
|
|
403
|
+
commands: removes,
|
|
404
|
+
risks,
|
|
359
405
|
});
|
|
360
406
|
}
|
|
361
|
-
if (pr && pr.state === 'OPEN')
|
|
362
|
-
return keep(`open PR #${pr.number}`, { requires });
|
|
363
407
|
// Merged by ancestry alone: a worktree just made from the default branch is
|
|
364
408
|
// merged that way too, so ancestry needs a sign the branch was worked on.
|
|
365
|
-
if (
|
|
409
|
+
if (evidence.merged === true && branch && head !== null) {
|
|
366
410
|
const used = branchUse(git.at(repo.root), branch, repo.mainBranch);
|
|
367
|
-
|
|
411
|
+
evidence.branchUsed = used;
|
|
368
412
|
if (used !== null) {
|
|
369
|
-
return
|
|
370
|
-
category: 'worktree',
|
|
371
|
-
id: path,
|
|
372
|
-
classification: 'safe-to-delete',
|
|
373
|
-
action: 'remove',
|
|
374
|
-
reason: `clean; merged into ${landedRef(repo)}, and ${USE_REASON[used]}`,
|
|
375
|
-
facts,
|
|
376
|
-
commands: safeRemove,
|
|
377
|
-
requires,
|
|
378
|
-
});
|
|
413
|
+
return deletes(`clean; merged into ${landedRef(repo)}, and ${USE_REASON[used]}${forced}`);
|
|
379
414
|
}
|
|
380
415
|
}
|
|
381
|
-
const
|
|
416
|
+
const notes = [`Worktree \`${path}\` is clean.`];
|
|
382
417
|
if (branch)
|
|
383
|
-
|
|
384
|
-
|
|
418
|
+
notes.push(`Branch: \`${branch}\`.`);
|
|
419
|
+
notes.push(pr ? `PR #${pr.number} is ${pr.state}.` : 'No associated PR.');
|
|
385
420
|
if (pr && gap)
|
|
386
|
-
|
|
421
|
+
notes.push(gapSentence(pr, gap, repo));
|
|
387
422
|
const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
|
|
388
423
|
if (unmatched !== null)
|
|
389
|
-
|
|
390
|
-
return
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
investigationParts: parts,
|
|
398
|
-
requires,
|
|
424
|
+
notes.push(unmatched);
|
|
425
|
+
return row({
|
|
426
|
+
verdict: 'review',
|
|
427
|
+
action: 'remove',
|
|
428
|
+
why: 'clean but no clear disposability signal',
|
|
429
|
+
commands: removes,
|
|
430
|
+
notes,
|
|
431
|
+
risks,
|
|
399
432
|
});
|
|
400
433
|
}
|
|
401
434
|
const USE_REASON = {
|
|
@@ -422,7 +455,7 @@ function isWorkEntry(entry) {
|
|
|
422
455
|
* null: removing its worktree could lose ignored files from work about to
|
|
423
456
|
* start.
|
|
424
457
|
*/
|
|
425
|
-
|
|
458
|
+
function branchUse(git, branch, mainBranch) {
|
|
426
459
|
// `raw`: a branch with no reflog (core.logAllRefUpdates off) exits non-zero.
|
|
427
460
|
const reflog = git.raw(['reflog', 'show', '--format=%gs', `refs/heads/${branch}`, '--']);
|
|
428
461
|
if (reflog.exitCode === 0 && reflog.stdout.split('\n').some(isWorkEntry)) {
|