@reviewflow/sdk 6.25.0 → 6.27.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/build/git-node.mjs +722 -7
- package/build/index-browser.es.js +205 -18
- package/build/index-node.mjs +205 -18
- package/package.json +1 -1
- package/src/checkout/overview.ts +109 -0
- package/src/checkout/sync.ts +85 -0
- package/src/checkout/worktreeRemoval.ts +136 -0
- package/src/config/defaultRepoPaths.ts +2 -0
- package/src/config/insertCheckoutEntry.ts +9 -15
- package/src/config/parseConfigText.ts +23 -0
- package/src/config/repoPathsSetting.ts +44 -0
- package/src/diff/types.ts +112 -0
- package/src/git/checkoutBranchDiff.ts +191 -0
- package/src/git/checkoutBranches.ts +187 -0
- package/src/git/checkoutCommits.ts +223 -0
- package/src/git/checkoutSync.ts +179 -0
- package/src/git/readBranchDiff.ts +9 -10
- package/src/git/run.ts +55 -1
- package/src/git/worktreeRemoval.ts +316 -0
- package/src/git.ts +52 -1
- package/src/github/openPullRequests.ts +154 -0
- package/src/index.ts +9 -1
- package/src/config/defaultRepoPath.ts +0 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CheckoutHeadCommit,
|
|
3
|
+
CheckoutStatus,
|
|
4
|
+
CheckoutSyncResponse,
|
|
5
|
+
} from "../checkout/sync.ts";
|
|
6
|
+
import { pullRefusal, pushRefusal } from "../checkout/sync.ts";
|
|
7
|
+
import { git, gitOrNull } from "./run.ts";
|
|
8
|
+
|
|
9
|
+
export interface StatusV2 extends Omit<CheckoutStatus, "commit" | "path"> {
|
|
10
|
+
/** false on a branch with no commit yet (`branch.oid (initial)`) */
|
|
11
|
+
hasCommit: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const headerValue = (line: string, key: string): string | null =>
|
|
15
|
+
line.startsWith(`# ${key} `) ? line.slice(`# ${key} `.length) : null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `git status --porcelain=v2 --branch`: `# branch.*` headers, then one line per
|
|
19
|
+
* entry — `1`/`2` for a tracked change, `u` for an unmerged path, `?` for an
|
|
20
|
+
* untracked one (`!` only appears with `--ignored`). `branch.ab` is absent both
|
|
21
|
+
* without an upstream and when the upstream's ref is gone, which is why
|
|
22
|
+
* ahead/behind are null whenever it is.
|
|
23
|
+
*/
|
|
24
|
+
export const parseStatusV2 = (stdout: string): StatusV2 => {
|
|
25
|
+
const status: StatusV2 = {
|
|
26
|
+
branch: null,
|
|
27
|
+
hasCommit: true,
|
|
28
|
+
upstream: null,
|
|
29
|
+
ahead: null,
|
|
30
|
+
behind: null,
|
|
31
|
+
modified: 0,
|
|
32
|
+
untracked: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (const line of stdout.split("\n")) {
|
|
36
|
+
if (line.startsWith("# ")) {
|
|
37
|
+
const oid = headerValue(line, "branch.oid");
|
|
38
|
+
if (oid !== null) status.hasCommit = oid !== "(initial)";
|
|
39
|
+
const head = headerValue(line, "branch.head");
|
|
40
|
+
if (head !== null) status.branch = head === "(detached)" ? null : head;
|
|
41
|
+
const upstream = headerValue(line, "branch.upstream");
|
|
42
|
+
if (upstream !== null) status.upstream = upstream;
|
|
43
|
+
const ab = headerValue(line, "branch.ab");
|
|
44
|
+
const counts = ab === null ? null : /^\+(\d+) -(\d+)$/.exec(ab);
|
|
45
|
+
if (counts) {
|
|
46
|
+
status.ahead = Number(counts[1]);
|
|
47
|
+
status.behind = Number(counts[2]);
|
|
48
|
+
}
|
|
49
|
+
} else if (line.startsWith("? ")) {
|
|
50
|
+
status.untracked += 1;
|
|
51
|
+
} else if (/^[12u] /.test(line)) {
|
|
52
|
+
status.modified += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return status;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const readHeadCommit = async (
|
|
60
|
+
cwd: string,
|
|
61
|
+
): Promise<CheckoutHeadCommit | null> => {
|
|
62
|
+
const stdout = await gitOrNull(cwd, [
|
|
63
|
+
"log",
|
|
64
|
+
"-1",
|
|
65
|
+
"--format=%h%x00%s%x00%cI",
|
|
66
|
+
"--no-patch",
|
|
67
|
+
]);
|
|
68
|
+
const [shortSha, subject, committedAt] = (stdout?.trim() ?? "").split("\0");
|
|
69
|
+
if (!shortSha || subject === undefined || !committedAt) return null;
|
|
70
|
+
return {
|
|
71
|
+
shortSha,
|
|
72
|
+
subject,
|
|
73
|
+
committedAt: new Date(committedAt).toISOString(),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** One checkout's status: two git calls, read on request and never cached. */
|
|
78
|
+
export const readCheckoutStatus = async (
|
|
79
|
+
cwd: string,
|
|
80
|
+
): Promise<CheckoutStatus> => {
|
|
81
|
+
const { hasCommit, ...status } = parseStatusV2(
|
|
82
|
+
await git(cwd, ["status", "--porcelain=v2", "--branch"]),
|
|
83
|
+
);
|
|
84
|
+
return {
|
|
85
|
+
path: cwd,
|
|
86
|
+
...status,
|
|
87
|
+
commit: hasCommit ? await readHeadCommit(cwd) : null,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const messageOf = (error: unknown): string =>
|
|
92
|
+
error instanceof Error ? error.message : String(error);
|
|
93
|
+
|
|
94
|
+
const hasOriginRemote = async (cwd: string): Promise<boolean> =>
|
|
95
|
+
(await gitOrNull(cwd, ["remote", "get-url", "origin"])) !== null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether to fetch before reading. `always`: the caller wants behind counted
|
|
99
|
+
* against the remote as it is now. A sha: only when the upstream ref is not at
|
|
100
|
+
* it (a pull request's head as GitHub reported it), so reading costs no network
|
|
101
|
+
* call unless someone pushed. A branch with no upstream is never fetched: there
|
|
102
|
+
* is nothing it would pull.
|
|
103
|
+
*/
|
|
104
|
+
export type CheckoutFetch = "always" | "never" | { unlessUpstreamAt: string };
|
|
105
|
+
|
|
106
|
+
const fetchFirst = async (
|
|
107
|
+
cwd: string,
|
|
108
|
+
fetch: CheckoutFetch,
|
|
109
|
+
): Promise<string | null> => {
|
|
110
|
+
if (fetch === "never") return null;
|
|
111
|
+
const upstreamSha = await gitOrNull(cwd, [
|
|
112
|
+
"rev-parse",
|
|
113
|
+
"--verify",
|
|
114
|
+
"--quiet",
|
|
115
|
+
"@{upstream}",
|
|
116
|
+
]);
|
|
117
|
+
if (upstreamSha === null) return null;
|
|
118
|
+
if (fetch !== "always" && upstreamSha.trim() === fetch.unlessUpstreamAt) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await git(cwd, ["fetch", "--quiet"], { network: true });
|
|
123
|
+
return null;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return messageOf(error);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export interface ReadCheckoutSyncParams {
|
|
130
|
+
cwd: string;
|
|
131
|
+
/** `never` unless set */
|
|
132
|
+
fetch?: CheckoutFetch;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A failed fetch degrades: the status is then as of the previous one, and says why. */
|
|
136
|
+
export const readCheckoutSync = async ({
|
|
137
|
+
cwd,
|
|
138
|
+
fetch = "never",
|
|
139
|
+
}: ReadCheckoutSyncParams): Promise<CheckoutSyncResponse> => {
|
|
140
|
+
const fetchError = await fetchFirst(cwd, fetch);
|
|
141
|
+
const [status, hasOrigin] = await Promise.all([
|
|
142
|
+
readCheckoutStatus(cwd),
|
|
143
|
+
hasOriginRemote(cwd),
|
|
144
|
+
]);
|
|
145
|
+
return { status, hasOrigin, fetchError };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `--ff-only`: a pull that would need a merge or a rebase is a decision about
|
|
150
|
+
* someone's branch, and git refuses it with the reason. A dirty checkout is left
|
|
151
|
+
* to git too — it refuses only when an incoming change touches a modified file.
|
|
152
|
+
* The refusal a surface hides the button on is thrown first, with its reason.
|
|
153
|
+
*/
|
|
154
|
+
export const pullCheckout = async (cwd: string): Promise<void> => {
|
|
155
|
+
const refusal = pullRefusal(await readCheckoutStatus(cwd));
|
|
156
|
+
if (refusal !== null) throw new Error(refusal);
|
|
157
|
+
await git(cwd, ["pull", "--quiet", "--ff-only"], { network: true });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Never `--force`: a rejected non-fast-forward comes back as git's reason. A
|
|
162
|
+
* branch with no upstream is published to `origin` and starts tracking it, so
|
|
163
|
+
* the next push and the ahead count both work from the same place.
|
|
164
|
+
*/
|
|
165
|
+
export const pushCheckout = async (cwd: string): Promise<void> => {
|
|
166
|
+
const [status, hasOrigin] = await Promise.all([
|
|
167
|
+
readCheckoutStatus(cwd),
|
|
168
|
+
hasOriginRemote(cwd),
|
|
169
|
+
]);
|
|
170
|
+
const refusal = pushRefusal(status, hasOrigin);
|
|
171
|
+
if (refusal !== null) throw new Error(refusal);
|
|
172
|
+
await git(
|
|
173
|
+
cwd,
|
|
174
|
+
status.upstream === null && status.branch !== null
|
|
175
|
+
? ["push", "--quiet", "--set-upstream", "origin", status.branch]
|
|
176
|
+
: ["push", "--quiet"],
|
|
177
|
+
{ network: true },
|
|
178
|
+
);
|
|
179
|
+
};
|
|
@@ -33,7 +33,7 @@ export interface DiffLimits {
|
|
|
33
33
|
maxLinesPerFile: number;
|
|
34
34
|
maxTotalLines: number;
|
|
35
35
|
maxFiles: number;
|
|
36
|
-
/** untracked files read under the `
|
|
36
|
+
/** untracked files read under the `workingTree` scope; the rest are omitted */
|
|
37
37
|
maxUntrackedFiles: number;
|
|
38
38
|
/** an untracked file larger than this is omitted as too large */
|
|
39
39
|
maxUntrackedBytes: number;
|
|
@@ -289,12 +289,6 @@ const readUntrackedDiff = async (
|
|
|
289
289
|
return { patches, paths, omitted };
|
|
290
290
|
};
|
|
291
291
|
|
|
292
|
-
/**
|
|
293
|
-
* `committed`: the merge base to `head`. `all`: the merge base to the working
|
|
294
|
-
* tree, which adds the staged and unstaged changes and the untracked files.
|
|
295
|
-
*/
|
|
296
|
-
export type BranchDiffScope = "all" | "committed";
|
|
297
|
-
|
|
298
292
|
export interface BranchDiff {
|
|
299
293
|
patch: string;
|
|
300
294
|
/** the files in `patch`, in its order */
|
|
@@ -304,15 +298,20 @@ export interface BranchDiff {
|
|
|
304
298
|
untrackedCount: number;
|
|
305
299
|
}
|
|
306
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Where the diff ends: `committed` at a commit, `workingTree` at the files on
|
|
303
|
+
* disk, staged, unstaged and untracked changes included.
|
|
304
|
+
*/
|
|
305
|
+
export type BranchDiffEnd = "committed" | "workingTree";
|
|
306
|
+
|
|
307
307
|
interface ReadBranchDiffParams {
|
|
308
308
|
cwd: string;
|
|
309
309
|
mergeBase: string;
|
|
310
310
|
/** `committed` unless set: the pull request path reads no working tree */
|
|
311
|
-
scope?:
|
|
311
|
+
scope?: BranchDiffEnd;
|
|
312
312
|
/**
|
|
313
313
|
* The commit the `committed` scope ends at: a pull request's head, which no
|
|
314
|
-
* worktree need be on, or `HEAD` unless set. Ignored under `
|
|
315
|
-
* the working tree.
|
|
314
|
+
* worktree need be on, or `HEAD` unless set. Ignored under `workingTree`.
|
|
316
315
|
*/
|
|
317
316
|
head?: string;
|
|
318
317
|
/** `show` unless set */
|
package/src/git/run.ts
CHANGED
|
@@ -21,6 +21,21 @@ export interface GitOptions {
|
|
|
21
21
|
exitOneIsAnswer?: boolean;
|
|
22
22
|
/** kills git when the caller no longer wants the answer */
|
|
23
23
|
signal?: AbortSignal;
|
|
24
|
+
/**
|
|
25
|
+
* For a command that deletes files, which must never stop partway: killing
|
|
26
|
+
* `git worktree remove` leaves a directory with half its tracked files gone,
|
|
27
|
+
* which git then refuses to remove as dirty.
|
|
28
|
+
*
|
|
29
|
+
* - **No timeout**, and `maxBuffer`, `network` and `signal` are ignored. On a
|
|
30
|
+
* worktree with a populated `node_modules` it outlasts any read timeout.
|
|
31
|
+
* - **Its own process group**, so a signal sent to the caller's group (Ctrl-C
|
|
32
|
+
* in a terminal, a service manager stopping the job) does not reach it:
|
|
33
|
+
* past a kill of the caller it still finishes.
|
|
34
|
+
*
|
|
35
|
+
* A caller that must also run its own bookkeeping afterwards holds its
|
|
36
|
+
* shutdown until the promise settles.
|
|
37
|
+
*/
|
|
38
|
+
uninterruptible?: boolean;
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
/**
|
|
@@ -78,12 +93,50 @@ const isExitOneAnswer = (error: unknown): error is { stdout: string } =>
|
|
|
78
93
|
typeof error.stdout === "string" &&
|
|
79
94
|
error.stdout !== "";
|
|
80
95
|
|
|
96
|
+
/**
|
|
97
|
+
* `execFile` forwards a fixed list of options to `spawn`, and `detached` is not
|
|
98
|
+
* on it.
|
|
99
|
+
*/
|
|
100
|
+
const runUninterruptible = async (
|
|
101
|
+
cwd: string,
|
|
102
|
+
args: string[],
|
|
103
|
+
): Promise<string> =>
|
|
104
|
+
new Promise((resolve, reject) => {
|
|
105
|
+
const child = spawn("git", args, {
|
|
106
|
+
cwd,
|
|
107
|
+
env,
|
|
108
|
+
detached: true,
|
|
109
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
});
|
|
111
|
+
let stdout = "";
|
|
112
|
+
let stderr = "";
|
|
113
|
+
child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
|
|
114
|
+
stdout += chunk;
|
|
115
|
+
});
|
|
116
|
+
child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
|
|
117
|
+
stderr += chunk;
|
|
118
|
+
});
|
|
119
|
+
child.on("error", reject);
|
|
120
|
+
child.on("close", (code) => {
|
|
121
|
+
if (code === 0) {
|
|
122
|
+
resolve(stdout);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
reject(
|
|
126
|
+
Object.assign(new Error(`git exited with ${String(code)}`), {
|
|
127
|
+
stderr,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
81
133
|
/**
|
|
82
134
|
* git reads are on-demand: nothing here caches, and every call names the repository it ran in so a failure is actionable.
|
|
83
135
|
*
|
|
84
136
|
* `execFile` with an argument array and no shell, which is what makes a branch
|
|
85
137
|
* name safe to pass through: nothing here is ever interpolated into a command
|
|
86
|
-
* line.
|
|
138
|
+
* line.
|
|
139
|
+
*
|
|
87
140
|
* The message keeps only one stderr line on purpose (`reasonLine`): it is
|
|
88
141
|
* rendered to the user verbatim, and the rest is
|
|
89
142
|
* hints.
|
|
@@ -95,6 +148,7 @@ export const git = async (
|
|
|
95
148
|
): Promise<string> => {
|
|
96
149
|
const timeout = options.network ? networkTimeoutMs : timeoutMs;
|
|
97
150
|
try {
|
|
151
|
+
if (options.uninterruptible) return await runUninterruptible(cwd, args);
|
|
98
152
|
const { stdout } = await execFileAsync("git", args, {
|
|
99
153
|
cwd,
|
|
100
154
|
timeout,
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
5
|
+
import { pid } from "node:process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type {
|
|
8
|
+
WorktreeRemovalResult,
|
|
9
|
+
WorktreeRemovalStatus,
|
|
10
|
+
} from "../checkout/worktreeRemoval.ts";
|
|
11
|
+
import { worktreeRemovalRefusal } from "../checkout/worktreeRemoval.ts";
|
|
12
|
+
import { parseWorktreeList } from "./checkoutBranches.ts";
|
|
13
|
+
import { git, gitOrNull } from "./run.ts";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
const realpathOrSelf = (path: string): string => {
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync.native(path);
|
|
20
|
+
} catch {
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isInsideDirectory = (directory: string, path: string): boolean => {
|
|
26
|
+
const relativePath = relative(directory, path);
|
|
27
|
+
return (
|
|
28
|
+
relativePath === "" ||
|
|
29
|
+
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readGitDirectory = async (
|
|
34
|
+
worktree: string,
|
|
35
|
+
flag: "--git-common-dir" | "--git-dir",
|
|
36
|
+
): Promise<string> => {
|
|
37
|
+
const directory = await git(worktree, [
|
|
38
|
+
"rev-parse",
|
|
39
|
+
"--path-format=absolute",
|
|
40
|
+
flag,
|
|
41
|
+
]);
|
|
42
|
+
return realpathOrSelf(directory.trim());
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The branch read from git, never from a cached listing: it is what
|
|
47
|
+
* `git branch -d` is handed. `--abbrev-ref` answers the literal `HEAD` on a
|
|
48
|
+
* detached head, the one case with no branch to delete.
|
|
49
|
+
*/
|
|
50
|
+
const readBranch = async (worktree: string): Promise<string | null> => {
|
|
51
|
+
const branch = await gitOrNull(worktree, [
|
|
52
|
+
"rev-parse",
|
|
53
|
+
"--abbrev-ref",
|
|
54
|
+
"HEAD",
|
|
55
|
+
]);
|
|
56
|
+
const name = branch?.trim() ?? "";
|
|
57
|
+
return name === "" || name === "HEAD" ? null : name;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `??` is an untracked path, every other code a tracked change. Ignored files
|
|
62
|
+
* are absent without `--ignored`: `git worktree remove` ignores them too, so
|
|
63
|
+
* counting them would predict a refusal that never comes.
|
|
64
|
+
*/
|
|
65
|
+
const countChanges = async (
|
|
66
|
+
worktree: string,
|
|
67
|
+
): Promise<Pick<WorktreeRemovalStatus, "modified" | "untracked">> => {
|
|
68
|
+
const porcelain = await git(worktree, ["status", "--porcelain"]);
|
|
69
|
+
const lines = porcelain.split("\n").filter((line) => line.trim() !== "");
|
|
70
|
+
const untracked = lines.filter((line) => line.startsWith("??")).length;
|
|
71
|
+
return { modified: lines.length - untracked, untracked };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const hasCommit = async (worktree: string, sha: string): Promise<boolean> =>
|
|
75
|
+
(await gitOrNull(worktree, ["cat-file", "-e", `${sha}^{commit}`])) !== null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Commits on HEAD reachable from no remote-tracking ref and from no kept sha.
|
|
79
|
+
* Not `@{u}..HEAD`: that has no answer once the upstream is gone, the normal
|
|
80
|
+
* state of a merged branch after `fetch --prune`, and it ignores a commit
|
|
81
|
+
* already on another remote branch. A kept sha missing from the object store
|
|
82
|
+
* is left out, which only counts more as unpushed: the guard errs toward
|
|
83
|
+
* refusing.
|
|
84
|
+
*/
|
|
85
|
+
const countUnpushedCommits = async (
|
|
86
|
+
worktree: string,
|
|
87
|
+
keptShas: string[],
|
|
88
|
+
): Promise<number> => {
|
|
89
|
+
const present = await Promise.all(
|
|
90
|
+
keptShas
|
|
91
|
+
.filter((sha) => /^[\da-f]{7,64}$/i.test(sha))
|
|
92
|
+
.map(async (sha) => ((await hasCommit(worktree, sha)) ? sha : null)),
|
|
93
|
+
);
|
|
94
|
+
const count = await gitOrNull(worktree, [
|
|
95
|
+
"rev-list",
|
|
96
|
+
"--count",
|
|
97
|
+
"HEAD",
|
|
98
|
+
"--not",
|
|
99
|
+
"--remotes",
|
|
100
|
+
...present.filter((sha) => sha !== null),
|
|
101
|
+
]);
|
|
102
|
+
return count === null ? 0 : Number(count.trim());
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const readLock = async (worktree: string): Promise<string | null> => {
|
|
106
|
+
const listed = parseWorktreeList(
|
|
107
|
+
await git(worktree, ["worktree", "list", "--porcelain"]),
|
|
108
|
+
);
|
|
109
|
+
const own = listed.find(
|
|
110
|
+
({ path }) => realpathOrSelf(path) === realpathOrSelf(worktree),
|
|
111
|
+
);
|
|
112
|
+
return own?.lock ?? null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `lsof -d cwd` lists every process's working directory in one call. It exits
|
|
117
|
+
* 1 with a partial listing when some processes cannot be read, which is still
|
|
118
|
+
* an answer; without `lsof` at all nothing can be told, and null refuses
|
|
119
|
+
* nothing. This server's own process is not counted.
|
|
120
|
+
*/
|
|
121
|
+
export const countProcessesInside = async (
|
|
122
|
+
directory: string,
|
|
123
|
+
): Promise<number | null> => {
|
|
124
|
+
const stdout = await execFileAsync("lsof", ["-n", "-d", "cwd", "-F", "pn"], {
|
|
125
|
+
timeout: 10_000,
|
|
126
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
127
|
+
}).then(
|
|
128
|
+
({ stdout: answer }) => answer,
|
|
129
|
+
(error: unknown) => {
|
|
130
|
+
if (
|
|
131
|
+
typeof error === "object" &&
|
|
132
|
+
error !== null &&
|
|
133
|
+
"stdout" in error &&
|
|
134
|
+
typeof error.stdout === "string" &&
|
|
135
|
+
error.stdout !== ""
|
|
136
|
+
) {
|
|
137
|
+
return error.stdout;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (stdout === null) return null;
|
|
143
|
+
const root = realpathOrSelf(directory);
|
|
144
|
+
const inside = new Set<string>();
|
|
145
|
+
let processId = "";
|
|
146
|
+
for (const line of stdout.split("\n")) {
|
|
147
|
+
if (line.startsWith("p")) processId = line.slice(1);
|
|
148
|
+
else if (
|
|
149
|
+
line.startsWith("n") &&
|
|
150
|
+
processId !== String(pid) &&
|
|
151
|
+
isInsideDirectory(root, line.slice(1))
|
|
152
|
+
) {
|
|
153
|
+
inside.add(processId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return inside.size;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const ignoredPrefix = "!! ";
|
|
160
|
+
|
|
161
|
+
/** A directory git collapsed to one entry, named file by file. */
|
|
162
|
+
const listFiles = async (
|
|
163
|
+
worktree: string,
|
|
164
|
+
relativeDirectory: string,
|
|
165
|
+
): Promise<string[]> => {
|
|
166
|
+
const entries = await readdir(join(worktree, relativeDirectory), {
|
|
167
|
+
recursive: true,
|
|
168
|
+
withFileTypes: true,
|
|
169
|
+
}).catch(() => []);
|
|
170
|
+
return entries
|
|
171
|
+
.filter((entry) => entry.isFile())
|
|
172
|
+
.map((entry) => relative(worktree, join(entry.parentPath, entry.name)))
|
|
173
|
+
.toSorted();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compared by existence, not content: git collapses an ignored directory to one
|
|
178
|
+
* entry, and walking a `node_modules` to compare it is not a read to make on
|
|
179
|
+
* every confirmation. A symlink points at a file that survives, and a path the
|
|
180
|
+
* main checkout also has is regenerable or duplicated.
|
|
181
|
+
*/
|
|
182
|
+
const listLocalOnlyIgnored = async (
|
|
183
|
+
worktree: string,
|
|
184
|
+
mainCheckout: string,
|
|
185
|
+
): Promise<string[]> => {
|
|
186
|
+
const porcelain = await git(worktree, [
|
|
187
|
+
"status",
|
|
188
|
+
"--porcelain",
|
|
189
|
+
"--ignored",
|
|
190
|
+
"-z",
|
|
191
|
+
]);
|
|
192
|
+
const ignored = porcelain
|
|
193
|
+
.split("\0")
|
|
194
|
+
.filter((entry) => entry.startsWith(ignoredPrefix))
|
|
195
|
+
.map((entry) => entry.slice(ignoredPrefix.length));
|
|
196
|
+
const localOnly = await Promise.all(
|
|
197
|
+
ignored.map(async (relativePath): Promise<string[]> => {
|
|
198
|
+
// gone between the status and this read: nothing left to lose
|
|
199
|
+
const entry = await lstat(join(worktree, relativePath)).catch(() => null);
|
|
200
|
+
if (entry === null || entry.isSymbolicLink()) return [];
|
|
201
|
+
if (existsSync(join(mainCheckout, relativePath))) return [];
|
|
202
|
+
return entry.isDirectory()
|
|
203
|
+
? listFiles(worktree, relativePath)
|
|
204
|
+
: [relativePath];
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
return localOnly.flat();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export interface ReadWorktreeRemovalStatusParams {
|
|
211
|
+
/** the worktree, absolute */
|
|
212
|
+
worktree: string;
|
|
213
|
+
/**
|
|
214
|
+
* Commits known to be safe elsewhere although no remote-tracking ref holds
|
|
215
|
+
* them: the pull request heads GitHub keeps after deleting a head branch.
|
|
216
|
+
*/
|
|
217
|
+
keptShas?: string[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* What removing the worktree would lose, read from git and the disk alone.
|
|
222
|
+
* Advisory on a surface: `prepareWorktreeRemoval` reads it again and refuses.
|
|
223
|
+
*/
|
|
224
|
+
export const readWorktreeRemovalStatus = async ({
|
|
225
|
+
worktree,
|
|
226
|
+
keptShas = [],
|
|
227
|
+
}: ReadWorktreeRemovalStatusParams): Promise<WorktreeRemovalStatus> => {
|
|
228
|
+
const [gitDirectory, commonDirectory] = await Promise.all([
|
|
229
|
+
readGitDirectory(worktree, "--git-dir"),
|
|
230
|
+
readGitDirectory(worktree, "--git-common-dir"),
|
|
231
|
+
]);
|
|
232
|
+
const [branch, changes, unpushedCommits, lock, ignored] = await Promise.all([
|
|
233
|
+
readBranch(worktree),
|
|
234
|
+
countChanges(worktree),
|
|
235
|
+
countUnpushedCommits(worktree, keptShas),
|
|
236
|
+
readLock(worktree),
|
|
237
|
+
listLocalOnlyIgnored(worktree, dirname(commonDirectory)),
|
|
238
|
+
]);
|
|
239
|
+
// after the reads: the gits they run have their working directory inside too
|
|
240
|
+
const processesInside = await countProcessesInside(worktree);
|
|
241
|
+
return {
|
|
242
|
+
path: worktree,
|
|
243
|
+
main: gitDirectory === commonDirectory,
|
|
244
|
+
branch,
|
|
245
|
+
...changes,
|
|
246
|
+
unpushedCommits,
|
|
247
|
+
lock,
|
|
248
|
+
processesInside,
|
|
249
|
+
localOnlyIgnored: ignored,
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** A removal every refusal has passed: what `completeWorktreeRemoval` runs. */
|
|
254
|
+
export interface WorktreeRemovalPlan {
|
|
255
|
+
path: string;
|
|
256
|
+
/** where git runs from: the repository's `.git`, which outlives the worktree */
|
|
257
|
+
commonDirectory: string;
|
|
258
|
+
/** null when not asked for, or on a detached HEAD */
|
|
259
|
+
branch: string | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface PrepareWorktreeRemovalParams extends ReadWorktreeRemovalStatusParams {
|
|
263
|
+
/** run `git branch -d` on its branch after the removal */
|
|
264
|
+
deleteBranch: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reads the status now and throws the first refusal, before anything is
|
|
269
|
+
* deleted; a caller with guards of its own (a live session) runs them before
|
|
270
|
+
* this. Split from the removal so a caller can answer once every refusal has
|
|
271
|
+
* passed and remove after.
|
|
272
|
+
*/
|
|
273
|
+
export const prepareWorktreeRemoval = async ({
|
|
274
|
+
deleteBranch,
|
|
275
|
+
...params
|
|
276
|
+
}: PrepareWorktreeRemovalParams): Promise<WorktreeRemovalPlan> => {
|
|
277
|
+
const status = await readWorktreeRemovalStatus(params);
|
|
278
|
+
const refusal = worktreeRemovalRefusal(status);
|
|
279
|
+
if (refusal !== null) throw new Error(refusal);
|
|
280
|
+
return {
|
|
281
|
+
path: status.path,
|
|
282
|
+
commonDirectory: await readGitDirectory(
|
|
283
|
+
params.worktree,
|
|
284
|
+
"--git-common-dir",
|
|
285
|
+
),
|
|
286
|
+
branch: deleteBranch ? status.branch : null,
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const messageOf = (error: unknown): string =>
|
|
291
|
+
error instanceof Error ? error.message : String(error);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `git worktree remove` with no `--force`, then `git branch -d` (never `-D`,
|
|
295
|
+
* never a remote) when the plan carries a branch. The removal is
|
|
296
|
+
* `uninterruptible`: killed midway, it leaves half the tracked files gone and
|
|
297
|
+
* a worktree git then refuses as dirty, so neither a client leaving nor a
|
|
298
|
+
* signal to this process stops it. A refused branch deletion is reported in
|
|
299
|
+
* the result; the removal stands. A refused removal throws git's reason.
|
|
300
|
+
*/
|
|
301
|
+
export const completeWorktreeRemoval = async ({
|
|
302
|
+
path,
|
|
303
|
+
commonDirectory,
|
|
304
|
+
branch,
|
|
305
|
+
}: WorktreeRemovalPlan): Promise<WorktreeRemovalResult> => {
|
|
306
|
+
await git(commonDirectory, ["worktree", "remove", path], {
|
|
307
|
+
uninterruptible: true,
|
|
308
|
+
});
|
|
309
|
+
if (branch === null) return { path, branch, branchError: null };
|
|
310
|
+
try {
|
|
311
|
+
await git(commonDirectory, ["branch", "-d", branch]);
|
|
312
|
+
return { path, branch, branchError: null };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return { path, branch, branchError: messageOf(error) };
|
|
315
|
+
}
|
|
316
|
+
};
|
package/src/git.ts
CHANGED
|
@@ -9,12 +9,63 @@ export {
|
|
|
9
9
|
parseNumstat,
|
|
10
10
|
readBranchDiff,
|
|
11
11
|
} from "./git/readBranchDiff.ts";
|
|
12
|
+
export type { BranchDiffScope } from "./diff/types.ts";
|
|
12
13
|
export type {
|
|
13
14
|
BranchDiff,
|
|
14
|
-
|
|
15
|
+
BranchDiffEnd,
|
|
15
16
|
DiffLimits,
|
|
16
17
|
NumstatEntry,
|
|
17
18
|
} from "./git/readBranchDiff.ts";
|
|
19
|
+
export {
|
|
20
|
+
readCheckoutBranchDiff,
|
|
21
|
+
readDefaultBranch,
|
|
22
|
+
readHeadBranch,
|
|
23
|
+
readHeadSha,
|
|
24
|
+
resolveDiffBase,
|
|
25
|
+
} from "./git/checkoutBranchDiff.ts";
|
|
26
|
+
export type {
|
|
27
|
+
CheckoutBranchDiffParams,
|
|
28
|
+
ResolvedDiffBase,
|
|
29
|
+
} from "./git/checkoutBranchDiff.ts";
|
|
30
|
+
export {
|
|
31
|
+
defaultRecentCommits,
|
|
32
|
+
readCommitDiff,
|
|
33
|
+
readRecentCommits,
|
|
34
|
+
} from "./git/checkoutCommits.ts";
|
|
35
|
+
export type {
|
|
36
|
+
CommitDiffParams,
|
|
37
|
+
RecentCommitsParams,
|
|
38
|
+
} from "./git/checkoutCommits.ts";
|
|
39
|
+
export {
|
|
40
|
+
parseTrack,
|
|
41
|
+
parseWorktreeList,
|
|
42
|
+
readBranches,
|
|
43
|
+
readWorktrees,
|
|
44
|
+
} from "./git/checkoutBranches.ts";
|
|
45
|
+
export type { ReadBranchesParams } from "./git/checkoutBranches.ts";
|
|
46
|
+
export {
|
|
47
|
+
completeWorktreeRemoval,
|
|
48
|
+
countProcessesInside,
|
|
49
|
+
prepareWorktreeRemoval,
|
|
50
|
+
readWorktreeRemovalStatus,
|
|
51
|
+
} from "./git/worktreeRemoval.ts";
|
|
52
|
+
export type {
|
|
53
|
+
PrepareWorktreeRemovalParams,
|
|
54
|
+
ReadWorktreeRemovalStatusParams,
|
|
55
|
+
WorktreeRemovalPlan,
|
|
56
|
+
} from "./git/worktreeRemoval.ts";
|
|
57
|
+
export {
|
|
58
|
+
parseStatusV2,
|
|
59
|
+
pullCheckout,
|
|
60
|
+
pushCheckout,
|
|
61
|
+
readCheckoutStatus,
|
|
62
|
+
readCheckoutSync,
|
|
63
|
+
} from "./git/checkoutSync.ts";
|
|
64
|
+
export type {
|
|
65
|
+
CheckoutFetch,
|
|
66
|
+
ReadCheckoutSyncParams,
|
|
67
|
+
StatusV2,
|
|
68
|
+
} from "./git/checkoutSync.ts";
|
|
18
69
|
export {
|
|
19
70
|
groupDiffPaths,
|
|
20
71
|
noPackages,
|