| @@ -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 | 4 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; |
| 5 | 5 | import { nowFreshness } from "./VcsFreshness.ts"; |
| 6 | 6 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; |
| @@ -13,6 +13,25 @@ export interface JjVcsDriverShape extends VcsDriverShape { · 6 unmodified lines |
| 13 | 13 | readonly supportsWorktrees: false; |
| 14 | 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 | 37 | export class JjVcsDriver extends Context.Service<JjVcsDriver, JjVcsDriverShape>()( |
| @@ -42,6 +61,76 @@ function splitLineSeparatedPaths(input: string, truncated: boolean): string[] { · 23 unmodified lines |
| 42 | 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 | 134 | function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] { |
| 46 | 135 | const chunks: string[][] = []; |
| 47 | 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 | 405 | const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( |
| 237 | 406 | function* (cwd, relativePaths) { |
| 238 | 407 | if (relativePaths.length === 0) { |
| @@ -322,7 +491,11 @@ export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () · 83 unmodified lines |
| 322 | 491 | detectRepository, |
| 323 | 492 | isInsideWorkTree, |
| 324 | 493 | listWorkspaceFiles, |
| 494 | + listRemotes, |
| 325 | 495 | filterIgnoredPaths, |
| 496 | + currentChange, |
| 497 | + listBookmarks, |
| 498 | + listWorkspaces, |
| 326 | 499 | } satisfies JjVcsDriverShape; |
| 327 | 500 | }); |
| 328 | 501 | |