@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,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
|
+
};
|