@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.
@@ -0,0 +1,191 @@
1
+ import type {
2
+ BranchDiffBase,
3
+ BranchDiffScope,
4
+ CheckoutBranchDiffResponse,
5
+ DiffWhitespace,
6
+ } from "../diff/types.ts";
7
+ import { groupDiffPaths, packagesOnDisk } from "./groupDiffPaths.ts";
8
+ import type { DiffLimits } from "./readBranchDiff.ts";
9
+ import { readBranchDiff } from "./readBranchDiff.ts";
10
+ import { git, gitOrNull } from "./run.ts";
11
+
12
+ /** null on a detached HEAD */
13
+ export const readHeadBranch = async (cwd: string): Promise<string | null> => {
14
+ const branch = await gitOrNull(cwd, [
15
+ "symbolic-ref",
16
+ "--quiet",
17
+ "--short",
18
+ "HEAD",
19
+ ]);
20
+ return branch === null || branch.trim() === "" ? null : branch.trim();
21
+ };
22
+
23
+ export const readHeadSha = async (
24
+ cwd: string,
25
+ ref = "HEAD",
26
+ ): Promise<string> => {
27
+ const sha = await git(cwd, ["rev-parse", ref]);
28
+ return sha.trim();
29
+ };
30
+
31
+ /**
32
+ * The branch `origin/HEAD` points at, as the last clone or
33
+ * `git remote set-head` recorded it; null when it was never recorded.
34
+ */
35
+ export const readDefaultBranch = async (
36
+ cwd: string,
37
+ ): Promise<string | null> => {
38
+ const ref = await gitOrNull(cwd, [
39
+ "symbolic-ref",
40
+ "--quiet",
41
+ "--short",
42
+ "refs/remotes/origin/HEAD",
43
+ ]);
44
+ const name = ref?.trim().replace(/^origin\//, "") ?? "";
45
+ return name === "" ? null : name;
46
+ };
47
+
48
+ const readCommit = async (cwd: string, ref: string): Promise<string | null> => {
49
+ const sha = await gitOrNull(cwd, [
50
+ "rev-parse",
51
+ "--verify",
52
+ "--quiet",
53
+ `${ref}^{commit}`,
54
+ ]);
55
+ return sha === null ? null : sha.trim();
56
+ };
57
+
58
+ /** Where a read of a checkout starts: its HEAD, or one of its local branches. */
59
+ export interface ReadHead {
60
+ ref: string;
61
+ /** null on a detached HEAD */
62
+ branch: string | null;
63
+ }
64
+
65
+ /**
66
+ * The checkout's HEAD when `branch` is absent, else `refs/heads/<branch>`,
67
+ * which must exist: the full ref keeps a name from being taken for an option
68
+ * or for another kind of revision.
69
+ */
70
+ export const resolveReadHead = async (
71
+ cwd: string,
72
+ branch: string | undefined,
73
+ ): Promise<ReadHead> => {
74
+ if (branch === undefined) {
75
+ return { ref: "HEAD", branch: await readHeadBranch(cwd) };
76
+ }
77
+ const ref = `refs/heads/${branch}`;
78
+ if ((await readCommit(cwd, ref)) === null) {
79
+ throw new Error(`no local branch ${branch} in ${cwd}`);
80
+ }
81
+ return { ref, branch };
82
+ };
83
+
84
+ export interface ResolvedDiffBase {
85
+ base: BranchDiffBase;
86
+ mergeBase: string;
87
+ }
88
+
89
+ /**
90
+ * `origin/<name>` before `<name>`: in a worktree setup the local base branch is
91
+ * whatever was last pulled in the main checkout, usually older than the last
92
+ * fetch. Nothing is fetched: a read that talks to the network on every open is
93
+ * not on-demand anymore, so the ref and its sha travel with the diff.
94
+ *
95
+ * The diff starts at the merge base, not at the base's tip: what landed on the
96
+ * base since the branch left it is not the branch's change.
97
+ */
98
+ export const resolveDiffBase = async (
99
+ cwd: string,
100
+ name: string,
101
+ head = "HEAD",
102
+ ): Promise<ResolvedDiffBase> => {
103
+ const candidates = [
104
+ { ref: `origin/${name}`, fullRef: `refs/remotes/origin/${name}` },
105
+ { ref: name, fullRef: `refs/heads/${name}` },
106
+ ];
107
+ for (const { ref, fullRef } of candidates) {
108
+ const sha = await readCommit(cwd, fullRef);
109
+ if (sha === null) continue;
110
+ const mergeBase = await gitOrNull(cwd, ["merge-base", sha, head]);
111
+ if (mergeBase === null) {
112
+ throw new Error(`${head} shares no history with ${ref}`);
113
+ }
114
+ return { base: { name, ref, sha }, mergeBase: mergeBase.trim() };
115
+ }
116
+ throw new Error(
117
+ `neither origin/${name} nor ${name} exists in ${cwd}: fetch the repository, or check its default branch`,
118
+ );
119
+ };
120
+
121
+ export interface CheckoutBranchDiffParams {
122
+ cwd: string;
123
+ /** absent: the branch `origin/HEAD` names, else `main` */
124
+ baseName?: string;
125
+ scope: BranchDiffScope;
126
+ /** a local branch to read instead of HEAD; `committed` only, it has no working tree here */
127
+ branch?: string;
128
+ whitespace?: DiffWhitespace;
129
+ limits?: DiffLimits;
130
+ }
131
+
132
+ interface ScopeStartParams {
133
+ cwd: string;
134
+ scope: BranchDiffScope;
135
+ baseName: string | undefined;
136
+ head: string;
137
+ }
138
+
139
+ const readScopeStart = async ({
140
+ cwd,
141
+ scope,
142
+ baseName,
143
+ head,
144
+ }: ScopeStartParams): Promise<ResolvedDiffBase | null> => {
145
+ if (scope === "uncommitted") return null;
146
+ const name = baseName ?? (await readDefaultBranch(cwd)) ?? "main";
147
+ return resolveDiffBase(cwd, name, head);
148
+ };
149
+
150
+ /**
151
+ * The checked-out branch, or `branch`, since it left its base (`committed`),
152
+ * or the working tree since `HEAD` (`uncommitted`), grouped by package. The
153
+ * packages are those on disk, the checked-out ones, even for another branch.
154
+ */
155
+ export const readCheckoutBranchDiff = async ({
156
+ cwd,
157
+ baseName,
158
+ scope,
159
+ branch: branchName,
160
+ whitespace = "show",
161
+ limits,
162
+ }: CheckoutBranchDiffParams): Promise<CheckoutBranchDiffResponse> => {
163
+ if (branchName !== undefined && scope === "uncommitted") {
164
+ throw new Error(
165
+ `${branchName} is not checked out here: it has nothing uncommitted`,
166
+ );
167
+ }
168
+ const head = await resolveReadHead(cwd, branchName);
169
+ const [start, headSha] = await Promise.all([
170
+ readScopeStart({ cwd, scope, baseName, head: head.ref }),
171
+ readHeadSha(cwd, head.ref),
172
+ ]);
173
+ const { paths, ...diff } = await readBranchDiff({
174
+ cwd,
175
+ mergeBase: start?.mergeBase ?? headSha,
176
+ head: head.ref,
177
+ scope: scope === "uncommitted" ? "workingTree" : "committed",
178
+ whitespace,
179
+ limits,
180
+ });
181
+ return {
182
+ scope,
183
+ whitespace,
184
+ branch: head.branch,
185
+ headSha,
186
+ base: start?.base ?? null,
187
+ mergeBase: start?.mergeBase ?? null,
188
+ ...diff,
189
+ groups: groupDiffPaths(packagesOnDisk(cwd), paths),
190
+ };
191
+ };
@@ -0,0 +1,187 @@
1
+ import type {
2
+ CheckoutBranch,
3
+ CheckoutBranchesResponse,
4
+ CheckoutWorktree,
5
+ } from "../checkout/overview.ts";
6
+ import type { ResolvedDiffBase } from "./checkoutBranchDiff.ts";
7
+ import { readDefaultBranch, resolveDiffBase } from "./checkoutBranchDiff.ts";
8
+ import { readCheckoutStatus } from "./checkoutSync.ts";
9
+ import { git, gitOrNull } from "./run.ts";
10
+
11
+ interface ListedWorktree {
12
+ path: string;
13
+ branch: string | null;
14
+ headSha: string | null;
15
+ bare: boolean;
16
+ /** why it is locked, `""` when no reason was given; null when it is not */
17
+ lock: string | null;
18
+ prunable: boolean;
19
+ }
20
+
21
+ const zeroSha = /^0+$/;
22
+
23
+ /**
24
+ * `git worktree list --porcelain`: one block per worktree, blocks separated by
25
+ * an empty line, each opening with `worktree <path>`; `HEAD`, `branch`,
26
+ * `detached`, `bare`, `locked` and `prunable` follow. A worktree on a branch
27
+ * with no commit yet has an all-zero `HEAD`.
28
+ */
29
+ export const parseWorktreeList = (output: string): ListedWorktree[] =>
30
+ output
31
+ .split(/\n{2,}/)
32
+ .map((block) => block.split("\n").filter((line) => line !== ""))
33
+ .filter((lines) => lines[0]?.startsWith("worktree ") === true)
34
+ .map((lines) => {
35
+ const value = (key: string): string | null => {
36
+ const line = lines.find(
37
+ (candidate) => candidate === key || candidate.startsWith(`${key} `),
38
+ );
39
+ return line === undefined ? null : line.slice(key.length + 1);
40
+ };
41
+ const head = value("HEAD");
42
+ const branch = value("branch");
43
+ return {
44
+ path: value("worktree") ?? "",
45
+ branch: branch === null ? null : branch.replace(/^refs\/heads\//, ""),
46
+ headSha: head === null || zeroSha.test(head) ? null : head,
47
+ bare: value("bare") !== null,
48
+ lock: value("locked"),
49
+ prunable: value("prunable") !== null,
50
+ };
51
+ });
52
+
53
+ /**
54
+ * Every worktree of the repository `cwd` belongs to, the main one first, each
55
+ * with its status; a bare repository's own entry is left out, having no files.
56
+ */
57
+ export const readWorktrees = async (
58
+ cwd: string,
59
+ ): Promise<CheckoutWorktree[]> => {
60
+ const listed = parseWorktreeList(
61
+ await git(cwd, ["worktree", "list", "--porcelain"]),
62
+ );
63
+ return Promise.all(
64
+ listed
65
+ .map((worktree, index) => ({ ...worktree, main: index === 0 }))
66
+ .filter(({ bare }) => !bare)
67
+ .map(async ({ bare: _bare, ...worktree }) => ({
68
+ ...worktree,
69
+ status: worktree.prunable
70
+ ? null
71
+ : await readCheckoutStatus(worktree.path).catch(() => null),
72
+ })),
73
+ );
74
+ };
75
+
76
+ const fieldSeparator = "\u0000";
77
+ const branchFormat = [
78
+ "%(refname:lstrip=2)",
79
+ "%(objectname)",
80
+ "%(upstream:short)",
81
+ "%(upstream:track)",
82
+ "%(committerdate:iso-strict)",
83
+ "%(worktreepath)",
84
+ "%(subject)",
85
+ ].join("%00");
86
+
87
+ interface Track {
88
+ ahead: number | null;
89
+ behind: number | null;
90
+ gone: boolean;
91
+ }
92
+
93
+ /**
94
+ * `%(upstream:track)`: `[ahead 2, behind 1]`, `[gone]`, or empty both in sync
95
+ * and without an upstream, which `hasUpstream` tells apart.
96
+ */
97
+ export const parseTrack = (track: string, hasUpstream: boolean): Track => {
98
+ if (!hasUpstream) return { ahead: null, behind: null, gone: false };
99
+ if (track === "[gone]") return { ahead: null, behind: null, gone: true };
100
+ const count = (word: string): number =>
101
+ Number(new RegExp(`${word} (\\d+)`).exec(track)?.[1] ?? 0);
102
+ return { ahead: count("ahead"), behind: count("behind"), gone: false };
103
+ };
104
+
105
+ const readMergedBranches = async (
106
+ cwd: string,
107
+ start: ResolvedDiffBase | null,
108
+ ): Promise<Set<string>> => {
109
+ if (start === null) return new Set();
110
+ const listed = await git(cwd, [
111
+ "for-each-ref",
112
+ `--merged=${start.base.sha}`,
113
+ "--format=%(refname:lstrip=2)",
114
+ "refs/heads",
115
+ ]);
116
+ return new Set(
117
+ listed
118
+ .split("\n")
119
+ .filter((name) => name !== "" && name !== start.base.name),
120
+ );
121
+ };
122
+
123
+ const readBase = async (
124
+ cwd: string,
125
+ baseName: string | undefined,
126
+ ): Promise<ResolvedDiffBase | null> => {
127
+ const name = baseName ?? (await readDefaultBranch(cwd)) ?? "main";
128
+ return resolveDiffBase(cwd, name).catch(() => null);
129
+ };
130
+
131
+ export interface ReadBranchesParams {
132
+ cwd: string;
133
+ /** absent: the branch `origin/HEAD` names, else `main` */
134
+ baseName?: string;
135
+ }
136
+
137
+ /**
138
+ * The local branches, most recently committed first, and the worktrees they
139
+ * are checked out in. Nothing is fetched: ahead, behind and gone are as of the
140
+ * last fetch.
141
+ */
142
+ export const readBranches = async ({
143
+ cwd,
144
+ baseName,
145
+ }: ReadBranchesParams): Promise<CheckoutBranchesResponse> => {
146
+ const start = await readBase(cwd, baseName);
147
+ const [listing, merged, worktrees] = await Promise.all([
148
+ gitOrNull(cwd, [
149
+ "for-each-ref",
150
+ "--sort=-committerdate",
151
+ `--format=${branchFormat}`,
152
+ "refs/heads",
153
+ ]),
154
+ readMergedBranches(cwd, start),
155
+ readWorktrees(cwd),
156
+ ]);
157
+ const branches = (listing ?? "")
158
+ .split("\n")
159
+ .filter((line) => line !== "")
160
+ .map((line): CheckoutBranch => {
161
+ const [
162
+ name = "",
163
+ sha = "",
164
+ upstream = "",
165
+ track = "",
166
+ committedAt = "",
167
+ worktreePath = "",
168
+ subject = "",
169
+ ] = line.split(fieldSeparator);
170
+ const { ahead, behind, gone } = parseTrack(track, upstream !== "");
171
+ return {
172
+ name,
173
+ sha,
174
+ upstream: upstream === "" ? null : upstream,
175
+ ahead,
176
+ behind,
177
+ upstreamGone: gone,
178
+ merged: merged.has(name),
179
+ worktreePath: worktreePath === "" ? null : worktreePath,
180
+ lastCommit: {
181
+ subject,
182
+ committedAt: new Date(committedAt).toISOString(),
183
+ },
184
+ };
185
+ });
186
+ return { base: start?.base ?? null, worktrees, branches };
187
+ };
@@ -0,0 +1,223 @@
1
+ import type {
2
+ BranchDiffBase,
3
+ CheckoutCommit,
4
+ CheckoutCommitsResponse,
5
+ CommitDiffResponse,
6
+ DiffWhitespace,
7
+ } from "../diff/types.ts";
8
+ import {
9
+ readDefaultBranch,
10
+ resolveDiffBase,
11
+ resolveReadHead,
12
+ } from "./checkoutBranchDiff.ts";
13
+ import { groupDiffPaths, packagesOnDisk } from "./groupDiffPaths.ts";
14
+ import type { DiffLimits } from "./readBranchDiff.ts";
15
+ import { readBranchDiff } from "./readBranchDiff.ts";
16
+ import { git, gitOrNull } from "./run.ts";
17
+
18
+ export const defaultRecentCommits = 30;
19
+
20
+ const fieldSeparator = "\u001F";
21
+ const recordSeparator = "\u001E";
22
+ const logFormat = ["%H", "%P", "%an", "%cI", "%s", "%b"].join("%x1f");
23
+
24
+ interface LoggedCommit {
25
+ sha: string;
26
+ parents: string[];
27
+ author: string;
28
+ committedAt: string;
29
+ headline: string;
30
+ body: string;
31
+ }
32
+
33
+ const parseLog = (output: string): LoggedCommit[] =>
34
+ output
35
+ .split(recordSeparator)
36
+ .map((record) => record.replace(/^\n/, ""))
37
+ .filter((record) => record !== "")
38
+ .map((record) => {
39
+ const [
40
+ sha = "",
41
+ parents = "",
42
+ author = "",
43
+ committedAt = "",
44
+ headline = "",
45
+ body = "",
46
+ ] = record.split(fieldSeparator);
47
+ return {
48
+ sha,
49
+ parents: parents === "" ? [] : parents.split(" "),
50
+ author,
51
+ committedAt,
52
+ headline,
53
+ body: body.trim(),
54
+ };
55
+ });
56
+
57
+ interface BranchStart {
58
+ base: BranchDiffBase;
59
+ /** the branch's commits among the listed ones: its head has them, the base has not */
60
+ onBranch: Set<string>;
61
+ /** every commit of the branch, listed or not */
62
+ count: number;
63
+ }
64
+
65
+ interface BranchStartParams {
66
+ cwd: string;
67
+ baseName: string | undefined;
68
+ limit: number;
69
+ head: string;
70
+ }
71
+
72
+ /**
73
+ * Null when the base cannot be resolved: nothing is then marked on the branch.
74
+ * The range keeps `git log`'s order, so its first `limit` commits hold every
75
+ * listed one it has.
76
+ */
77
+ const readBranchStart = async ({
78
+ cwd,
79
+ baseName,
80
+ limit,
81
+ head,
82
+ }: BranchStartParams): Promise<BranchStart | null> => {
83
+ const name = baseName ?? (await readDefaultBranch(cwd)) ?? "main";
84
+ try {
85
+ const { base, mergeBase } = await resolveDiffBase(cwd, name, head);
86
+ const range = `${mergeBase}..${head}`;
87
+ const [listed, count] = await Promise.all([
88
+ git(cwd, ["rev-list", `--max-count=${String(limit)}`, range]),
89
+ git(cwd, ["rev-list", "--count", range]),
90
+ ]);
91
+ return {
92
+ base,
93
+ onBranch: new Set(listed.split("\n").filter((sha) => sha !== "")),
94
+ count: Number(count.trim()),
95
+ };
96
+ } catch {
97
+ return null;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * The commits among the head's last `limit` that no remote-tracking ref
103
+ * reaches: `--not --remotes` keeps rev-list's order, so the ones in the log are
104
+ * all in its first `limit`. Null when the checkout has no remote, where every
105
+ * commit would read as not pushed.
106
+ */
107
+ const readUnpushed = async (
108
+ cwd: string,
109
+ limit: number,
110
+ head: string,
111
+ ): Promise<Set<string> | null> => {
112
+ const remotes = await git(cwd, ["remote"]);
113
+ if (remotes.trim() === "") return null;
114
+ const listed = await gitOrNull(cwd, [
115
+ "rev-list",
116
+ `--max-count=${String(limit)}`,
117
+ head,
118
+ "--not",
119
+ "--remotes",
120
+ ]);
121
+ return new Set((listed ?? "").split("\n").filter((sha) => sha !== ""));
122
+ };
123
+
124
+ export interface RecentCommitsParams {
125
+ cwd: string;
126
+ /** `defaultRecentCommits` unless set */
127
+ limit?: number;
128
+ /** absent: the branch `origin/HEAD` names, else `main` */
129
+ baseName?: string;
130
+ /** a local branch to read instead of HEAD */
131
+ branch?: string;
132
+ }
133
+
134
+ /**
135
+ * The last commits of HEAD, or of `branch`, newest first, each marked when the
136
+ * branch carries it since it left its base, and when it is pushed. A
137
+ * repository without a commit has none.
138
+ */
139
+ export const readRecentCommits = async ({
140
+ cwd,
141
+ limit = defaultRecentCommits,
142
+ baseName,
143
+ branch: branchName,
144
+ }: RecentCommitsParams): Promise<CheckoutCommitsResponse> => {
145
+ const head = await resolveReadHead(cwd, branchName);
146
+ const [start, unpushed, log] = await Promise.all([
147
+ readBranchStart({ cwd, baseName, limit, head: head.ref }),
148
+ readUnpushed(cwd, limit, head.ref),
149
+ gitOrNull(cwd, [
150
+ "log",
151
+ "--no-color",
152
+ `--max-count=${String(limit)}`,
153
+ `--format=${logFormat}%x1e`,
154
+ head.ref,
155
+ ]),
156
+ ]);
157
+ const { branch } = head;
158
+ const commits = parseLog(log ?? "").map((commit): CheckoutCommit => ({
159
+ oid: commit.sha,
160
+ parentOid: commit.parents[0] ?? null,
161
+ merge: commit.parents.length > 1,
162
+ headline: commit.headline,
163
+ body: commit.body,
164
+ author: { login: commit.author, avatarUrl: null },
165
+ committedAt: commit.committedAt,
166
+ onBranch: start?.onBranch.has(commit.sha) ?? false,
167
+ pushed: unpushed === null ? null : !unpushed.has(commit.sha),
168
+ }));
169
+ return {
170
+ branch,
171
+ base: start?.base ?? null,
172
+ onBranchCount: start?.count ?? null,
173
+ commits,
174
+ };
175
+ };
176
+
177
+ /** The empty tree, in whichever object format the repository uses. */
178
+ const emptyTree = async (cwd: string): Promise<string> => {
179
+ const tree = await git(cwd, ["hash-object", "-t", "tree", "/dev/null"]);
180
+ return tree.trim();
181
+ };
182
+
183
+ export interface CommitDiffParams {
184
+ cwd: string;
185
+ sha: string;
186
+ whitespace?: DiffWhitespace;
187
+ limits?: DiffLimits;
188
+ }
189
+
190
+ /**
191
+ * One commit against its first parent, a merge included (its diff then
192
+ * carries what was merged in); a root commit against the empty tree.
193
+ */
194
+ export const readCommitDiff = async ({
195
+ cwd,
196
+ sha,
197
+ whitespace = "show",
198
+ limits,
199
+ }: CommitDiffParams): Promise<CommitDiffResponse> => {
200
+ const listed = await git(cwd, [
201
+ "rev-list",
202
+ "--parents",
203
+ "--max-count=1",
204
+ `${sha}^{commit}`,
205
+ ]);
206
+ const [commit = sha, parentSha = null] = listed.trim().split(" ");
207
+ const { paths, patch, omitted } = await readBranchDiff({
208
+ cwd,
209
+ mergeBase: parentSha ?? (await emptyTree(cwd)),
210
+ head: commit,
211
+ scope: "committed",
212
+ whitespace,
213
+ limits,
214
+ });
215
+ return {
216
+ sha: commit,
217
+ parentSha,
218
+ whitespace,
219
+ patch,
220
+ omitted,
221
+ groups: groupDiffPaths(packagesOnDisk(cwd), paths),
222
+ };
223
+ };