History

Add typed jj extension surface

Julius Marminge committed 6 months ago

Commit e2e40c21 parent ab974c82 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 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer, Option } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";2import { ChildProcessSpawner } from "effect/unstable/process";
3 3
4import {4import {
apps/server/src/vcs/JjVcsDriver.ts+175−2
@@ -1,6 +1,6 @@
1import { Context, Effect, FileSystem, Layer } from "effect";1import { Context, Effect, FileSystem, Layer, Option } from "effect";
2 2
3import { VcsOutputDecodeError, VcsProcessExitError } from "@t3tools/contracts";3import { VcsOutputDecodeError, VcsProcessExitError, type VcsError } from "@t3tools/contracts";
4import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";4import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
5import { nowFreshness } from "./VcsFreshness.ts";5import { nowFreshness } from "./VcsFreshness.ts";
6import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";6import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
@@ -13,6 +13,25 @@ export interface JjVcsDriverShape extends VcsDriverShape { · 6 unmodified lines
13 readonly supportsWorktrees: false;13 readonly supportsWorktrees: false;
14 readonly ignoreClassifier: "git-compatible-fallback";14 readonly ignoreClassifier: "git-compatible-fallback";
15 };15 };
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
21export interface JjCurrentChange {
22 readonly changeId: string;
23 readonly commitId: string | null;
24 readonly description: string | null;
25}
26
27export interface JjBookmark {
28 readonly name: string;
29 readonly target: string | null;
30}
31
32export interface JjWorkspace {
33 readonly name: string;
34 readonly path: string | null;
16}35}
17 36
18export class JjVcsDriver extends Context.Service<JjVcsDriver, JjVcsDriverShape>()(37export class JjVcsDriver extends Context.Service<JjVcsDriver, JjVcsDriverShape>()(
@@ -42,6 +61,76 @@ function splitLineSeparatedPaths(input: string, truncated: boolean): string[] { · 23 unmodified lines
42 return lines.map((line) => line.trim()).filter((line) => line.length > 0);61 return lines.map((line) => line.trim()).filter((line) => line.length > 0);
43}62}
44 63
64function 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
79function parseNullRecord(record: string): string[] {
80 return record.split("\0").map((value) => value.trim());
81}
82
83function 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
106function 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
120function 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
45function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {134function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {
46 const chunks: string[][] = [];135 const chunks: string[][] = [];
47 let chunk: string[] = [];136 let chunk: string[] = [];
@@ -233,6 +322,86 @@ export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () · 185 unmodified lines
233 ),322 ),
234 );323 );
235 324
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
236 const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")(405 const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")(
237 function* (cwd, relativePaths) {406 function* (cwd, relativePaths) {
238 if (relativePaths.length === 0) {407 if (relativePaths.length === 0) {
@@ -322,7 +491,11 @@ export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () · 83 unmodified lines
322 detectRepository,491 detectRepository,
323 isInsideWorkTree,492 isInsideWorkTree,
324 listWorkspaceFiles,493 listWorkspaceFiles,
494 listRemotes,
325 filterIgnoredPaths,495 filterIgnoredPaths,
496 currentChange,
497 listBookmarks,
498 listWorkspaces,
326 } satisfies JjVcsDriverShape;499 } satisfies JjVcsDriverShape;
327});500});
328 501