session-diff.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
  2. import { parsePatch } from "diff"
  3. import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
  4. type LegacyDiff = {
  5. file: string
  6. patch?: string
  7. before?: string
  8. after?: string
  9. additions: number
  10. deletions: number
  11. status?: "added" | "deleted" | "modified"
  12. }
  13. type SnapshotDiff = SnapshotFileDiff & { file: string }
  14. type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
  15. export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
  16. export type ViewDiff = {
  17. file: string
  18. additions: number
  19. deletions: number
  20. status?: "added" | "deleted" | "modified"
  21. fileDiff: FileDiffMetadata
  22. }
  23. const diffCacheLimit = 16
  24. const patchFileDiffCache = new Map<string, FileDiffMetadata>()
  25. export function resolveFileDiff(diff: DiffSource) {
  26. if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
  27. return fileDiffFromContent(
  28. diff.file,
  29. typeof diff.before === "string" ? diff.before : "",
  30. typeof diff.after === "string" ? diff.after : "",
  31. )
  32. }
  33. export function normalize(diff: ReviewDiff): ViewDiff {
  34. return {
  35. file: diff.file,
  36. additions: diff.additions,
  37. deletions: diff.deletions,
  38. status: diff.status,
  39. fileDiff: resolveFileDiff(diff),
  40. }
  41. }
  42. export function text(diff: ViewDiff, side: "deletions" | "additions") {
  43. if (side === "deletions") return diff.fileDiff.deletionLines.join("")
  44. return diff.fileDiff.additionLines.join("")
  45. }
  46. function fileDiffFromPatch(file: string, patch: string) {
  47. const key = `${file}\0${patch}`
  48. const hit = patchFileDiffCache.get(key)
  49. if (hit) {
  50. patchFileDiffCache.delete(key)
  51. patchFileDiffCache.set(key, hit)
  52. return hit
  53. }
  54. const contents = completePatchContents(patch)
  55. const input = contents ? undefined : patchInput(file, patch)
  56. const value = contents
  57. ? fileDiffFromContent(file, contents.before, contents.after)
  58. : ((input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file))
  59. patchFileDiffCache.set(key, value)
  60. while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
  61. return value
  62. }
  63. function completePatchContents(patch: string) {
  64. try {
  65. const parsed = parsePatch(patch)[0]
  66. if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return
  67. // Snapshot and VCS producers request full context. Tool patches use jsdiff's shorter default context.
  68. if (!patch.startsWith("diff --git ") && !/^--- [^\n]*\t\r?\n\+\+\+ [^\n]*\t(?:\r?\n|$)/m.test(patch)) return
  69. // Full patches collapse into one leading hunk. Separated hunks omit ranges and must stay partial.
  70. if (parsed.hunks.length !== 1) return
  71. const hunk = parsed.hunks[0]
  72. if (!hunk || hunk.oldStart > 1 || hunk.newStart > 1) return
  73. const before: Array<{ text: string; newline: boolean }> = []
  74. const after: Array<{ text: string; newline: boolean }> = []
  75. let previous: "-" | "+" | " " | undefined
  76. for (const line of hunk.lines) {
  77. if (line.startsWith("\\")) {
  78. if (previous === "-" || previous === " ") {
  79. const value = before.at(-1)
  80. if (value) value.newline = false
  81. }
  82. if (previous === "+" || previous === " ") {
  83. const value = after.at(-1)
  84. if (value) value.newline = false
  85. }
  86. continue
  87. }
  88. if (line.startsWith("-")) {
  89. before.push({ text: line.slice(1), newline: true })
  90. previous = "-"
  91. continue
  92. }
  93. if (line.startsWith("+")) {
  94. after.push({ text: line.slice(1), newline: true })
  95. previous = "+"
  96. continue
  97. }
  98. if (!line.startsWith(" ")) return
  99. before.push({ text: line.slice(1), newline: true })
  100. after.push({ text: line.slice(1), newline: true })
  101. previous = " "
  102. }
  103. const text = (lines: Array<{ text: string; newline: boolean }>) =>
  104. lines.map((line) => line.text + (line.newline ? "\n" : "")).join("")
  105. return { before: text(before), after: text(after) }
  106. } catch {
  107. return
  108. }
  109. }
  110. function patchInput(file: string, patch: string) {
  111. try {
  112. const parsed = parsePatch(patch)[0]
  113. if (!parsed) return
  114. if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
  115. if (!parsed.hunks.length) return
  116. return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
  117. } catch {
  118. return
  119. }
  120. }
  121. function fileDiffFromContent(file: string, before: string, after: string) {
  122. if (!before && !after) return emptyFileDiff(file)
  123. return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
  124. }
  125. function emptyFileDiff(file: string) {
  126. return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
  127. }