| 1 | import { |
| 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"; |
| 15 | import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; |
| 16 | import * as Context from "effect/Context"; |
| 17 | import * as Duration from "effect/Duration"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as FileSystem from "effect/FileSystem"; |
| 20 | import * as Layer from "effect/Layer"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Stream from "effect/Stream"; |
| 24 | |
| 25 | import { VcsProcess } from "./VcsProcess.ts"; |
| 26 | |
| 27 | /** Ancestors down to trunk plus anything above `@`; sibling lines that share a base stay out. */ |
| 28 | const STACK_REVSET = "(::@ | @::) ~ ::trunk() ~ (empty() ~ @)"; |
| 29 | const STACK_LIMIT = 60; |
| 30 | const MAX_DIFF_BYTES = 4_000_000; |
| 31 | |
| 32 | const 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 | |
| 50 | const 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 | }); |
| 67 | const decodeRawChange = Schema.decodeUnknownSync(Schema.fromJsonString(RawChange)); |
| 68 | |
| 69 | function 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. */ |
| 93 | export 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. */ |
| 118 | export 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 | |
| 127 | export 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 | |
| 141 | const 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 | |
| 404 | export const layer = Layer.effect(JjStackService, make); |