| ... | @@ -32,6 +32,11 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ... | @@ -32,6 +32,11 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 32 | | 32 | |
| 33 | const DEFAULT_TIMEOUT_MS = 30_000; | 33 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 34 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | 34 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| | 35 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; |
| | 36 | const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; |
| | 37 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| | 38 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| | 39 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; |
| 35 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | 40 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 36 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | 41 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 37 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 42 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| ... | @@ -53,6 +58,8 @@ interface ExecuteGitOptions { | ... | @@ -53,6 +58,8 @@ interface ExecuteGitOptions { |
| 53 | timeoutMs?: number | undefined; | 58 | timeoutMs?: number | undefined; |
| 54 | allowNonZeroExit?: boolean | undefined; | 59 | allowNonZeroExit?: boolean | undefined; |
| 55 | fallbackErrorMessage?: string | undefined; | 60 | fallbackErrorMessage?: string | undefined; |
| | 61 | maxOutputBytes?: number | undefined; |
| | 62 | truncateOutputAtMaxBytes?: boolean | undefined; |
| 56 | progress?: ExecuteGitProgress | undefined; | 63 | progress?: ExecuteGitProgress | undefined; |
| 57 | } | 64 | } |
| 58 | | 65 | |
| ... | @@ -439,12 +446,14 @@ const collectOutput = Effect.fn(function* <E>( | ... | @@ -439,12 +446,14 @@ const collectOutput = Effect.fn(function* <E>( |
| 439 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | 446 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, |
| 440 | stream: Stream.Stream<Uint8Array, E>, | 447 | stream: Stream.Stream<Uint8Array, E>, |
| 441 | maxOutputBytes: number, | 448 | maxOutputBytes: number, |
| | 449 | truncateOutputAtMaxBytes: boolean, |
| 442 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | 450 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, |
| 443 | ): Effect.fn.Return<string, GitCommandError> { | 451 | ): Effect.fn.Return<string, GitCommandError> { |
| 444 | const decoder = new TextDecoder(); | 452 | const decoder = new TextDecoder(); |
| 445 | let bytes = 0; | 453 | let bytes = 0; |
| 446 | let text = ""; | 454 | let text = ""; |
| 447 | let lineBuffer = ""; | 455 | let lineBuffer = ""; |
| | 456 | let truncated = false; |
| 448 | | 457 | |
| 449 | const emitCompleteLines = (flush: boolean) => | 458 | const emitCompleteLines = (flush: boolean) => |
| 450 | Effect.gen(function* () { | 459 | Effect.gen(function* () { |
| ... | @@ -469,8 +478,11 @@ const collectOutput = Effect.fn(function* <E>( | ... | @@ -469,8 +478,11 @@ const collectOutput = Effect.fn(function* <E>( |
| 469 | | 478 | |
| 470 | yield* Stream.runForEach(stream, (chunk) => | 479 | yield* Stream.runForEach(stream, (chunk) => |
| 471 | Effect.gen(function* () { | 480 | Effect.gen(function* () { |
| 472 | bytes += chunk.byteLength; | 481 | if (truncateOutputAtMaxBytes && truncated) { |
| 473 | if (bytes > maxOutputBytes) { | 482 | return; |
| | 483 | } |
| | 484 | const nextBytes = bytes + chunk.byteLength; |
| | 485 | if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { |
| 474 | return yield* new GitCommandError({ | 486 | return yield* new GitCommandError({ |
| 475 | operation: input.operation, | 487 | operation: input.operation, |
| 476 | command: quoteGitCommand(input.args), | 488 | command: quoteGitCommand(input.args), |
| ... | @@ -478,18 +490,26 @@ const collectOutput = Effect.fn(function* <E>( | ... | @@ -478,18 +490,26 @@ const collectOutput = Effect.fn(function* <E>( |
| 478 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | 490 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, |
| 479 | }); | 491 | }); |
| 480 | } | 492 | } |
| 481 | const decoded = decoder.decode(chunk, { stream: true }); | 493 | |
| | 494 | const chunkToDecode = |
| | 495 | truncateOutputAtMaxBytes && nextBytes > maxOutputBytes |
| | 496 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) |
| | 497 | : chunk; |
| | 498 | bytes += chunkToDecode.byteLength; |
| | 499 | truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; |
| | 500 | |
| | 501 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); |
| 482 | text += decoded; | 502 | text += decoded; |
| 483 | lineBuffer += decoded; | 503 | lineBuffer += decoded; |
| 484 | yield* emitCompleteLines(false); | 504 | yield* emitCompleteLines(false); |
| 485 | }), | 505 | }), |
| 486 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | 506 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); |
| 487 | | 507 | |
| 488 | const remainder = decoder.decode(); | 508 | const remainder = truncated ? "" : decoder.decode(); |
| 489 | text += remainder; | 509 | text += remainder; |
| 490 | lineBuffer += remainder; | 510 | lineBuffer += remainder; |
| 491 | yield* emitCompleteLines(true); | 511 | yield* emitCompleteLines(true); |
| 492 | return text; | 512 | return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; |
| 493 | }); | 513 | }); |
| 494 | | 514 | |
| 495 | export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => | 515 | export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => |
| ... | @@ -511,6 +531,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -511,6 +531,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 511 | } as const; | 531 | } as const; |
| 512 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | 532 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 513 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 533 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| | 534 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; |
| 514 | | 535 | |
| 515 | const commandEffect = Effect.gen(function* () { | 536 | const commandEffect = Effect.gen(function* () { |
| 516 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( | 537 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( |
| ... | @@ -537,12 +558,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -537,12 +558,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 537 | commandInput, | 558 | commandInput, |
| 538 | child.stdout, | 559 | child.stdout, |
| 539 | maxOutputBytes, | 560 | maxOutputBytes, |
| | 561 | truncateOutputAtMaxBytes, |
| 540 | input.progress?.onStdoutLine, | 562 | input.progress?.onStdoutLine, |
| 541 | ), | 563 | ), |
| 542 | collectOutput( | 564 | collectOutput( |
| 543 | commandInput, | 565 | commandInput, |
| 544 | child.stderr, | 566 | child.stderr, |
| 545 | maxOutputBytes, | 567 | maxOutputBytes, |
| | 568 | truncateOutputAtMaxBytes, |
| 546 | input.progress?.onStderrLine, | 569 | input.progress?.onStderrLine, |
| 547 | ), | 570 | ), |
| 548 | child.exitCode.pipe( | 571 | child.exitCode.pipe( |
| ... | @@ -603,6 +626,10 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -603,6 +626,10 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 603 | args, | 626 | args, |
| 604 | allowNonZeroExit: true, | 627 | allowNonZeroExit: true, |
| 605 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 628 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| | 629 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| | 630 | ...(options.truncateOutputAtMaxBytes !== undefined |
| | 631 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } |
| | 632 | : {}), |
| 606 | ...(options.progress ? { progress: options.progress } : {}), | 633 | ...(options.progress ? { progress: options.progress } : {}), |
| 607 | }).pipe( | 634 | }).pipe( |
| 608 | Effect.flatMap((result) => { | 635 | Effect.flatMap((result) => { |
| ... | @@ -647,6 +674,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -647,6 +674,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 647 | Effect.map((result) => result.stdout), | 674 | Effect.map((result) => result.stdout), |
| 648 | ); | 675 | ); |
| 649 | | 676 | |
| | 677 | const runGitStdoutWithOptions = ( |
| | 678 | operation: string, |
| | 679 | cwd: string, |
| | 680 | args: readonly string[], |
| | 681 | options: ExecuteGitOptions = {}, |
| | 682 | ): Effect.Effect<string, GitCommandError> => |
| | 683 | executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); |
| | 684 | |
| 650 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | 685 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => |
| 651 | executeGit( | 686 | executeGit( |
| 652 | "GitCore.branchExists", | 687 | "GitCore.branchExists", |
| ... | @@ -1162,12 +1197,15 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -1162,12 +1197,15 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 1162 | return null; | 1197 | return null; |
| 1163 | } | 1198 | } |
| 1164 | | 1199 | |
| 1165 | const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ | 1200 | const stagedPatch = yield* runGitStdoutWithOptions( |
| 1166 | "diff", | 1201 | "GitCore.prepareCommitContext.stagedPatch", |
| 1167 | "--cached", | 1202 | cwd, |
| 1168 | "--patch", | 1203 | ["diff", "--cached", "--patch", "--minimal"], |
| 1169 | "--minimal", | 1204 | { |
| 1170 | ]); | 1205 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, |
| | 1206 | truncateOutputAtMaxBytes: true, |
| | 1207 | }, |
| | 1208 | ); |
| 1171 | | 1209 | |
| 1172 | return { | 1210 | return { |
| 1173 | stagedSummary, | 1211 | stagedSummary, |
| ... | @@ -1363,14 +1401,33 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" | ... | @@ -1363,14 +1401,33 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" |
| 1363 | const range = `${baseBranch}..HEAD`; | 1401 | const range = `${baseBranch}..HEAD`; |
| 1364 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | 1402 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( |
| 1365 | [ | 1403 | [ |
| 1366 | runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), | 1404 | runGitStdoutWithOptions( |
| 1367 | runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), | 1405 | "GitCore.readRangeContext.log", |
| 1368 | runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ | 1406 | cwd, |
| 1369 | "diff", | 1407 | ["log", "--oneline", range], |
| 1370 | "--patch", | 1408 | { |
| 1371 | "--minimal", | 1409 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, |
| 1372 | range, | 1410 | truncateOutputAtMaxBytes: true, |
| 1373 | ]), | 1411 | }, |
| | 1412 | ), |
| | 1413 | runGitStdoutWithOptions( |
| | 1414 | "GitCore.readRangeContext.diffStat", |
| | 1415 | cwd, |
| | 1416 | ["diff", "--stat", range], |
| | 1417 | { |
| | 1418 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, |
| | 1419 | truncateOutputAtMaxBytes: true, |
| | 1420 | }, |
| | 1421 | ), |
| | 1422 | runGitStdoutWithOptions( |
| | 1423 | "GitCore.readRangeContext.diffPatch", |
| | 1424 | cwd, |
| | 1425 | ["diff", "--patch", "--minimal", range], |
| | 1426 | { |
| | 1427 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, |
| | 1428 | truncateOutputAtMaxBytes: true, |
| | 1429 | }, |
| | 1430 | ), |
| 1374 | ], | 1431 | ], |
| 1375 | { concurrency: "unbounded" }, | 1432 | { concurrency: "unbounded" }, |
| 1376 | ); | 1433 | ); |