@reviewflow/sdk 6.24.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.
@@ -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
+ };
@@ -126,9 +126,9 @@ export const groupDiffPaths = (
126
126
  if (group === undefined) groups.set(name, [path]);
127
127
  else group.push(path);
128
128
  }
129
- // The root first: its files (a lockfile, the root manifest, a README) are
130
- // what the rest of the change is read against.
129
+ // The root last: its files (a lockfile, the root manifest, a README) follow
130
+ // from the packages' changes rather than explain them.
131
131
  return [...groups]
132
- .toSorted(([left], [right]) => Number(left !== "") - Number(right !== ""))
132
+ .toSorted(([left], [right]) => Number(left === "") - Number(right === ""))
133
133
  .map(([name, groupPaths]) => ({ name, paths: groupPaths }));
134
134
  };
@@ -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 `all` scope; the rest are omitted */
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?: BranchDiffScope;
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 `all`, which is
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
- BranchDiffScope,
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,