@muyichengshayu/promptx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1425 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { all, get, run, transaction } from './db.js'
7
+ import { getPromptxCodexSessionById } from './codexSessions.js'
8
+ import { getTaskBySlug } from './repository.js'
9
+
10
+ const MAX_SNAPSHOT_TEXT_BYTES = 220_000
11
+ const MAX_PATCH_TEXT_BYTES = 260_000
12
+ const DIFF_REVIEW_CACHE_TTL_MS = 4000
13
+ const DIFF_REVIEW_CACHE_MAX_ENTRIES = 80
14
+ const FILE_DIFF_CACHE_TTL_MS = 8000
15
+ const FILE_DIFF_CACHE_MAX_ENTRIES = 400
16
+
17
+ const diffReviewCache = new Map()
18
+ const fileDiffCache = new Map()
19
+ const gitDiffCacheMetrics = {
20
+ reviewHits: 0,
21
+ reviewMisses: 0,
22
+ fileHits: 0,
23
+ fileMisses: 0,
24
+ }
25
+
26
+ function isGitDiffDebugEnabled(channel = 'all') {
27
+ const rawValue = String(process.env.PROMPTX_GIT_DIFF_DEBUG || '').trim().toLowerCase()
28
+ if (!rawValue) {
29
+ return false
30
+ }
31
+
32
+ if (rawValue === '1' || rawValue === 'true' || rawValue === 'all' || rawValue === '*') {
33
+ return true
34
+ }
35
+
36
+ const enabledChannels = rawValue
37
+ .split(/[,\s]+/)
38
+ .map((item) => item.trim())
39
+ .filter(Boolean)
40
+
41
+ return enabledChannels.includes(channel) || enabledChannels.includes('all')
42
+ }
43
+
44
+ function logGitDiffDebug(channel = 'all', action = '', meta = {}) {
45
+ if (!isGitDiffDebugEnabled(channel)) {
46
+ return
47
+ }
48
+
49
+ const normalizedMeta = Object.entries(meta)
50
+ .filter(([, value]) => value !== '' && value !== null && typeof value !== 'undefined')
51
+ .map(([key, value]) => `${key}=${String(value)}`)
52
+ .join(' ')
53
+
54
+ console.info(`[promptx][git-diff:${channel}] ${action}${normalizedMeta ? ` ${normalizedMeta}` : ''}`)
55
+ }
56
+
57
+ function runGit(repoRoot = '', args = [], options = {}) {
58
+ const result = spawnSync('git', ['-C', repoRoot, ...args], {
59
+ encoding: 'utf8',
60
+ maxBuffer: 8 * 1024 * 1024,
61
+ ...options,
62
+ })
63
+
64
+ return {
65
+ status: typeof result.status === 'number' ? result.status : 1,
66
+ stdout: String(result.stdout || ''),
67
+ stderr: String(result.stderr || ''),
68
+ }
69
+ }
70
+
71
+ function runGitBuffer(repoRoot = '', args = [], options = {}) {
72
+ const result = spawnSync('git', ['-C', repoRoot, ...args], {
73
+ encoding: 'buffer',
74
+ maxBuffer: 8 * 1024 * 1024,
75
+ ...options,
76
+ })
77
+
78
+ return {
79
+ status: typeof result.status === 'number' ? result.status : 1,
80
+ stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || ''),
81
+ stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr || ''),
82
+ }
83
+ }
84
+
85
+ function splitNullText(value = '') {
86
+ return String(value || '').split('\0').filter(Boolean)
87
+ }
88
+
89
+ function createHash(value) {
90
+ return crypto.createHash('sha1').update(value).digest('hex')
91
+ }
92
+
93
+ function getCachedValue(cache, key, ttlMs, metricKey = '', options = {}) {
94
+ const {
95
+ channel = 'all',
96
+ cacheName = 'cache',
97
+ debugMeta = {},
98
+ } = options
99
+ const entry = cache.get(key)
100
+ if (!entry) {
101
+ if (metricKey) {
102
+ gitDiffCacheMetrics[metricKey] += 1
103
+ }
104
+ logGitDiffDebug(channel, 'miss', {
105
+ cache: cacheName,
106
+ ...debugMeta,
107
+ })
108
+ return null
109
+ }
110
+
111
+ if (Date.now() - entry.createdAt > ttlMs) {
112
+ cache.delete(key)
113
+ if (metricKey) {
114
+ gitDiffCacheMetrics[metricKey] += 1
115
+ }
116
+ logGitDiffDebug(channel, 'stale', {
117
+ cache: cacheName,
118
+ ...debugMeta,
119
+ })
120
+ return null
121
+ }
122
+
123
+ cache.delete(key)
124
+ cache.set(key, entry)
125
+ logGitDiffDebug(channel, 'hit', {
126
+ cache: cacheName,
127
+ ...debugMeta,
128
+ })
129
+ return entry.value
130
+ }
131
+
132
+ function setCachedValue(cache, key, value, maxEntries = 0, options = {}) {
133
+ const {
134
+ channel = 'all',
135
+ cacheName = 'cache',
136
+ debugMeta = {},
137
+ } = options
138
+ cache.delete(key)
139
+ cache.set(key, {
140
+ value,
141
+ createdAt: Date.now(),
142
+ })
143
+ logGitDiffDebug(channel, 'store', {
144
+ cache: cacheName,
145
+ size: cache.size,
146
+ ...debugMeta,
147
+ })
148
+
149
+ while (maxEntries > 0 && cache.size > maxEntries) {
150
+ const oldestKey = cache.keys().next().value
151
+ if (typeof oldestKey === 'undefined') {
152
+ break
153
+ }
154
+ cache.delete(oldestKey)
155
+ logGitDiffDebug(channel, 'evict', {
156
+ cache: cacheName,
157
+ size: cache.size,
158
+ })
159
+ }
160
+ }
161
+
162
+ function normalizeDiffStatus(value = '') {
163
+ const status = String(value || '').trim().charAt(0).toUpperCase()
164
+ if (status === 'A' || status === 'D') {
165
+ return status
166
+ }
167
+ return 'M'
168
+ }
169
+
170
+ function parseTrackedDiffEntries(output = '') {
171
+ const parts = splitNullText(output)
172
+ const entries = new Map()
173
+
174
+ for (let index = 0; index < parts.length; index += 1) {
175
+ const rawStatus = String(parts[index] || '').trim()
176
+ if (!rawStatus) {
177
+ continue
178
+ }
179
+ const rawPath = String(parts[index + 1] || '').trim()
180
+ if (!rawPath) {
181
+ continue
182
+ }
183
+
184
+ let nextPath = rawPath
185
+ if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
186
+ nextPath = String(parts[index + 2] || rawPath).trim() || rawPath
187
+ index += 2
188
+ } else {
189
+ index += 1
190
+ }
191
+
192
+ entries.set(nextPath, {
193
+ path: nextPath,
194
+ status: normalizeDiffStatus(rawStatus),
195
+ })
196
+ }
197
+
198
+ return entries
199
+ }
200
+
201
+ function resolveGitRepoRoot(cwd = '') {
202
+ const targetCwd = String(cwd || '').trim()
203
+ if (!targetCwd) {
204
+ return ''
205
+ }
206
+
207
+ const result = runGit(targetCwd, ['rev-parse', '--show-toplevel'])
208
+ if (result.status !== 0) {
209
+ return ''
210
+ }
211
+
212
+ return result.stdout.trim()
213
+ }
214
+
215
+ function resolveGitHeadOid(repoRoot = '') {
216
+ const result = runGit(repoRoot, ['rev-parse', '--verify', 'HEAD'])
217
+ if (result.status !== 0) {
218
+ return ''
219
+ }
220
+ return result.stdout.trim()
221
+ }
222
+
223
+ function resolveGitBranchLabel(repoRoot = '') {
224
+ const branchResult = runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
225
+ const branchName = branchResult.stdout.trim()
226
+ if (branchResult.status === 0 && branchName) {
227
+ return branchName
228
+ }
229
+
230
+ const headShort = runGit(repoRoot, ['rev-parse', '--short', 'HEAD']).stdout.trim()
231
+ if (headShort) {
232
+ return `detached@${headShort}`
233
+ }
234
+
235
+ return ''
236
+ }
237
+
238
+ function resolveWorkspaceStatusSignature(repoRoot = '') {
239
+ if (!repoRoot) {
240
+ return ''
241
+ }
242
+
243
+ const result = runGitBuffer(repoRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
244
+ if (result.status !== 0) {
245
+ return ''
246
+ }
247
+
248
+ return createHash(result.stdout)
249
+ }
250
+
251
+ function resolveShortOid(value = '') {
252
+ const text = String(value || '').trim()
253
+ return text ? text.slice(0, 7) : ''
254
+ }
255
+
256
+ function commitExists(repoRoot = '', oid = '') {
257
+ const normalizedOid = String(oid || '').trim()
258
+ if (!repoRoot || !normalizedOid) {
259
+ return false
260
+ }
261
+
262
+ return runGit(repoRoot, ['cat-file', '-e', `${normalizedOid}^{commit}`]).status === 0
263
+ }
264
+
265
+ function isAncestorCommit(repoRoot = '', ancestorOid = '', descendantOid = '') {
266
+ const normalizedAncestorOid = String(ancestorOid || '').trim()
267
+ const normalizedDescendantOid = String(descendantOid || '').trim()
268
+ if (!repoRoot || !normalizedAncestorOid || !normalizedDescendantOid) {
269
+ return false
270
+ }
271
+
272
+ return runGit(repoRoot, ['merge-base', '--is-ancestor', normalizedAncestorOid, normalizedDescendantOid]).status === 0
273
+ }
274
+
275
+ function listGitChangeEntries(repoRoot = '') {
276
+ const entries = new Map()
277
+ const headOid = resolveGitHeadOid(repoRoot)
278
+
279
+ if (headOid) {
280
+ const trackedResult = runGit(repoRoot, ['diff', '--name-status', '-z', 'HEAD', '--'])
281
+ parseTrackedDiffEntries(trackedResult.stdout).forEach((entry, filePath) => {
282
+ entries.set(filePath, entry)
283
+ })
284
+ } else {
285
+ splitNullText(runGit(repoRoot, ['ls-files', '-z']).stdout).forEach((filePath) => {
286
+ entries.set(filePath, {
287
+ path: filePath,
288
+ status: 'A',
289
+ })
290
+ })
291
+ }
292
+
293
+ splitNullText(runGit(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']).stdout).forEach((filePath) => {
294
+ if (!entries.has(filePath)) {
295
+ entries.set(filePath, {
296
+ path: filePath,
297
+ status: 'A',
298
+ })
299
+ }
300
+ })
301
+
302
+ return {
303
+ headOid,
304
+ entries,
305
+ }
306
+ }
307
+
308
+ function readFileState(repoRoot = '', filePath = '') {
309
+ const absolutePath = path.resolve(repoRoot, filePath)
310
+ if (!absolutePath.startsWith(path.resolve(repoRoot))) {
311
+ return {
312
+ exists: false,
313
+ isBinary: false,
314
+ tooLarge: false,
315
+ size: 0,
316
+ hash: '',
317
+ text: '',
318
+ }
319
+ }
320
+
321
+ try {
322
+ const stats = fs.statSync(absolutePath)
323
+ if (!stats.isFile()) {
324
+ return {
325
+ exists: false,
326
+ isBinary: false,
327
+ tooLarge: false,
328
+ size: 0,
329
+ hash: '',
330
+ text: '',
331
+ }
332
+ }
333
+
334
+ const buffer = fs.readFileSync(absolutePath)
335
+ const isBinary = buffer.includes(0)
336
+ const tooLarge = !isBinary && buffer.length > MAX_SNAPSHOT_TEXT_BYTES
337
+
338
+ return {
339
+ exists: true,
340
+ isBinary,
341
+ tooLarge,
342
+ size: buffer.length,
343
+ hash: createHash(buffer),
344
+ text: !isBinary && !tooLarge ? buffer.toString('utf8') : '',
345
+ }
346
+ } catch {
347
+ return {
348
+ exists: false,
349
+ isBinary: false,
350
+ tooLarge: false,
351
+ size: 0,
352
+ hash: '',
353
+ text: '',
354
+ }
355
+ }
356
+ }
357
+
358
+ function areFileStatesEqual(left, right) {
359
+ const previous = left || null
360
+ const next = right || null
361
+
362
+ if (!previous && !next) {
363
+ return true
364
+ }
365
+
366
+ if (!previous || !next) {
367
+ return false
368
+ }
369
+
370
+ return (
371
+ Boolean(previous.exists) === Boolean(next.exists)
372
+ && Boolean(previous.isBinary) === Boolean(next.isBinary)
373
+ && Boolean(previous.tooLarge) === Boolean(next.tooLarge)
374
+ && String(previous.hash || '') === String(next.hash || '')
375
+ )
376
+ }
377
+
378
+ function captureDirtySnapshots(repoRoot = '') {
379
+ const { headOid, entries } = listGitChangeEntries(repoRoot)
380
+ const snapshots = new Map()
381
+
382
+ entries.forEach((entry, filePath) => {
383
+ snapshots.set(filePath, readFileState(repoRoot, filePath))
384
+ })
385
+
386
+ return {
387
+ headOid,
388
+ snapshots,
389
+ }
390
+ }
391
+
392
+ function readHeadFileState(repoRoot = '', headOid = '', filePath = '') {
393
+ const normalizedHeadOid = String(headOid || '').trim()
394
+ const normalizedPath = String(filePath || '').trim()
395
+ if (!repoRoot || !normalizedHeadOid || !normalizedPath) {
396
+ return null
397
+ }
398
+
399
+ const result = runGitBuffer(repoRoot, ['show', `${normalizedHeadOid}:${normalizedPath}`])
400
+ if (result.status !== 0) {
401
+ return null
402
+ }
403
+
404
+ const buffer = result.stdout
405
+ const isBinary = buffer.includes(0)
406
+ const tooLarge = !isBinary && buffer.length > MAX_SNAPSHOT_TEXT_BYTES
407
+
408
+ return {
409
+ exists: true,
410
+ isBinary,
411
+ tooLarge,
412
+ size: buffer.length,
413
+ hash: createHash(buffer),
414
+ text: !isBinary && !tooLarge ? buffer.toString('utf8') : '',
415
+ }
416
+ }
417
+
418
+ function createBaselineStateResolver(repoRoot = '', baseline = null) {
419
+ const cache = new Map()
420
+
421
+ return (filePath = '') => {
422
+ const normalizedPath = String(filePath || '').trim()
423
+ if (!normalizedPath) {
424
+ return null
425
+ }
426
+
427
+ if (baseline?.entries?.has(normalizedPath)) {
428
+ return baseline.entries.get(normalizedPath) || null
429
+ }
430
+
431
+ if (cache.has(normalizedPath)) {
432
+ return cache.get(normalizedPath)
433
+ }
434
+
435
+ const state = readHeadFileState(repoRoot, baseline?.headOid, normalizedPath)
436
+ cache.set(normalizedPath, state)
437
+ return state
438
+ }
439
+ }
440
+
441
+ function listCommittedChangeEntries(repoRoot = '', fromHeadOid = '', toHeadOid = '') {
442
+ const normalizedFromHeadOid = String(fromHeadOid || '').trim()
443
+ const normalizedToHeadOid = String(toHeadOid || '').trim()
444
+
445
+ if (!repoRoot || !normalizedFromHeadOid || !normalizedToHeadOid || normalizedFromHeadOid === normalizedToHeadOid) {
446
+ return new Map()
447
+ }
448
+
449
+ const result = runGit(repoRoot, ['diff', '--name-status', '-z', `${normalizedFromHeadOid}..${normalizedToHeadOid}`, '--'])
450
+ return parseTrackedDiffEntries(result.stdout)
451
+ }
452
+
453
+ function serializeState(value = {}) {
454
+ return JSON.stringify({
455
+ exists: Boolean(value.exists),
456
+ isBinary: Boolean(value.isBinary),
457
+ tooLarge: Boolean(value.tooLarge),
458
+ size: Math.max(0, Number(value.size) || 0),
459
+ hash: String(value.hash || ''),
460
+ text: String(value.text || ''),
461
+ })
462
+ }
463
+
464
+ function parseState(value = '{}') {
465
+ try {
466
+ const payload = JSON.parse(value || '{}')
467
+ return {
468
+ exists: Boolean(payload.exists),
469
+ isBinary: Boolean(payload.isBinary),
470
+ tooLarge: Boolean(payload.tooLarge),
471
+ size: Math.max(0, Number(payload.size) || 0),
472
+ hash: String(payload.hash || ''),
473
+ text: String(payload.text || ''),
474
+ }
475
+ } catch {
476
+ return {
477
+ exists: false,
478
+ isBinary: false,
479
+ tooLarge: false,
480
+ size: 0,
481
+ hash: '',
482
+ text: '',
483
+ }
484
+ }
485
+ }
486
+
487
+ function loadTaskBaseline(taskSlug = '') {
488
+ const row = get(
489
+ `SELECT task_slug, repo_root, head_oid, branch_label, created_at, updated_at
490
+ FROM task_git_baselines
491
+ WHERE task_slug = ?`,
492
+ [String(taskSlug || '').trim()]
493
+ )
494
+
495
+ if (!row) {
496
+ return null
497
+ }
498
+
499
+ const entries = new Map()
500
+ all(
501
+ `SELECT path, state_json
502
+ FROM task_git_baseline_entries
503
+ WHERE task_slug = ?
504
+ ORDER BY path ASC`,
505
+ [row.task_slug]
506
+ ).forEach((entry) => {
507
+ entries.set(String(entry.path || '').trim(), parseState(entry.state_json))
508
+ })
509
+
510
+ return {
511
+ taskSlug: row.task_slug,
512
+ repoRoot: String(row.repo_root || ''),
513
+ headOid: String(row.head_oid || ''),
514
+ branchLabel: String(row.branch_label || ''),
515
+ createdAt: row.created_at,
516
+ updatedAt: row.updated_at,
517
+ entries,
518
+ }
519
+ }
520
+
521
+ function loadRunBaseline(runId = '') {
522
+ const row = get(
523
+ `SELECT run_id, repo_root, head_oid, branch_label, created_at
524
+ FROM run_git_baselines
525
+ WHERE run_id = ?`,
526
+ [String(runId || '').trim()]
527
+ )
528
+
529
+ if (!row) {
530
+ return null
531
+ }
532
+
533
+ const entries = new Map()
534
+ all(
535
+ `SELECT path, state_json
536
+ FROM run_git_baseline_entries
537
+ WHERE run_id = ?
538
+ ORDER BY path ASC`,
539
+ [row.run_id]
540
+ ).forEach((entry) => {
541
+ entries.set(String(entry.path || '').trim(), parseState(entry.state_json))
542
+ })
543
+
544
+ return {
545
+ runId: row.run_id,
546
+ repoRoot: String(row.repo_root || ''),
547
+ headOid: String(row.head_oid || ''),
548
+ branchLabel: String(row.branch_label || ''),
549
+ createdAt: row.created_at,
550
+ entries,
551
+ }
552
+ }
553
+
554
+ function loadRunFinalSnapshot(runId = '') {
555
+ const row = get(
556
+ `SELECT run_id, repo_root, head_oid, branch_label, created_at
557
+ FROM run_git_final_snapshots
558
+ WHERE run_id = ?`,
559
+ [String(runId || '').trim()]
560
+ )
561
+
562
+ if (!row) {
563
+ return null
564
+ }
565
+
566
+ const entries = new Map()
567
+ all(
568
+ `SELECT path, state_json
569
+ FROM run_git_final_snapshot_entries
570
+ WHERE run_id = ?
571
+ ORDER BY path ASC`,
572
+ [row.run_id]
573
+ ).forEach((entry) => {
574
+ entries.set(String(entry.path || '').trim(), parseState(entry.state_json))
575
+ })
576
+
577
+ return {
578
+ runId: row.run_id,
579
+ repoRoot: String(row.repo_root || ''),
580
+ headOid: String(row.head_oid || ''),
581
+ branchLabel: String(row.branch_label || ''),
582
+ createdAt: row.created_at,
583
+ entries,
584
+ }
585
+ }
586
+
587
+ function saveTaskBaseline(taskSlug = '', repoRoot = '', headOid = '', branchLabel = '', entries = new Map()) {
588
+ const now = new Date().toISOString()
589
+ const normalizedTaskSlug = String(taskSlug || '').trim()
590
+
591
+ transaction(() => {
592
+ run('DELETE FROM task_git_baseline_entries WHERE task_slug = ?', [normalizedTaskSlug])
593
+ run('DELETE FROM task_git_baselines WHERE task_slug = ?', [normalizedTaskSlug])
594
+ run(
595
+ `INSERT INTO task_git_baselines (task_slug, repo_root, head_oid, branch_label, created_at, updated_at)
596
+ VALUES (?, ?, ?, ?, ?, ?)`,
597
+ [normalizedTaskSlug, repoRoot, headOid, branchLabel, now, now]
598
+ )
599
+
600
+ entries.forEach((state, filePath) => {
601
+ run(
602
+ `INSERT INTO task_git_baseline_entries (task_slug, path, state_json)
603
+ VALUES (?, ?, ?)`,
604
+ [normalizedTaskSlug, filePath, serializeState(state)]
605
+ )
606
+ })
607
+ })
608
+
609
+ return loadTaskBaseline(normalizedTaskSlug)
610
+ }
611
+
612
+ function saveRunBaseline(runId = '', repoRoot = '', headOid = '', branchLabel = '', entries = new Map()) {
613
+ const now = new Date().toISOString()
614
+ const normalizedRunId = String(runId || '').trim()
615
+
616
+ transaction(() => {
617
+ run('DELETE FROM run_git_baseline_entries WHERE run_id = ?', [normalizedRunId])
618
+ run('DELETE FROM run_git_baselines WHERE run_id = ?', [normalizedRunId])
619
+ run(
620
+ `INSERT INTO run_git_baselines (run_id, repo_root, head_oid, branch_label, created_at)
621
+ VALUES (?, ?, ?, ?, ?)`,
622
+ [normalizedRunId, repoRoot, headOid, branchLabel, now]
623
+ )
624
+
625
+ entries.forEach((state, filePath) => {
626
+ run(
627
+ `INSERT INTO run_git_baseline_entries (run_id, path, state_json)
628
+ VALUES (?, ?, ?)`,
629
+ [normalizedRunId, filePath, serializeState(state)]
630
+ )
631
+ })
632
+ })
633
+
634
+ return loadRunBaseline(normalizedRunId)
635
+ }
636
+
637
+ function saveRunFinalSnapshot(runId = '', repoRoot = '', headOid = '', branchLabel = '', entries = new Map()) {
638
+ const now = new Date().toISOString()
639
+ const normalizedRunId = String(runId || '').trim()
640
+
641
+ transaction(() => {
642
+ run('DELETE FROM run_git_final_snapshot_entries WHERE run_id = ?', [normalizedRunId])
643
+ run('DELETE FROM run_git_final_snapshots WHERE run_id = ?', [normalizedRunId])
644
+ run(
645
+ `INSERT INTO run_git_final_snapshots (run_id, repo_root, head_oid, branch_label, created_at)
646
+ VALUES (?, ?, ?, ?, ?)`,
647
+ [normalizedRunId, repoRoot, headOid, branchLabel, now]
648
+ )
649
+
650
+ entries.forEach((state, filePath) => {
651
+ run(
652
+ `INSERT INTO run_git_final_snapshot_entries (run_id, path, state_json)
653
+ VALUES (?, ?, ?)`,
654
+ [normalizedRunId, filePath, serializeState(state)]
655
+ )
656
+ })
657
+ })
658
+
659
+ return loadRunFinalSnapshot(normalizedRunId)
660
+ }
661
+
662
+ function resolveTaskRepoRoot(taskSlug = '') {
663
+ const task = getTaskBySlug(taskSlug)
664
+ if (!task || task.expired) {
665
+ return ''
666
+ }
667
+
668
+ const sessionId = String(task.codexSessionId || '').trim()
669
+ if (!sessionId) {
670
+ return ''
671
+ }
672
+
673
+ const session = getPromptxCodexSessionById(sessionId)
674
+ if (!session) {
675
+ return ''
676
+ }
677
+
678
+ return resolveGitRepoRoot(session.cwd)
679
+ }
680
+
681
+ function getRunTaskSlug(runId = '') {
682
+ const row = get(
683
+ `SELECT task_slug
684
+ FROM codex_runs
685
+ WHERE id = ?`,
686
+ [String(runId || '').trim()]
687
+ )
688
+
689
+ return String(row?.task_slug || '').trim()
690
+ }
691
+
692
+ export function captureTaskGitBaseline(taskSlug = '', cwd = '') {
693
+ const normalizedTaskSlug = String(taskSlug || '').trim()
694
+ if (!normalizedTaskSlug) {
695
+ return null
696
+ }
697
+
698
+ const repoRoot = resolveGitRepoRoot(cwd)
699
+ if (!repoRoot) {
700
+ return null
701
+ }
702
+
703
+ const existing = loadTaskBaseline(normalizedTaskSlug)
704
+ if (existing?.repoRoot === repoRoot) {
705
+ return existing
706
+ }
707
+
708
+ const { headOid, snapshots } = captureDirtySnapshots(repoRoot)
709
+ return saveTaskBaseline(normalizedTaskSlug, repoRoot, headOid, resolveGitBranchLabel(repoRoot), snapshots)
710
+ }
711
+
712
+ export function captureRunGitBaseline(runId = '', cwd = '') {
713
+ const normalizedRunId = String(runId || '').trim()
714
+ if (!normalizedRunId) {
715
+ return null
716
+ }
717
+
718
+ const repoRoot = resolveGitRepoRoot(cwd)
719
+ if (!repoRoot) {
720
+ return null
721
+ }
722
+
723
+ const { headOid, snapshots } = captureDirtySnapshots(repoRoot)
724
+ return saveRunBaseline(normalizedRunId, repoRoot, headOid, resolveGitBranchLabel(repoRoot), snapshots)
725
+ }
726
+
727
+ export function captureRunGitFinalSnapshot(runId = '', cwd = '') {
728
+ const normalizedRunId = String(runId || '').trim()
729
+ if (!normalizedRunId) {
730
+ return null
731
+ }
732
+
733
+ const existing = loadRunFinalSnapshot(normalizedRunId)
734
+ if (existing) {
735
+ return existing
736
+ }
737
+
738
+ const baseline = loadRunBaseline(normalizedRunId)
739
+ const repoRoot = resolveGitRepoRoot(cwd) || resolveGitRepoRoot(baseline?.repoRoot || '')
740
+ if (!repoRoot) {
741
+ return null
742
+ }
743
+
744
+ const { headOid, snapshots } = captureDirtySnapshots(repoRoot)
745
+ return saveRunFinalSnapshot(normalizedRunId, repoRoot, headOid, resolveGitBranchLabel(repoRoot), snapshots)
746
+ }
747
+
748
+ function parsePatchStats(patch = '') {
749
+ let additions = 0
750
+ let deletions = 0
751
+
752
+ String(patch || '').split('\n').forEach((line) => {
753
+ if (!line || line.startsWith('+++') || line.startsWith('---')) {
754
+ return
755
+ }
756
+ if (line.startsWith('+')) {
757
+ additions += 1
758
+ return
759
+ }
760
+ if (line.startsWith('-')) {
761
+ deletions += 1
762
+ }
763
+ })
764
+
765
+ return { additions, deletions }
766
+ }
767
+
768
+ function parseNumstat(output = '') {
769
+ const line = String(output || '')
770
+ .split('\n')
771
+ .map((entry) => entry.trim())
772
+ .filter(Boolean)[0] || ''
773
+
774
+ if (!line) {
775
+ return {
776
+ additions: 0,
777
+ deletions: 0,
778
+ }
779
+ }
780
+
781
+ const [rawAdditions, rawDeletions] = line.split('\t')
782
+ return {
783
+ additions: rawAdditions === '-' ? 0 : Math.max(0, Number(rawAdditions) || 0),
784
+ deletions: rawDeletions === '-' ? 0 : Math.max(0, Number(rawDeletions) || 0),
785
+ }
786
+ }
787
+
788
+ function buildDiffPayloadForFile(filePath = '', previousState = null, nextState = null, options = {}) {
789
+ const includePatch = Boolean(options.includePatch)
790
+ const includeStats = includePatch || Boolean(options.includeStats)
791
+ const cacheKey = JSON.stringify([
792
+ String(filePath || '').trim(),
793
+ includePatch,
794
+ includeStats,
795
+ Boolean(previousState?.exists),
796
+ Boolean(previousState?.isBinary),
797
+ Boolean(previousState?.tooLarge),
798
+ String(previousState?.hash || ''),
799
+ Boolean(nextState?.exists),
800
+ Boolean(nextState?.isBinary),
801
+ Boolean(nextState?.tooLarge),
802
+ String(nextState?.hash || ''),
803
+ ])
804
+ const cachedPayload = getCachedValue(fileDiffCache, cacheKey, FILE_DIFF_CACHE_TTL_MS, 'fileMisses', {
805
+ channel: 'file',
806
+ cacheName: 'file-diff',
807
+ debugMeta: {
808
+ path: String(filePath || '').trim(),
809
+ includePatch,
810
+ includeStats,
811
+ },
812
+ })
813
+ if (cachedPayload) {
814
+ gitDiffCacheMetrics.fileHits += 1
815
+ return cachedPayload
816
+ }
817
+
818
+ if ((previousState?.isBinary || nextState?.isBinary)) {
819
+ const payload = {
820
+ binary: true,
821
+ tooLarge: false,
822
+ patch: '',
823
+ patchLoaded: true,
824
+ additions: 0,
825
+ deletions: 0,
826
+ statsLoaded: true,
827
+ message: '二进制文件暂不支持在线 diff 预览。',
828
+ }
829
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
830
+ channel: 'file',
831
+ cacheName: 'file-diff',
832
+ debugMeta: {
833
+ path: String(filePath || '').trim(),
834
+ includePatch,
835
+ includeStats,
836
+ },
837
+ })
838
+ return payload
839
+ }
840
+
841
+ if (previousState?.tooLarge || nextState?.tooLarge) {
842
+ const payload = {
843
+ binary: false,
844
+ tooLarge: true,
845
+ patch: '',
846
+ patchLoaded: true,
847
+ additions: 0,
848
+ deletions: 0,
849
+ statsLoaded: true,
850
+ message: '文件内容较大,暂不展示具体 diff。',
851
+ }
852
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
853
+ channel: 'file',
854
+ cacheName: 'file-diff',
855
+ debugMeta: {
856
+ path: String(filePath || '').trim(),
857
+ includePatch,
858
+ includeStats,
859
+ },
860
+ })
861
+ return payload
862
+ }
863
+
864
+ if (!includeStats) {
865
+ const payload = {
866
+ binary: false,
867
+ tooLarge: false,
868
+ patch: '',
869
+ patchLoaded: false,
870
+ additions: null,
871
+ deletions: null,
872
+ statsLoaded: false,
873
+ message: '',
874
+ }
875
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
876
+ channel: 'file',
877
+ cacheName: 'file-diff',
878
+ debugMeta: {
879
+ path: String(filePath || '').trim(),
880
+ includePatch,
881
+ includeStats,
882
+ },
883
+ })
884
+ return payload
885
+ }
886
+
887
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptx-git-diff-'))
888
+ const previousPath = path.join(tempDir, 'before')
889
+ const nextPath = path.join(tempDir, 'after')
890
+ const nullPath = process.platform === 'win32'
891
+ ? path.join(tempDir, 'null')
892
+ : '/dev/null'
893
+
894
+ try {
895
+ if (process.platform === 'win32') {
896
+ fs.writeFileSync(nullPath, '', 'utf8')
897
+ }
898
+
899
+ if (previousState?.exists) {
900
+ fs.writeFileSync(previousPath, previousState.text || '', 'utf8')
901
+ }
902
+ if (nextState?.exists) {
903
+ fs.writeFileSync(nextPath, nextState.text || '', 'utf8')
904
+ }
905
+
906
+ const statsResult = runGit(tempDir, [
907
+ 'diff',
908
+ '--no-index',
909
+ '--numstat',
910
+ previousState?.exists ? previousPath : nullPath,
911
+ nextState?.exists ? nextPath : nullPath,
912
+ ])
913
+ const numstat = parseNumstat(statsResult.stdout)
914
+
915
+ if (!includePatch) {
916
+ const payload = {
917
+ binary: false,
918
+ tooLarge: false,
919
+ patch: '',
920
+ patchLoaded: false,
921
+ additions: numstat.additions,
922
+ deletions: numstat.deletions,
923
+ statsLoaded: true,
924
+ message: '',
925
+ }
926
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
927
+ channel: 'file',
928
+ cacheName: 'file-diff',
929
+ debugMeta: {
930
+ path: String(filePath || '').trim(),
931
+ includePatch,
932
+ includeStats,
933
+ },
934
+ })
935
+ return payload
936
+ }
937
+
938
+ const result = runGit(tempDir, [
939
+ 'diff',
940
+ '--no-index',
941
+ '--no-color',
942
+ '--unified=3',
943
+ previousState?.exists ? previousPath : nullPath,
944
+ nextState?.exists ? nextPath : nullPath,
945
+ ])
946
+
947
+ let patch = String(result.stdout || '').trim()
948
+ if (patch) {
949
+ patch = patch
950
+ .replace(/^diff --git .*$|^diff --git[^\n]*$/m, `diff --git ${previousState?.exists ? `a/${filePath}` : '/dev/null'} ${nextState?.exists ? `b/${filePath}` : '/dev/null'}`)
951
+ .replace(/^--- .*$/m, previousState?.exists ? `--- a/${filePath}` : '--- /dev/null')
952
+ .replace(/^\+\+\+ .*$/m, nextState?.exists ? `+++ b/${filePath}` : '+++ /dev/null')
953
+ }
954
+ const stats = parsePatchStats(patch)
955
+
956
+ if (!patch) {
957
+ const payload = {
958
+ binary: false,
959
+ tooLarge: false,
960
+ patch: '',
961
+ patchLoaded: true,
962
+ additions: stats.additions,
963
+ deletions: stats.deletions,
964
+ statsLoaded: true,
965
+ message: '',
966
+ }
967
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
968
+ channel: 'file',
969
+ cacheName: 'file-diff',
970
+ debugMeta: {
971
+ path: String(filePath || '').trim(),
972
+ includePatch,
973
+ includeStats,
974
+ },
975
+ })
976
+ return payload
977
+ }
978
+
979
+ if (patch.length > MAX_PATCH_TEXT_BYTES) {
980
+ const payload = {
981
+ binary: false,
982
+ tooLarge: true,
983
+ patch: '',
984
+ patchLoaded: true,
985
+ additions: stats.additions,
986
+ deletions: stats.deletions,
987
+ statsLoaded: true,
988
+ message: 'diff 内容较长,暂不在页面内完整展示。',
989
+ }
990
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
991
+ channel: 'file',
992
+ cacheName: 'file-diff',
993
+ debugMeta: {
994
+ path: String(filePath || '').trim(),
995
+ includePatch,
996
+ includeStats,
997
+ },
998
+ })
999
+ return payload
1000
+ }
1001
+
1002
+ const payload = {
1003
+ binary: false,
1004
+ tooLarge: false,
1005
+ patch,
1006
+ patchLoaded: true,
1007
+ additions: stats.additions,
1008
+ deletions: stats.deletions,
1009
+ statsLoaded: true,
1010
+ message: '',
1011
+ }
1012
+ setCachedValue(fileDiffCache, cacheKey, payload, FILE_DIFF_CACHE_MAX_ENTRIES, {
1013
+ channel: 'file',
1014
+ cacheName: 'file-diff',
1015
+ debugMeta: {
1016
+ path: String(filePath || '').trim(),
1017
+ includePatch,
1018
+ includeStats,
1019
+ },
1020
+ })
1021
+ return payload
1022
+ } finally {
1023
+ fs.rmSync(tempDir, { recursive: true, force: true })
1024
+ }
1025
+ }
1026
+
1027
+ function deriveFileStatus(previousState, nextState) {
1028
+ if (!previousState?.exists && nextState?.exists) {
1029
+ return 'A'
1030
+ }
1031
+ if (previousState?.exists && !nextState?.exists) {
1032
+ return 'D'
1033
+ }
1034
+ return 'M'
1035
+ }
1036
+
1037
+ function sortDiffFiles(items = []) {
1038
+ const weightMap = {
1039
+ A: 0,
1040
+ M: 1,
1041
+ D: 2,
1042
+ }
1043
+
1044
+ return [...items].sort((left, right) => {
1045
+ const statusDiff = (weightMap[left.status] ?? 9) - (weightMap[right.status] ?? 9)
1046
+ if (statusDiff) {
1047
+ return statusDiff
1048
+ }
1049
+ return String(left.path || '').localeCompare(String(right.path || ''), 'zh-CN')
1050
+ })
1051
+ }
1052
+
1053
+ function createDiffFileEntry(filePath = '', previousState = null, nextState = null, options = {}) {
1054
+ if (areFileStatesEqual(previousState, nextState)) {
1055
+ return null
1056
+ }
1057
+
1058
+ const patchPayload = buildDiffPayloadForFile(filePath, previousState, nextState, options)
1059
+ return {
1060
+ path: filePath,
1061
+ status: deriveFileStatus(previousState, nextState),
1062
+ additions: patchPayload.additions,
1063
+ deletions: patchPayload.deletions,
1064
+ statsLoaded: patchPayload.statsLoaded,
1065
+ binary: patchPayload.binary,
1066
+ tooLarge: patchPayload.tooLarge,
1067
+ patch: patchPayload.patch,
1068
+ patchLoaded: patchPayload.patchLoaded,
1069
+ message: patchPayload.message,
1070
+ }
1071
+ }
1072
+
1073
+ function createUnsupportedResult(reason = '', repoRoot = '', branch = '') {
1074
+ return {
1075
+ supported: false,
1076
+ reason,
1077
+ repoRoot,
1078
+ branch,
1079
+ baseline: null,
1080
+ warnings: [],
1081
+ summary: {
1082
+ fileCount: 0,
1083
+ additions: 0,
1084
+ deletions: 0,
1085
+ },
1086
+ files: [],
1087
+ }
1088
+ }
1089
+
1090
+ export function getWorkspaceGitDiffReviewByCwd(cwd = '', options = {}) {
1091
+ const repoRoot = resolveGitRepoRoot(cwd)
1092
+ if (!repoRoot) {
1093
+ return createUnsupportedResult('当前工作目录不是 Git 仓库,暂不支持代码变更审查。')
1094
+ }
1095
+
1096
+ const branch = resolveGitBranchLabel(repoRoot)
1097
+ const workspaceStatusSignature = resolveWorkspaceStatusSignature(repoRoot)
1098
+ const currentHeadOid = resolveGitHeadOid(repoRoot)
1099
+ const targetFilePath = String(options.filePath || '').trim()
1100
+ const includeFiles = targetFilePath || options.includeFiles !== false
1101
+ const includeStats = targetFilePath || options.includeStats !== false
1102
+ const cacheKey = JSON.stringify([
1103
+ 'workspace',
1104
+ repoRoot,
1105
+ branch,
1106
+ currentHeadOid,
1107
+ workspaceStatusSignature,
1108
+ targetFilePath,
1109
+ includeFiles,
1110
+ includeStats,
1111
+ ])
1112
+ const cachedReview = getCachedValue(diffReviewCache, cacheKey, DIFF_REVIEW_CACHE_TTL_MS, 'reviewMisses', {
1113
+ channel: 'review',
1114
+ cacheName: 'diff-review',
1115
+ debugMeta: {
1116
+ scope: 'workspace',
1117
+ repo: path.basename(repoRoot),
1118
+ filePath: targetFilePath,
1119
+ includeFiles,
1120
+ includeStats,
1121
+ },
1122
+ })
1123
+ if (cachedReview) {
1124
+ gitDiffCacheMetrics.reviewHits += 1
1125
+ return cachedReview
1126
+ }
1127
+
1128
+ const { headOid, entries: workingTreeEntries } = listGitChangeEntries(repoRoot)
1129
+ const baselineStateForPath = createBaselineStateResolver(repoRoot, {
1130
+ headOid,
1131
+ entries: new Map(),
1132
+ })
1133
+ const files = []
1134
+ let additions = 0
1135
+ let deletions = 0
1136
+ let fileCount = 0
1137
+ const candidatePaths = targetFilePath ? [targetFilePath] : [...workingTreeEntries.keys()]
1138
+
1139
+ candidatePaths.forEach((filePath) => {
1140
+ const previousState = baselineStateForPath(filePath)
1141
+ const nextState = readFileState(repoRoot, filePath)
1142
+ const diffEntry = createDiffFileEntry(filePath, previousState, nextState, {
1143
+ includePatch: Boolean(targetFilePath),
1144
+ includeStats,
1145
+ })
1146
+ if (!diffEntry) {
1147
+ return
1148
+ }
1149
+
1150
+ fileCount += 1
1151
+ additions += Math.max(0, Number(diffEntry.additions) || 0)
1152
+ deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1153
+ if (includeFiles) {
1154
+ files.push(diffEntry)
1155
+ }
1156
+ })
1157
+
1158
+ const payload = {
1159
+ supported: true,
1160
+ scope: 'workspace',
1161
+ runId: '',
1162
+ repoRoot,
1163
+ branch,
1164
+ baseline: null,
1165
+ warnings: [],
1166
+ baselineCreatedAt: '',
1167
+ summary: {
1168
+ fileCount,
1169
+ additions: includeStats ? additions : null,
1170
+ deletions: includeStats ? deletions : null,
1171
+ statsComplete: includeStats,
1172
+ },
1173
+ files: includeFiles ? sortDiffFiles(files) : [],
1174
+ }
1175
+ setCachedValue(diffReviewCache, cacheKey, payload, DIFF_REVIEW_CACHE_MAX_ENTRIES, {
1176
+ channel: 'review',
1177
+ cacheName: 'diff-review',
1178
+ debugMeta: {
1179
+ scope: 'workspace',
1180
+ repo: path.basename(repoRoot),
1181
+ fileCount,
1182
+ includeFiles,
1183
+ includeStats,
1184
+ },
1185
+ })
1186
+ return payload
1187
+ }
1188
+
1189
+ export function getTaskGitDiffReview(taskSlug = '', options = {}) {
1190
+ const normalizedTaskSlug = String(taskSlug || '').trim()
1191
+ if (!normalizedTaskSlug) {
1192
+ return createUnsupportedResult('任务不存在。')
1193
+ }
1194
+
1195
+ const rawScope = String(options.scope || 'workspace').trim()
1196
+ const scope = rawScope === 'run'
1197
+ ? 'run'
1198
+ : rawScope === 'task'
1199
+ ? 'task'
1200
+ : 'workspace'
1201
+ const runId = String(options.runId || '').trim()
1202
+ const targetFilePath = String(options.filePath || '').trim()
1203
+ const includeFiles = targetFilePath || options.includeFiles !== false
1204
+ const includeStats = targetFilePath || options.includeStats !== false
1205
+
1206
+ if (scope === 'workspace') {
1207
+ return getWorkspaceGitDiffReviewByCwd(resolveTaskRepoRoot(normalizedTaskSlug), {
1208
+ filePath: targetFilePath,
1209
+ })
1210
+ }
1211
+
1212
+ let baseline = null
1213
+ let comparisonSnapshot = null
1214
+ if (scope === 'run') {
1215
+ if (!runId) {
1216
+ return createUnsupportedResult('请选择一轮执行后再查看本轮代码变更。')
1217
+ }
1218
+ if (getRunTaskSlug(runId) !== normalizedTaskSlug) {
1219
+ return createUnsupportedResult('没有找到对应的执行记录。')
1220
+ }
1221
+ baseline = loadRunBaseline(runId)
1222
+ comparisonSnapshot = loadRunFinalSnapshot(runId)
1223
+ } else {
1224
+ baseline = loadTaskBaseline(normalizedTaskSlug)
1225
+ }
1226
+
1227
+ if (!baseline) {
1228
+ const fallbackRepoRoot = resolveTaskRepoRoot(normalizedTaskSlug)
1229
+ if (!fallbackRepoRoot) {
1230
+ return createUnsupportedResult('当前工作目录不是 Git 仓库,暂不支持代码变更审查。')
1231
+ }
1232
+
1233
+ return createUnsupportedResult(
1234
+ scope === 'run'
1235
+ ? '这轮执行还没有建立代码变更基线,暂时无法查看本轮 diff。'
1236
+ : '当前任务还没有建立代码变更基线,请先让 Codex 执行一轮。',
1237
+ fallbackRepoRoot,
1238
+ resolveGitBranchLabel(fallbackRepoRoot)
1239
+ )
1240
+ }
1241
+
1242
+ if (scope === 'run' && !comparisonSnapshot) {
1243
+ const fallbackRepoRoot = resolveGitRepoRoot(baseline.repoRoot) || resolveTaskRepoRoot(normalizedTaskSlug)
1244
+ return createUnsupportedResult(
1245
+ '这轮执行缺少结束快照,暂时无法准确还原本轮代码变更。',
1246
+ fallbackRepoRoot,
1247
+ fallbackRepoRoot ? resolveGitBranchLabel(fallbackRepoRoot) : ''
1248
+ )
1249
+ }
1250
+
1251
+ const repoRoot = resolveGitRepoRoot(baseline.repoRoot)
1252
+ if (!repoRoot) {
1253
+ return createUnsupportedResult('原工作目录已不是有效的 Git 仓库,暂时无法读取代码变更。', baseline.repoRoot)
1254
+ }
1255
+ const currentBranchLabel = resolveGitBranchLabel(repoRoot)
1256
+ const branch = scope === 'run' && comparisonSnapshot?.branchLabel
1257
+ ? String(comparisonSnapshot.branchLabel || '')
1258
+ : currentBranchLabel
1259
+ const currentHeadOid = scope === 'run' && comparisonSnapshot
1260
+ ? String(comparisonSnapshot.headOid || '')
1261
+ : resolveGitHeadOid(repoRoot)
1262
+ const workspaceStatusSignature = scope === 'run' && comparisonSnapshot
1263
+ ? ''
1264
+ : resolveWorkspaceStatusSignature(repoRoot)
1265
+ const warnings = []
1266
+
1267
+ if (baseline.headOid) {
1268
+ if (!commitExists(repoRoot, baseline.headOid)) {
1269
+ return createUnsupportedResult(
1270
+ '基线对应的 commit 已不存在,仓库可能被 reset、rebase 或切换到无关历史,暂时无法准确读取该范围的代码变更。',
1271
+ repoRoot,
1272
+ branch
1273
+ )
1274
+ }
1275
+
1276
+ if (baseline.branchLabel && branch && baseline.branchLabel !== branch) {
1277
+ warnings.push(`当前分支已从 ${baseline.branchLabel} 切换到 ${branch}`)
1278
+ }
1279
+
1280
+ if (currentHeadOid && baseline.headOid !== currentHeadOid && !isAncestorCommit(repoRoot, baseline.headOid, currentHeadOid)) {
1281
+ warnings.push('当前 HEAD 已不在基线 commit 的后续历史中,仓库可能经历了 reset、rebase 或切分支')
1282
+ }
1283
+ }
1284
+
1285
+ const cacheKey = JSON.stringify([
1286
+ scope,
1287
+ normalizedTaskSlug,
1288
+ runId,
1289
+ repoRoot,
1290
+ branch,
1291
+ currentHeadOid,
1292
+ workspaceStatusSignature,
1293
+ baseline.createdAt,
1294
+ baseline.headOid,
1295
+ baseline.branchLabel,
1296
+ baseline.entries.size,
1297
+ comparisonSnapshot?.createdAt || '',
1298
+ comparisonSnapshot?.headOid || '',
1299
+ comparisonSnapshot?.branchLabel || '',
1300
+ comparisonSnapshot?.entries?.size || 0,
1301
+ targetFilePath,
1302
+ includeFiles,
1303
+ includeStats,
1304
+ ])
1305
+ const cachedReview = getCachedValue(diffReviewCache, cacheKey, DIFF_REVIEW_CACHE_TTL_MS, 'reviewMisses', {
1306
+ channel: 'review',
1307
+ cacheName: 'diff-review',
1308
+ debugMeta: {
1309
+ scope,
1310
+ task: normalizedTaskSlug,
1311
+ runId,
1312
+ repo: path.basename(repoRoot),
1313
+ filePath: targetFilePath,
1314
+ includeFiles,
1315
+ includeStats,
1316
+ },
1317
+ })
1318
+ if (cachedReview) {
1319
+ gitDiffCacheMetrics.reviewHits += 1
1320
+ return cachedReview
1321
+ }
1322
+
1323
+ const { entries: workingTreeEntries } = listGitChangeEntries(repoRoot)
1324
+ const baselineStateForPath = createBaselineStateResolver(repoRoot, baseline)
1325
+ const nextStateForPath = scope === 'run' && comparisonSnapshot
1326
+ ? createBaselineStateResolver(repoRoot, comparisonSnapshot)
1327
+ : (filePath) => readFileState(repoRoot, filePath)
1328
+ const files = []
1329
+ let additions = 0
1330
+ let deletions = 0
1331
+ let fileCount = 0
1332
+
1333
+ const candidatePaths = targetFilePath
1334
+ ? [targetFilePath]
1335
+ : new Set([
1336
+ ...baseline.entries.keys(),
1337
+ ...listCommittedChangeEntries(repoRoot, baseline.headOid, currentHeadOid).keys(),
1338
+ ...(comparisonSnapshot ? comparisonSnapshot.entries.keys() : workingTreeEntries.keys()),
1339
+ ])
1340
+
1341
+ candidatePaths.forEach((filePath) => {
1342
+ const previousState = baselineStateForPath(filePath)
1343
+ const nextState = nextStateForPath(filePath)
1344
+ const diffEntry = createDiffFileEntry(filePath, previousState, nextState, {
1345
+ includePatch: Boolean(targetFilePath),
1346
+ includeStats,
1347
+ })
1348
+ if (!diffEntry) {
1349
+ return
1350
+ }
1351
+
1352
+ fileCount += 1
1353
+ additions += Math.max(0, Number(diffEntry.additions) || 0)
1354
+ deletions += Math.max(0, Number(diffEntry.deletions) || 0)
1355
+ if (includeFiles) {
1356
+ files.push(diffEntry)
1357
+ }
1358
+ })
1359
+
1360
+ const payload = {
1361
+ supported: true,
1362
+ scope,
1363
+ runId: scope === 'run' ? runId : '',
1364
+ repoRoot,
1365
+ branch,
1366
+ baseline: {
1367
+ createdAt: baseline.createdAt,
1368
+ headOid: baseline.headOid,
1369
+ headShort: resolveShortOid(baseline.headOid),
1370
+ branch: baseline.branchLabel || '',
1371
+ currentHeadOid,
1372
+ currentHeadShort: resolveShortOid(currentHeadOid),
1373
+ },
1374
+ warnings,
1375
+ baselineCreatedAt: baseline.createdAt,
1376
+ summary: {
1377
+ fileCount,
1378
+ additions: includeStats ? additions : null,
1379
+ deletions: includeStats ? deletions : null,
1380
+ statsComplete: includeStats,
1381
+ },
1382
+ files: includeFiles ? sortDiffFiles(files) : [],
1383
+ }
1384
+ setCachedValue(diffReviewCache, cacheKey, payload, DIFF_REVIEW_CACHE_MAX_ENTRIES, {
1385
+ channel: 'review',
1386
+ cacheName: 'diff-review',
1387
+ debugMeta: {
1388
+ scope,
1389
+ task: normalizedTaskSlug,
1390
+ runId,
1391
+ repo: path.basename(repoRoot),
1392
+ fileCount,
1393
+ includeFiles,
1394
+ includeStats,
1395
+ },
1396
+ })
1397
+ return payload
1398
+ }
1399
+
1400
+ export function __resetGitDiffCachesForTest() {
1401
+ diffReviewCache.clear()
1402
+ fileDiffCache.clear()
1403
+ gitDiffCacheMetrics.reviewHits = 0
1404
+ gitDiffCacheMetrics.reviewMisses = 0
1405
+ gitDiffCacheMetrics.fileHits = 0
1406
+ gitDiffCacheMetrics.fileMisses = 0
1407
+ }
1408
+
1409
+ export function __getGitDiffCacheMetricsForTest() {
1410
+ return {
1411
+ reviewHits: gitDiffCacheMetrics.reviewHits,
1412
+ reviewMisses: gitDiffCacheMetrics.reviewMisses,
1413
+ fileHits: gitDiffCacheMetrics.fileHits,
1414
+ fileMisses: gitDiffCacheMetrics.fileMisses,
1415
+ }
1416
+ }
1417
+
1418
+ export function getGitDiffCacheDebugSnapshot() {
1419
+ return {
1420
+ debugEnabled: isGitDiffDebugEnabled(),
1421
+ reviewCacheSize: diffReviewCache.size,
1422
+ fileCacheSize: fileDiffCache.size,
1423
+ metrics: __getGitDiffCacheMetricsForTest(),
1424
+ }
1425
+ }