History

Add typed jj extension surface

Julius Marminge committed 6 months ago

Commit 96bd5ce1 parent 87ad5b42 files · +176 −3Browse files

Unified · Split

2 files
  1. apps/server/src/vcs/GitVcsDriver.ts +1 −1
  2. apps/server/src/vcs/JjVcsDriver.ts +175 −2
apps/server/src/vcs/GitVcsDriver.ts+1−1
@@ -1,4 +1,4 @@
1−import { Context, Effect, Layer } from "effect";
1+import { Context, Effect, Layer, Option } from "effect";
22 import { ChildProcessSpawner } from "effect/unstable/process";
33
44 import {
apps/server/src/vcs/JjVcsDriver.ts+175−2
@@ -1,6 +1,6 @@
1−import { Context, Effect, FileSystem, Layer } from "effect";
1+import { Context, Effect, FileSystem, Layer, Option } from "effect";
22
3−import { VcsOutputDecodeError, VcsProcessExitError } from "@t3tools/contracts";
3+import { VcsOutputDecodeError, VcsProcessExitError, type VcsError } from "@t3tools/contracts";
44 import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
55 import { nowFreshness } from "./VcsFreshness.ts";
66 import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
@@ -13,6 +13,25 @@ export interface JjVcsDriverShape extends VcsDriverShape { · 6 unmodified lines
1313 readonly supportsWorktrees: false;
1414 readonly ignoreClassifier: "git-compatible-fallback";
1515 };
16+ readonly currentChange: (cwd: string) => Effect.Effect<JjCurrentChange | null, VcsError>;
17+ readonly listBookmarks: (cwd: string) => Effect.Effect<ReadonlyArray<JjBookmark>, VcsError>;
18+ readonly listWorkspaces: (cwd: string) => Effect.Effect<ReadonlyArray<JjWorkspace>, VcsError>;
19+}
20+
21+export interface JjCurrentChange {
22+ readonly changeId: string;
23+ readonly commitId: string | null;
24+ readonly description: string | null;
25+}
26+
27+export interface JjBookmark {
28+ readonly name: string;
29+ readonly target: string | null;
30+}
31+
32+export interface JjWorkspace {
33+ readonly name: string;
34+ readonly path: string | null;
1635 }
1736
1837 export class JjVcsDriver extends Context.Service<JjVcsDriver, JjVcsDriverShape>()(
@@ -42,6 +61,76 @@ function splitLineSeparatedPaths(input: string, truncated: boolean): string[] { · 23 unmodified lines
4261 return lines.map((line) => line.trim()).filter((line) => line.length > 0);
4362 }
4463
64+function parseJjRemoteList(output: string): Array<{ name: string; url: string }> {
65+ return output
66+ .split(/\r?\n/g)
67+ .map((line) => line.trim())
68+ .flatMap((line) => {
69+ if (line.length === 0) {
70+ return [];
71+ }
72+
73+ const [name, ...urlParts] = line.split(/\s+/g);
74+ const url = urlParts.join(" ").trim();
75+ return name && url ? [{ name, url }] : [];
76+ });
77+}
78+
79+function parseNullRecord(record: string): string[] {
80+ return record.split("\0").map((value) => value.trim());
81+}
82+
83+function decodeJjCurrentChange(raw: string, cwd: string): JjCurrentChange | null {
84+ const trimmed = raw.trim();
85+ if (trimmed.length === 0) {
86+ return null;
87+ }
88+
89+ const [changeId, commitId, description] = parseNullRecord(trimmed);
90+ if (!changeId) {
91+ throw new VcsOutputDecodeError({
92+ operation: "JjVcsDriver.currentChange",
93+ command: "jj log",
94+ cwd,
95+ detail: "jj current change output did not include a change id",
96+ });
97+ }
98+
99+ return {
100+ changeId,
101+ commitId: commitId || null,
102+ description: description || null,
103+ };
104+}
105+
106+function decodeJjBookmarkList(raw: string): ReadonlyArray<JjBookmark> {
107+ return raw
108+ .split(/\r?\n/g)
109+ .map((line) => line.trim())
110+ .filter((line) => line.length > 0)
111+ .map((line) => {
112+ const [name, target] = parseNullRecord(line);
113+ return {
114+ name: name ?? line,
115+ target: target || null,
116+ };
117+ });
118+}
119+
120+function decodeJjWorkspaceList(raw: string): ReadonlyArray<JjWorkspace> {
121+ return raw
122+ .split(/\r?\n/g)
123+ .map((line) => line.trim())
124+ .filter((line) => line.length > 0)
125+ .map((line) => {
126+ const [name, path] = parseNullRecord(line);
127+ return {
128+ name: name ?? line,
129+ path: path || null,
130+ };
131+ });
132+}
133+
45134 function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {
46135 const chunks: string[][] = [];
47136 let chunk: string[] = [];
@@ -233,6 +322,86 @@ export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () · 185 unmodified lines
233322 ),
234323 );
235324
325+ const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) {
326+ const result = yield* jjCommand(
327+ process,
328+ "JjVcsDriver.listRemotes",
329+ cwd,
330+ ["git", "remote", "list"],
331+ {
332+ allowNonZeroExit: true,
333+ timeoutMs: 5_000,
334+ maxOutputBytes: 64 * 1024,
335+ },
336+ );
337+
338+ if (result.exitCode !== 0) {
339+ return {
340+ remotes: [],
341+ freshness: yield* nowFreshness(),
342+ };
343+ }
344+
345+ return {
346+ remotes: parseJjRemoteList(result.stdout).map((remote) => ({
347+ name: remote.name,
348+ url: remote.url,
349+ pushUrl: Option.none(),
350+ isPrimary: remote.name === "origin",
351+ })),
352+ freshness: yield* nowFreshness(),
353+ };
354+ });
355+
356+ const currentChange: JjVcsDriverShape["currentChange"] = (cwd) =>
357+ jjCommand(
358+ process,
359+ "JjVcsDriver.currentChange",
360+ cwd,
361+ [
362+ "log",
363+ "-r",
364+ "@",
365+ "--no-graph",
366+ "--template",
367+ 'change_id ++ "\\0" ++ commit_id ++ "\\0" ++ description.first_line()',
368+ ],
369+ {
370+ timeoutMs: 5_000,
371+ maxOutputBytes: 64 * 1024,
372+ },
373+ ).pipe(Effect.map((result) => decodeJjCurrentChange(result.stdout, cwd)));
374+
375+ const listBookmarks: JjVcsDriverShape["listBookmarks"] = (cwd) =>
376+ jjCommand(
377+ process,
378+ "JjVcsDriver.listBookmarks",
379+ cwd,
380+ ["bookmark", "list", "--template", 'name ++ "\\0" ++ target.commit_id() ++ "\\n"'],
381+ {
382+ allowNonZeroExit: true,
383+ timeoutMs: 5_000,
384+ maxOutputBytes: 256 * 1024,
385+ },
386+ ).pipe(
387+ Effect.map((result) => (result.exitCode === 0 ? decodeJjBookmarkList(result.stdout) : [])),
388+ );
389+
390+ const listWorkspaces: JjVcsDriverShape["listWorkspaces"] = (cwd) =>
391+ jjCommand(
392+ process,
393+ "JjVcsDriver.listWorkspaces",
394+ cwd,
395+ ["workspace", "list", "--template", 'name ++ "\\0" ++ root ++ "\\n"'],
396+ {
397+ allowNonZeroExit: true,
398+ timeoutMs: 5_000,
399+ maxOutputBytes: 256 * 1024,
400+ },
401+ ).pipe(
402+ Effect.map((result) => (result.exitCode === 0 ? decodeJjWorkspaceList(result.stdout) : [])),
403+ );
404+
236405 const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")(
237406 function* (cwd, relativePaths) {
238407 if (relativePaths.length === 0) {
@@ -322,7 +491,11 @@ export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () · 83 unmodified lines
322491 detectRepository,
323492 isInsideWorkTree,
324493 listWorkspaceFiles,
494+ listRemotes,
325495 filterIgnoredPaths,
496+ currentChange,
497+ listBookmarks,
498+ listWorkspaces,
326499 } satisfies JjVcsDriverShape;
327500 });
328501