| @@ -1,6 +1,6 @@ |
| 1 | import { Context, Effect, FileSystem, Layer } from "effect"; | 1 | import { Context, Effect, FileSystem, Layer, Option } from "effect"; |
| 2 | | 2 | |
| 3 | import { VcsOutputDecodeError, VcsProcessExitError } from "@t3tools/contracts"; | 3 | import { VcsOutputDecodeError, VcsProcessExitError, type VcsError } from "@t3tools/contracts"; |
| 4 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; | 4 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; |
| 5 | import { nowFreshness } from "./VcsFreshness.ts"; | 5 | import { nowFreshness } from "./VcsFreshness.ts"; |
| 6 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; | 6 | import { 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 | |
| | 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; |
| 16 | } | 35 | } |
| 17 | | 36 | |
| 18 | export class JjVcsDriver extends Context.Service<JjVcsDriver, JjVcsDriverShape>()( | 37 | export 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 | |
| | 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 | |
| 45 | function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] { | 134 | function 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 | |