1import {
2 JjDescriptionConflictError,
3 JjError,
4 type JjChange,
5 type JjChangeInput,
6 type JjDescribeInput,
7 type JjDiffInput,
8 type JjDiffResult,
9 type JjPushInput,
10 type JjPushResult,
11 type JjStack,
12 type JjStackInput,
13 type ReviewDiffFileStat,
14} from "@t3tools/contracts";
15import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git";
16import * as Context from "effect/Context";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import { VcsProcess } from "./VcsProcess.ts";
26
27/** Ancestors down to trunk plus anything above `@`; sibling lines that share a base stay out. */
28const STACK_REVSET = "(::@ | @::) ~ ::trunk() ~ (empty() ~ @)";
29const STACK_LIMIT = 60;
30const MAX_DIFF_BYTES = 4_000_000;
31
32const STACK_TEMPLATE = [
33 '"{\\"changeId\\":" ++ json(change_id)',
34 '",\\"shortId\\":" ++ json(stringify(change_id.shortest(4)))',
35 '",\\"commitId\\":" ++ json(commit_id)',
36 '",\\"parentCommitIds\\":[" ++ parents.map(|p| json(p.commit_id())).join(",") ++ "]"',
37 '",\\"description\\":" ++ json(description)',
38 '",\\"trailers\\":[" ++ trailers.map(|t| "{\\"key\\":" ++ json(t.key()) ++ ",\\"value\\":" ++ json(t.value()) ++ "}").join(",") ++ "]"',
39 '",\\"localBookmarks\\":[" ++ local_bookmarks.map(|b| json(b.name())).join(",") ++ "]"',
40 '",\\"remoteBookmarks\\":[" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| json(b.name())).join(",") ++ "]"',
41 '",\\"immutable\\":" ++ json(immutable)',
42 '",\\"conflict\\":" ++ json(conflict)',
43 '",\\"empty\\":" ++ json(empty)',
44 '",\\"workingCopy\\":" ++ json(current_working_copy)',
45 '",\\"additions\\":" ++ json(diff.stat().total_added())',
46 '",\\"deletions\\":" ++ json(diff.stat().total_removed())',
47 '",\\"authoredAt\\":" ++ json(stringify(author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z"))) ++ "}\\n"',
48].join(" ++ ");
49
50const RawChange = Schema.Struct({
51 changeId: Schema.String,
52 shortId: Schema.String,
53 commitId: Schema.String,
54 parentCommitIds: Schema.Array(Schema.String),
55 description: Schema.String,
56 trailers: Schema.Array(Schema.Struct({ key: Schema.String, value: Schema.String })),
57 localBookmarks: Schema.Array(Schema.String),
58 remoteBookmarks: Schema.Array(Schema.String),
59 immutable: Schema.Boolean,
60 conflict: Schema.Boolean,
61 empty: Schema.Boolean,
62 workingCopy: Schema.Boolean,
63 additions: Schema.Number,
64 deletions: Schema.Number,
65 authoredAt: Schema.String,
66});
67const decodeRawChange = Schema.decodeUnknownSync(Schema.fromJsonString(RawChange));
68
69function toChange(line: string): JjChange {
70 const raw = decodeRawChange(line);
71 return {
72 changeId: raw.changeId,
73 shortId: raw.shortId,
74 commitId: raw.commitId,
75 parentCommitIds: raw.parentCommitIds,
76 description: raw.description.replace(/\n+$/, ""),
77 trailers: raw.trailers,
78 bookmarks: raw.localBookmarks.map((name) => ({
79 name,
80 pushed: raw.remoteBookmarks.includes(name),
81 })),
82 immutable: raw.immutable,
83 conflict: raw.conflict,
84 empty: raw.empty,
85 workingCopy: raw.workingCopy,
86 additions: raw.additions,
87 deletions: raw.deletions,
88 authoredAt: raw.authoredAt,
89 };
90}
91
92/** Per-file statistics from `jj diff --git`, so the client can list files before rendering patches. */
93export function gitDiffFileStats(diff: string): ReviewDiffFileStat[] {
94 const files: ReviewDiffFileStat[] = [];
95 let current: {
96 path: string;
97 previousPath: string | null;
98 additions: number;
99 deletions: number;
100 } | null = null;
101 for (const line of diff.split("\n")) {
102 const header = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
103 if (header) {
104 if (current) files.push(current);
105 current = { path: header[2]!, previousPath: null, additions: 0, deletions: 0 };
106 continue;
107 }
108 if (!current) continue;
109 if (line.startsWith("rename from ")) current.previousPath = line.slice("rename from ".length);
110 else if (line.startsWith("+") && !line.startsWith("+++ ")) current.additions++;
111 else if (line.startsWith("-") && !line.startsWith("--- ")) current.deletions++;
112 }
113 if (current) files.push(current);
114 return files;
115}
116
117/** `owner/name` of the named remote in `jj git remote list` output, when it is on GitHub. */
118export function githubRepository(remoteList: string, remote: string): string | null {
119 for (const line of remoteList.split("\n")) {
120 const [name, url] = line.trim().split(/\s+/);
121 if (name !== remote || !url) continue;
122 return parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url);
123 }
124 return null;
125}
126
127export class JjStackService extends Context.Service<
128 JjStackService,
129 {
130 readonly watchStack: (input: JjStackInput) => Stream.Stream<JjStack | null, JjError>;
131 readonly snapshot: (input: JjStackInput) => Effect.Effect<void, JjError>;
132 readonly diff: (input: JjDiffInput) => Effect.Effect<JjDiffResult, JjError>;
133 readonly describe: (
134 input: JjDescribeInput,
135 ) => Effect.Effect<{ readonly commitId: string }, JjError | JjDescriptionConflictError>;
136 readonly squash: (input: JjChangeInput) => Effect.Effect<void, JjError>;
137 readonly push: (input: JjPushInput) => Effect.Effect<JjPushResult, JjError>;
138 }
139>()("t3/vcs/JjStackService") {}
140
141const make = Effect.gen(function* () {
142 const vcs = yield* VcsProcess;
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145
146 const jj = (
147 operation: string,
148 cwd: string,
149 args: ReadonlyArray<string>,
150 options: {
151 readonly stdin?: string;
152 readonly timeoutMs?: number;
153 readonly maxOutputBytes?: number;
154 } = {},
155 ) =>
156 vcs
157 .run({
158 operation: `JjStackService.${operation}`,
159 command: "jj",
160 args: ["--no-pager", "--color=never", ...args],
161 cwd,
162 ...options,
163 })
164 .pipe(Effect.mapError((error) => new JjError({ operation, detail: error.message })));
165
166 const workspaceRoot = (cwd: string) =>
167 jj("root", cwd, ["--ignore-working-copy", "root"]).pipe(
168 Effect.map((output) => output.stdout.trim() || null),
169 Effect.orElseSucceed(() => null),
170 );
171
172 const readStack = Effect.fn("JjStackService.readStack")(function* (root: string) {
173 const [changes, trunk, behind, remotes, pushRemote] = yield* Effect.all(
174 [
175 jj("log", root, [
176 "--ignore-working-copy",
177 "log",
178 "--no-graph",
179 "--limit",
180 String(STACK_LIMIT),
181 "-r",
182 STACK_REVSET,
183 "-T",
184 STACK_TEMPLATE,
185 ]),
186 jj("trunk", root, [
187 "--ignore-working-copy",
188 "log",
189 "--no-graph",
190 "-r",
191 "trunk()",
192 "-T",
193 'commit_id ++ "\\t" ++ remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "@" ++ b.remote())).join(",") ++ "\\n"',
194 ]),
195 jj("behind", root, [
196 "--ignore-working-copy",
197 "log",
198 "--no-graph",
199 "-r",
200 "::trunk() ~ ::@",
201 "-T",
202 '"."',
203 ]),
204 jj("remotes", root, ["--ignore-working-copy", "git", "remote", "list"]),
205 jj("push-remote", root, ["--ignore-working-copy", "config", "get", "git.push"]).pipe(
206 Effect.map((output) => output.stdout.trim()),
207 Effect.orElseSucceed(() => ""),
208 ),
209 ],
210 { concurrency: "unbounded" },
211 );
212 const [trunkCommitId, trunkNames] = trunk.stdout.trim().split("\t");
213 return {
214 workspaceRoot: root,
215 repository: githubRepository(remotes.stdout, pushRemote || "origin"),
216 trunk: trunkCommitId
217 ? {
218 name: trunkNames?.split(",")[0] || "trunk()",
219 commitId: trunkCommitId,
220 behind: behind.stdout.trim().length,
221 }
222 : null,
223 changes: changes.stdout
224 .split("\n")
225 .filter((line) => line.trim())
226 .map(toChange),
227 } satisfies JjStack;
228 });
229
230 // Every jj operation, from any workspace of the repo, adds a file under op_heads/heads.
231 const opHeadsDirectory = Effect.fn("JjStackService.opHeadsDirectory")(function* (root: string) {
232 const repoPath = path.join(root, ".jj", "repo");
233 const info = yield* fs.stat(repoPath);
234 const repo =
235 info.type === "File"
236 ? path.resolve(path.join(root, ".jj"), (yield* fs.readFileString(repoPath)).trim())
237 : repoPath;
238 return path.join(repo, "op_heads", "heads");
239 });
240
241 const watchStack = (input: JjStackInput) =>
242 Stream.unwrap(
243 Effect.gen(function* () {
244 const root = yield* workspaceRoot(input.cwd);
245 if (!root) return Stream.make(null);
246 const heads = yield* opHeadsDirectory(root).pipe(
247 Effect.mapError((error) => new JjError({ operation: "watch", detail: String(error) })),
248 );
249 const changes = fs.watch(heads).pipe(
250 Stream.debounce(Duration.millis(150)),
251 Stream.catch(() => Stream.empty),
252 );
253 let previous = "";
254 return Stream.concat(Stream.make(undefined), changes).pipe(
255 Stream.mapEffect(() => readStack(root)),
256 Stream.filter((stack) => {
257 const key = JSON.stringify(stack);
258 if (key === previous) return false;
259 previous = key;
260 return true;
261 }),
262 );
263 }),
264 );
265
266 const snapshot = (input: JjStackInput) =>
267 jj("snapshot", input.cwd, ["util", "snapshot"]).pipe(Effect.asVoid);
268
269 const diff = Effect.fn("JjStackService.diff")(function* (input: JjDiffInput) {
270 const output = yield* jj(
271 "diff",
272 input.cwd,
273 [
274 "--ignore-working-copy",
275 "diff",
276 "--git",
277 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
278 "--from",
279 input.fromCommitId,
280 "--to",
281 input.toCommitId,
282 ],
283 { maxOutputBytes: MAX_DIFF_BYTES },
284 );
285 const range = `${input.fromCommitId}..${input.toCommitId}`;
286 return {
287 id: range,
288 kind: "branch-range",
289 title: "Changes",
290 baseRef: input.fromCommitId,
291 headRef: input.toCommitId,
292 diff: output.stdout,
293 // Commit ids are content addresses, so the range alone identifies the diff.
294 diffHash: `${range}${input.ignoreWhitespace ? ":w" : ""}`,
295 truncated: output.stdoutTruncated,
296 files: gitDiffFileStats(output.stdout),
297 } satisfies JjDiffResult;
298 });
299
300 const readCommit = (cwd: string, revision: string) =>
301 jj("show-commit", cwd, [
302 "--ignore-working-copy",
303 "log",
304 "--no-graph",
305 "-r",
306 revision,
307 "-T",
308 'commit_id ++ "\\n" ++ description',
309 ]).pipe(
310 Effect.map((output) => {
311 const newline = output.stdout.indexOf("\n");
312 return {
313 commitId: output.stdout.slice(0, newline),
314 description: output.stdout.slice(newline + 1).replace(/\n+$/, ""),
315 };
316 }),
317 );
318
319 const describe = Effect.fn("JjStackService.describe")(function* (input: JjDescribeInput) {
320 const [current, loaded] = yield* Effect.all([
321 readCommit(input.cwd, input.changeId),
322 readCommit(input.cwd, input.expectedCommitId),
323 ]);
324 if (current.commitId !== input.expectedCommitId && current.description !== loaded.description) {
325 return yield* new JjDescriptionConflictError({
326 changeId: input.changeId,
327 description: current.description,
328 commitId: current.commitId,
329 });
330 }
331 if (current.description !== input.message) {
332 yield* jj(
333 "describe",
334 input.cwd,
335 ["--ignore-working-copy", "describe", "-r", input.changeId, "--stdin"],
336 { stdin: input.message },
337 );
338 }
339 return { commitId: (yield* readCommit(input.cwd, input.changeId)).commitId };
340 });
341
342 const squash = (input: JjChangeInput) =>
343 jj("squash", input.cwd, ["squash", "-r", input.changeId, "--use-destination-message"]).pipe(
344 Effect.asVoid,
345 );
346
347 const push = Effect.fn("JjStackService.push")(function* (input: JjPushInput) {
348 const network = { timeoutMs: 120_000 };
349 let bookmark = input.bookmark;
350 if (bookmark) {
351 yield* jj("bookmark-move", input.cwd, ["bookmark", "move", bookmark, "--to", input.changeId]);
352 yield* jj("push", input.cwd, ["git", "push", "--bookmark", bookmark], network);
353 } else {
354 const template = yield* jj("push-template", input.cwd, [
355 "--ignore-working-copy",
356 "config",
357 "get",
358 "templates.git_push_bookmark",
359 ]).pipe(
360 Effect.map((output) => output.stdout.trim()),
361 Effect.orElseSucceed(() => '"push-" ++ change_id.short()'),
362 );
363 bookmark = (yield* jj("bookmark-name", input.cwd, [
364 "--ignore-working-copy",
365 "log",
366 "--no-graph",
367 "-r",
368 input.nameFrom ?? input.changeId,
369 "-T",
370 template,
371 ])).stdout.trim();
372 yield* jj(
373 "push",
374 input.cwd,
375 ["git", "push", "--named", `${bookmark}=${input.changeId}`],
376 network,
377 );
378 }
379 const pushed = yield* jj("pushed-bookmark", input.cwd, [
380 "--ignore-working-copy",
381 "log",
382 "--no-graph",
383 "-r",
384 input.changeId,
385 "-T",
386 'remote_bookmarks.filter(|b| b.remote() != "git").map(|b| stringify(b.name() ++ "\\t" ++ b.remote())).join("\\n")',
387 ]);
388 const remote = pushed.stdout
389 .split("\n")
390 .find((line) => line.startsWith(`${bookmark}\t`))
391 ?.split("\t")[1];
392 if (!bookmark || !remote) {
393 return yield* new JjError({
394 operation: "push",
395 detail: "No pushed bookmark points at this change.",
396 });
397 }
398 return { bookmark, remote };
399 });
400
401 return { watchStack, snapshot, diff, describe, squash, push };
402});
403
404export const layer = Layer.effect(JjStackService, make);