dsh-git-changes 0.0.0-stage → 0.1.1

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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +119 -0
  3. package/README.md +137 -2
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/vendor/monaco/LICENSE +21 -0
  6. package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
  7. package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  8. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
  9. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
  10. package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
  11. package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  12. package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  13. package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  14. package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  15. package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  16. package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  17. package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  18. package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  19. package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  20. package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  21. package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  22. package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  23. package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  24. package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  25. package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  26. package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  27. package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  28. package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  29. package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  30. package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  31. package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  32. package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  33. package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  34. package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  35. package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  36. package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  37. package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  38. package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  39. package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  40. package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  41. package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  42. package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  43. package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  44. package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  45. package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  46. package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  47. package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  48. package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  49. package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  50. package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  51. package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  52. package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  53. package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  54. package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  55. package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  56. package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  57. package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  58. package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  59. package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  60. package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  61. package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  62. package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  63. package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  64. package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  65. package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  66. package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  67. package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  68. package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  69. package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  70. package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  71. package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  72. package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  73. package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  74. package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  75. package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  76. package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  77. package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  78. package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  79. package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  80. package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  81. package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  82. package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  83. package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  84. package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  85. package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  86. package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  87. package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  88. package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  89. package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  90. package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  91. package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
  92. package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
  93. package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
  94. package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
  95. package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
  96. package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
  97. package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  98. package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
  99. package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
  100. package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
  101. package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  102. package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
  103. package/assets/vendor/monaco/vs/loader.js +10 -0
  104. package/cordis.patch.yml +3 -0
  105. package/docs/images/git-changes-diff.png +0 -0
  106. package/docs/regression-checklist.md +47 -0
  107. package/icon.svg +10 -0
  108. package/lib/client.js +9418 -0
  109. package/lib/host/assets.js +161 -0
  110. package/lib/host/commit-message.js +158 -0
  111. package/lib/host/files-index.js +206 -0
  112. package/lib/host/git-write.js +194 -0
  113. package/lib/host/git.js +1147 -0
  114. package/lib/host/lsp.js +842 -0
  115. package/lib/host/routes.js +976 -0
  116. package/lib/host/session.js +84 -0
  117. package/lib/host/util.js +107 -0
  118. package/lib/index.js +33 -0
  119. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-LICENSE +21 -0
  121. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  122. package/locale/en.json +6 -0
  123. package/locale/zh.json +6 -0
  124. package/package.json +65 -4
  125. package/src/client/00-const.js +79 -0
  126. package/src/client/05-utils.js +277 -0
  127. package/src/client/10-i18n.js +548 -0
  128. package/src/client/12-vscode-icons.js +460 -0
  129. package/src/client/15-theme.js +509 -0
  130. package/src/client/20-monaco.js +1404 -0
  131. package/src/client/22-lsp.js +592 -0
  132. package/src/client/25-tree.js +563 -0
  133. package/src/client/28-changes.js +1163 -0
  134. package/src/client/30-page.js +2259 -0
  135. package/src/client/35-pill.js +185 -0
  136. package/src/client/40-commands.js +248 -0
  137. package/src/client/45-css.js +357 -0
  138. package/src/client/47-pickers.js +457 -0
  139. package/src/client/50-apply.js +207 -0
  140. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,1147 @@
1
+ /**
2
+ * Read-only git integration for the review page.
3
+ *
4
+ * Every command runs through the Host `subprocess` capability with a scrubbed
5
+ * environment, a hard timeout, and bounded output; nothing here writes to the
6
+ * repository (no add/commit/stage/fetch). Paths are validated by the route
7
+ * layer before they reach this module.
8
+ */
9
+ import { realpath } from 'node:fs/promises'
10
+ import { extname, isAbsolute, relative, resolve } from 'node:path'
11
+ import { safeRevision } from './util.js'
12
+
13
+ /** Milliseconds a git child gets to exit after termination starts. */
14
+ const TERMINATE_GRACE_MS = 2000
15
+ /** Retained stderr tail for diagnostics. */
16
+ const STDERR_TAIL_BYTES = 16 * 1024
17
+ /** Default command timeout. */
18
+ const TIMEOUT_MS = 15000
19
+ /** Default stdout cap (8 MiB). */
20
+ const OUTPUT_MAX_BYTES = 8 * 1024 * 1024
21
+ /** Text file cap for untracked-file synthesis (2 MiB). */
22
+ const TEXT_MAX_BYTES = 2 * 1024 * 1024
23
+ /** Maximum number of lines to synthesize for an all-added text file. */
24
+ const TEXT_MAX_LINES = 20000
25
+ /** Maximum size served for one full file read (2 MiB, mirrors the file route). */
26
+ const MAX_FILE_BYTES = 2 * 1024 * 1024
27
+ /** Commits listed for the revision picker when the request does not choose a count. */
28
+ const DEFAULT_REVISION_LIMIT = 60
29
+ /** Hard clamp for the revision list size. */
30
+ const MAX_REVISION_LIMIT = 200
31
+ /** Field caps for commit metadata (defensive against hostile commit messages). */
32
+ const SUBJECT_MAX_CHARS = 300
33
+ const AUTHOR_MAX_CHARS = 100
34
+ const REF_MAX_CHARS = 100
35
+ const REFS_MAX_COUNT = 12
36
+ /** Format used for every commit metadata read; fields are NUL-separated and the
37
+ * record ends with the ASCII record separator so subjects keep their bytes. */
38
+ const COMMIT_FORMAT = '%H%x00%h%x00%P%x00%an%x00%aI%x00%D%x00%s%x1e'
39
+ /** Git record separator between formatted commits. */
40
+ const RECORD_SEPARATOR = '\u001e'
41
+ /** Well-known empty tree object ids. Git resolves them even when absent locally. */
42
+ const EMPTY_TREE_SHA1 = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
43
+ const EMPTY_TREE_SHA256 = '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321'
44
+
45
+ /** Extension → true for files the review page treats as code. */
46
+ const BINARY_HINTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.pdf', '.zip', '.gz', '.tgz', '.7z', '.rar', '.tar', '.exe', '.dll', '.so', '.dylib', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.mp4', '.mov', '.avi', '.webm', '.sqlite', '.db', '.class', '.jar', '.wasm', '.o', '.obj', '.a', '.lib', '.pdb'])
47
+
48
+ /**
49
+ * Whether a path is likely binary by extension.
50
+ * @param {string} path path
51
+ * @returns {boolean}
52
+ */
53
+ function binaryByExtension(path) {
54
+ const dot = String(path).toLowerCase().lastIndexOf('.')
55
+ return dot >= 0 && BINARY_HINTS.has(String(path).toLowerCase().slice(dot))
56
+ }
57
+
58
+ /**
59
+ * Runs one resolved git executable with a scrubbed environment, timeout, and
60
+ * bounded output (same contract as the builtin workspace-changes GitRunner).
61
+ */
62
+ export class GitRunner {
63
+ /**
64
+ * @param {object} subprocess Host subprocess service
65
+ * @param {string} executable resolved git path
66
+ * @param {{timeoutMs:number, outputMaxBytes:number}} limits command limits
67
+ */
68
+ constructor(subprocess, executable, limits) {
69
+ this.subprocess = subprocess
70
+ this.executable = executable
71
+ this.limits = limits
72
+ }
73
+
74
+ /**
75
+ * Run `git <args>` to completion.
76
+ * @param {string[]} args git arguments (never shell-interpreted)
77
+ * @param {{cwd:string, signal?:AbortSignal, maxBytes?:number, stdin?:string, env?:Record<string,string>}} options run options
78
+ * @returns {Promise<{exitCode:number|null, stdout:string, stderr:string, truncated:boolean}>}
79
+ */
80
+ async run(args, options) {
81
+ const timeout = AbortSignal.timeout(this.limits.timeoutMs)
82
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
83
+ const handle = this.subprocess.spawn({
84
+ argv: [this.executable, ...args],
85
+ cwd: options.cwd,
86
+ stdio: {
87
+ stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
88
+ stdout: { maxBytes: options.maxBytes ?? this.limits.outputMaxBytes },
89
+ stderr: { maxBytes: STDERR_TAIL_BYTES },
90
+ },
91
+ graceMs: TERMINATE_GRACE_MS,
92
+ signal,
93
+ env: {
94
+ GIT_CONFIG_COUNT: '0',
95
+ GIT_TERMINAL_PROMPT: '0',
96
+ GIT_OPTIONAL_LOCKS: '0',
97
+ LC_ALL: 'C',
98
+ ...(options.env || {}),
99
+ },
100
+ })
101
+ const outcome = await handle.done
102
+ if (signal.aborted) throw new Error('git ' + args.join(' ') + (timeout.aborted ? ' timed out after ' + this.limits.timeoutMs + 'ms' : ' was aborted'))
103
+ const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
104
+ const stderr = handle.collected.stderr?.readFrom(0)?.text ?? ''
105
+ return {
106
+ exitCode: outcome.exitCode,
107
+ stdout: stdout.text,
108
+ stderr,
109
+ truncated: stdout.lossy === true,
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Resolve the git executable once.
116
+ * @param {object} ctx Host context
117
+ * @param {AbortSignal} [signal] cancellation
118
+ * @returns {Promise<string | null>}
119
+ */
120
+ export async function resolveGit(ctx, signal) {
121
+ try {
122
+ return await ctx.subprocess.resolveExecutable('git', undefined, signal)
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create a lazily-resolved runner.
130
+ * @param {object} ctx Host context
131
+ * @returns {() => Promise<GitRunner | null>}
132
+ */
133
+ export function createGitProvider(ctx) {
134
+ let pending
135
+ return () => {
136
+ pending ??= resolveGit(ctx).then((executable) => (executable === null
137
+ ? null
138
+ : new GitRunner(ctx.subprocess, executable, { timeoutMs: TIMEOUT_MS, outputMaxBytes: OUTPUT_MAX_BYTES })))
139
+ return pending
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Locate the repository enclosing a working directory.
145
+ * @param {GitRunner} git runner
146
+ * @param {string} cwd absolute session working directory
147
+ * @param {AbortSignal} signal cancellation
148
+ * @returns {Promise<string | null>} repository work-tree root, or null when not a git work tree
149
+ */
150
+ export async function repositoryRoot(git, cwd, signal) {
151
+ const found = await git.run(['rev-parse', '--show-toplevel'], { cwd, signal })
152
+ if (found.exitCode === 128 && /not a git repository/i.test(found.stderr)) return null
153
+ if (found.exitCode !== 0 || found.truncated) return null
154
+ const root = found.stdout.replace(/\r?\n$/, '')
155
+ return root === '' ? null : root
156
+ }
157
+
158
+ /**
159
+ * The empty tree object id for the repository's object format.
160
+ * @param {GitRunner} git runner
161
+ * @param {string} cwd absolute session working directory
162
+ * @param {AbortSignal} signal cancellation
163
+ * @returns {Promise<string>} full empty-tree hash (SHA-1 fallback)
164
+ */
165
+ export async function emptyTreeHash(git, cwd, signal) {
166
+ const format = await git.run(['rev-parse', '--show-object-format'], { cwd, signal })
167
+ const name = format.exitCode === 0 ? format.stdout.trim() : ''
168
+ return name === 'sha256' ? EMPTY_TREE_SHA256 : EMPTY_TREE_SHA1
169
+ }
170
+
171
+ /**
172
+ * Parse `git status --porcelain=v1 -z` output into review-tree records.
173
+ * Porcelain v1 paths are always relative to the repository root.
174
+ * @param {string} raw NUL-separated status output
175
+ * @returns {Array<{path:string, code:string, indexStatus:string, workTreeStatus:string, renamedFrom?:string}>}
176
+ */
177
+ export function parsePorcelain(raw) {
178
+ const parts = String(raw).split('\0')
179
+ const files = []
180
+ for (let index = 0; index < parts.length; index += 1) {
181
+ const entry = parts[index]
182
+ if (!entry || entry.length < 4) continue
183
+ // `--branch` prepends a `## ...` header token.
184
+ if (entry.startsWith('##')) continue
185
+ const indexStatus = entry.charAt(0)
186
+ const workTreeStatus = entry.charAt(1)
187
+ const path = entry.slice(3)
188
+ if (!path) continue
189
+ const code = indexStatus === '?' || workTreeStatus === '?'
190
+ ? '?'
191
+ : indexStatus !== ' '
192
+ ? indexStatus
193
+ : workTreeStatus !== ' '
194
+ ? workTreeStatus
195
+ : ' '
196
+ const record = { path, code, indexStatus, workTreeStatus }
197
+ // Rename/copy entries carry the original path in the next NUL-separated slot.
198
+ if ((/[RC]/.test(indexStatus) || /[RC]/.test(workTreeStatus)) && index + 1 < parts.length) {
199
+ record.renamedFrom = parts[index + 1]
200
+ index += 1
201
+ }
202
+ files.push(record)
203
+ }
204
+ return files
205
+ }
206
+
207
+ /**
208
+ * Parse a unified diff body into comparison hunks (workspace-changes shape).
209
+ * @param {string} text unified diff text
210
+ * @returns {{hunks:Array<object>, binary:boolean, multipleFiles:boolean}}
211
+ */
212
+ export function parseUnifiedDiff(text) {
213
+ const hunks = []
214
+ let binary = false
215
+ let current = null
216
+ let fileCount = 0
217
+ const lines = String(text).split('\n')
218
+ for (const raw of lines) {
219
+ if (raw.startsWith('diff --git ')) {
220
+ if (current) hunks.push(current)
221
+ current = null
222
+ fileCount += 1
223
+ continue
224
+ }
225
+ if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
226
+ binary = true
227
+ continue
228
+ }
229
+ const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw)
230
+ if (header) {
231
+ if (current) hunks.push(current)
232
+ current = {
233
+ oldStart: Number(header[1]),
234
+ oldLines: header[2] === undefined ? 1 : Number(header[2]),
235
+ newStart: Number(header[3]),
236
+ newLines: header[4] === undefined ? 1 : Number(header[4]),
237
+ lines: [],
238
+ }
239
+ continue
240
+ }
241
+ if (!current) continue
242
+ if (raw.startsWith('\\ No newline')) continue
243
+ if (raw.startsWith('+') || raw.startsWith('-') || raw.startsWith(' ')) current.lines.push(raw)
244
+ else if (raw.startsWith('@@')) { /* next hunk header handled above */ }
245
+ }
246
+ if (current) hunks.push(current)
247
+ return { hunks, binary, multipleFiles: fileCount > 1 }
248
+ }
249
+
250
+ /**
251
+ * Count text lines the same way the file route does.
252
+ * @param {string} text decoded text
253
+ * @returns {number}
254
+ */
255
+ function countLines(text) {
256
+ if (text.length === 0) return 0
257
+ let lines = 1
258
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lines += 1
259
+ return text.endsWith('\n') ? lines - 1 : lines
260
+ }
261
+
262
+ /**
263
+ * Parse commit metadata emitted with {@link COMMIT_FORMAT}.
264
+ * @param {string} raw command output
265
+ * @returns {Array<{sha:string, short:string, parents:string[], author:string, date:string, refs:string[], subject:string}>}
266
+ */
267
+ function parseCommitRecords(raw) {
268
+ const commits = []
269
+ for (const piece of String(raw).split(RECORD_SEPARATOR)) {
270
+ const text = piece.replace(/^\r?\n+/, '').replace(/\r?\n+$/, '')
271
+ if (text === '') continue
272
+ const fields = text.split('\u0000')
273
+ if (fields.length < 7) continue
274
+ if (!/^[0-9a-f]{40,64}$/i.test(fields[0])) continue
275
+ const refs = fields[5] === ''
276
+ ? []
277
+ : fields[5].split(',').map((value) => value.trim()).filter(Boolean).slice(0, REFS_MAX_COUNT).map((value) => value.slice(0, REF_MAX_CHARS))
278
+ commits.push({
279
+ sha: fields[0],
280
+ short: fields[1].slice(0, 40),
281
+ parents: fields[2] === '' ? [] : fields[2].split(' ').filter(Boolean),
282
+ author: fields[3].slice(0, AUTHOR_MAX_CHARS),
283
+ date: fields[4],
284
+ refs,
285
+ subject: fields.slice(6).join('\u0000').slice(0, SUBJECT_MAX_CHARS),
286
+ })
287
+ }
288
+ return commits
289
+ }
290
+
291
+ /**
292
+ * Read the metadata of one already-canonicalized commit.
293
+ * @param {GitRunner} git runner
294
+ * @param {string} cwd absolute session working directory
295
+ * @param {string} sha full commit hash
296
+ * @param {AbortSignal} signal cancellation
297
+ * @returns {Promise<object | null>}
298
+ */
299
+ async function commitRecordBySha(git, cwd, sha, signal) {
300
+ const logged = await git.run(['log', '-1', '--format=' + COMMIT_FORMAT, sha], { cwd, signal })
301
+ if (logged.exitCode !== 0 || logged.truncated) return null
302
+ const [record] = parseCommitRecords(logged.stdout)
303
+ return record ?? null
304
+ }
305
+
306
+ /**
307
+ * Resolve a revision expression to a commit and read its metadata.
308
+ * @param {GitRunner} git runner
309
+ * @param {string} cwd absolute session working directory
310
+ * @param {string} rev revision expression (validated by the route)
311
+ * @param {AbortSignal} signal cancellation
312
+ * @returns {Promise<object | null>}
313
+ */
314
+ async function commitRecordFor(git, cwd, rev, signal) {
315
+ const verified = await git.run(['rev-parse', '--verify', '--quiet', rev + '^{commit}'], { cwd, signal })
316
+ let sha
317
+ if (verified.exitCode === 0 && !verified.truncated) {
318
+ sha = verified.stdout.trim()
319
+ } else {
320
+ // Some revision expressions (e.g. `:/<regex>` message search) name a commit
321
+ // but cannot be parsed with the `^{commit}` peel suffix. Resolve them
322
+ // verbatim and require the result to be a commit object.
323
+ const plain = await git.run(['rev-parse', '--verify', '--quiet', rev], { cwd, signal })
324
+ if (plain.exitCode !== 0 || plain.truncated) return null
325
+ const candidate = plain.stdout.trim()
326
+ if (!/^[0-9a-f]{40,64}$/i.test(candidate)) return null
327
+ const typed = await git.run(['cat-file', '-t', candidate], { cwd, signal })
328
+ if (typed.exitCode !== 0 || typed.stdout.trim() !== 'commit') return null
329
+ sha = candidate
330
+ }
331
+ if (!/^[0-9a-f]{40,64}$/i.test(sha)) return null
332
+ return commitRecordBySha(git, cwd, sha, signal)
333
+ }
334
+
335
+ /**
336
+ * Parse `git diff --name-status -z` output.
337
+ * @param {string} raw NUL-separated name/status output
338
+ * @returns {Array<{path:string, code:string, renamedFrom?:string}>}
339
+ */
340
+ export function parseNameStatus(raw) {
341
+ const parts = String(raw).split('\u0000')
342
+ const files = []
343
+ for (let index = 0; index < parts.length; index += 1) {
344
+ const status = parts[index]
345
+ if (!status) continue
346
+ // `--branch` prepends a `## ...` header token.
347
+ if (status.startsWith('##')) continue
348
+ const code = status.charAt(0)
349
+ if (code === 'R' || code === 'C') {
350
+ const renamedFrom = parts[index + 1]
351
+ const path = parts[index + 2]
352
+ index += 2
353
+ if (!renamedFrom || !path) continue
354
+ files.push({ path, code, renamedFrom })
355
+ } else {
356
+ const path = parts[index + 1]
357
+ index += 1
358
+ if (!path) continue
359
+ files.push({ path, code })
360
+ }
361
+ }
362
+ return files
363
+ }
364
+
365
+ /** Convert a repository-relative status path to a path within the session. */
366
+ function workspacePath(root, cwd, path) {
367
+ const rel = relative(resolve(cwd), resolve(root, path))
368
+ if (rel === '' || rel === '..' || rel.startsWith('..\\') || rel.startsWith('../') || isAbsolute(rel)) return null
369
+ return rel.replace(/\\/g, '/')
370
+ }
371
+
372
+ /** Keep only changes in the session, including the visible side of a rename. */
373
+ function workspaceStatus(files, root, cwd) {
374
+ const result = []
375
+ for (const file of files) {
376
+ const path = workspacePath(root, cwd, file.path)
377
+ const renamedFrom = file.renamedFrom === undefined ? null : workspacePath(root, cwd, file.renamedFrom)
378
+ if (path !== null) {
379
+ const record = { ...file, path }
380
+ delete record.renamedFrom
381
+ if (renamedFrom !== null) record.renamedFrom = renamedFrom
382
+ else if (file.renamedFrom !== undefined) {
383
+ // The source is outside this workspace, so only the added side exists here.
384
+ record.indexStatus = /[RC]/.test(file.indexStatus) ? 'A' : file.indexStatus
385
+ record.workTreeStatus = /[RC]/.test(file.workTreeStatus) ? 'A' : file.workTreeStatus
386
+ record.code = record.indexStatus !== ' ' ? record.indexStatus : record.workTreeStatus
387
+ }
388
+ result.push(record)
389
+ } else if (renamedFrom !== null && (file.indexStatus === 'R' || file.workTreeStatus === 'R')) {
390
+ // Moving a file out of the workspace removes its old path from this view.
391
+ result.push({
392
+ path: renamedFrom,
393
+ code: 'D',
394
+ indexStatus: file.indexStatus === 'R' ? 'D' : ' ',
395
+ workTreeStatus: file.workTreeStatus === 'R' ? 'D' : ' ',
396
+ })
397
+ }
398
+ }
399
+ return result
400
+ }
401
+
402
+ /** Keep only diff records in the session, including the visible side of a rename. */
403
+ function workspaceDiff(files, root, cwd) {
404
+ const result = []
405
+ for (const file of files) {
406
+ const path = workspacePath(root, cwd, file.path)
407
+ const renamedFrom = file.renamedFrom === undefined ? null : workspacePath(root, cwd, file.renamedFrom)
408
+ if (path !== null) {
409
+ const record = { path, code: file.code }
410
+ if (renamedFrom !== null) record.renamedFrom = renamedFrom
411
+ else if (file.renamedFrom !== undefined && (file.code === 'R' || file.code === 'C')) record.code = 'A'
412
+ result.push(record)
413
+ } else if (renamedFrom !== null && file.code === 'R') {
414
+ // Moving a file out of the workspace removes its old path from this view.
415
+ result.push({ path: renamedFrom, code: 'D' })
416
+ }
417
+ }
418
+ return result
419
+ }
420
+
421
+ /**
422
+ * Parse the `## ...` header of `git status --porcelain=v1 --branch`.
423
+ * Handles upstream/ahead/behind, detached HEAD, and unborn branches.
424
+ * @param {string} line raw header line, with or without the leading `## `
425
+ * @returns {{name:string|null, detached:boolean, upstream:string|null, ahead:number, behind:number}}
426
+ */
427
+ export function parseBranchHeader(line) {
428
+ const raw = String(line || '').replace(/^## /, '').trim()
429
+ const empty = { name: null, detached: false, upstream: null, ahead: 0, behind: 0 }
430
+ if (!raw) return empty
431
+ const counts = { ahead: 0, behind: 0 }
432
+ const bracket = raw.match(/\[([^\]]+)\]\s*$/)
433
+ if (bracket) {
434
+ const ahead = bracket[1].match(/ahead (\d+)/)
435
+ const behind = bracket[1].match(/behind (\d+)/)
436
+ if (ahead) counts.ahead = Number(ahead[1])
437
+ if (behind) counts.behind = Number(behind[1])
438
+ }
439
+ const head = raw.replace(/\s*\[[^\]]*\]\s*$/, '').trim()
440
+ if (head.startsWith('HEAD (no branch)')) return { ...empty, detached: true }
441
+ if (head.startsWith('No commits yet on ')) {
442
+ return { ...empty, name: head.slice('No commits yet on '.length).trim() || null }
443
+ }
444
+ const marker = head.indexOf('...')
445
+ if (marker >= 0) {
446
+ return {
447
+ name: head.slice(0, marker).trim() || null,
448
+ detached: false,
449
+ upstream: head.slice(marker + 3).trim() || null,
450
+ ...counts,
451
+ }
452
+ }
453
+ return { name: head.split(/\s+/)[0] || null, detached: false, upstream: null, ...counts }
454
+ }
455
+
456
+ /**
457
+ * Resolve a revision expression to a canonical commit hash and metadata.
458
+ * @param {GitRunner} git runner
459
+ * @param {string} cwd absolute session working directory
460
+ * @param {string} rev revision expression (validated by the route)
461
+ * @param {AbortSignal} signal cancellation
462
+ * @returns {Promise<{sha:string, short:string, subject:string, author:string, date:string, parents:string[]} | null>}
463
+ */
464
+ export async function resolveCommit(git, cwd, rev, signal) {
465
+ const record = await commitRecordFor(git, cwd, rev, signal)
466
+ if (!record) return null
467
+ return {
468
+ sha: record.sha,
469
+ short: record.short,
470
+ subject: record.subject,
471
+ author: record.author,
472
+ date: record.date,
473
+ parents: record.parents,
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Resolve the picker's raw revision query into the route's 200-shape fields.
479
+ * @param {GitRunner} git runner
480
+ * @param {string} cwd absolute session working directory
481
+ * @param {string | undefined} rawQuery raw browser revision expression
482
+ * @param {AbortSignal} signal cancellation
483
+ * @returns {Promise<{resolved?:object, resolveError?:string}>}
484
+ */
485
+ async function resolveRevisionQuery(git, cwd, rawQuery, signal) {
486
+ if (rawQuery === undefined) return {}
487
+ const rev = safeRevision(rawQuery)
488
+ if (!rev) return { resolveError: 'Invalid git revision.' }
489
+ const record = await commitRecordFor(git, cwd, rev, signal)
490
+ if (!record) return { resolveError: 'Unknown git revision.' }
491
+ return {
492
+ resolved: {
493
+ sha: record.sha,
494
+ short: record.short,
495
+ subject: record.subject,
496
+ author: record.author,
497
+ date: record.date,
498
+ parents: record.parents,
499
+ refs: record.refs,
500
+ },
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Read the revision picker payload: HEAD metadata plus recent commits.
506
+ * @param {GitRunner} git runner
507
+ * @param {string} cwd absolute session working directory
508
+ * @param {{limit?:number, query?:string, signal:AbortSignal}} [options] request facts
509
+ * @returns {Promise<object>} revision list payload (route git-revisions shape)
510
+ */
511
+ export async function listGitRevisions(git, cwd, options = {}) {
512
+ const { limit, query, signal } = options
513
+ const root = await repositoryRoot(git, cwd, signal)
514
+ if (!root) return { available: false, reason: 'not-git' }
515
+ const requested = Number.isSafeInteger(limit) ? limit : DEFAULT_REVISION_LIMIT
516
+ const count = Math.min(MAX_REVISION_LIMIT, Math.max(1, requested))
517
+ const headCheck = await git.run(['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd, signal })
518
+ const resolution = await resolveRevisionQuery(git, cwd, query, signal)
519
+ if (headCheck.exitCode !== 0) {
520
+ return { available: true, root, unborn: true, head: null, commits: [], ...resolution }
521
+ }
522
+ const headSha = headCheck.stdout.trim()
523
+ if (!/^[0-9a-f]{40,64}$/i.test(headSha)) {
524
+ return { available: false, reason: 'head-failed', detail: headCheck.stderr.trim().slice(0, 400) }
525
+ }
526
+ const [logged, branch, upstream, headRecord] = await Promise.all([
527
+ git.run(['log', '--max-count=' + String(count), '--format=' + COMMIT_FORMAT], { cwd, signal }),
528
+ git.run(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, signal }),
529
+ git.run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, signal }),
530
+ commitRecordBySha(git, cwd, headSha, signal),
531
+ ])
532
+ if (logged.exitCode !== 0) return { available: false, reason: 'log-failed', detail: logged.stderr.trim().slice(0, 400) }
533
+ const branchName = branch.exitCode === 0 ? branch.stdout.trim() : ''
534
+ const detached = branchName === '' || branchName === 'HEAD'
535
+ const upstreamName = upstream.exitCode === 0 ? upstream.stdout.trim() : ''
536
+ let ahead = 0
537
+ if (upstreamName !== '') {
538
+ const counted = await git.run(['rev-list', '--count', upstreamName + '..HEAD'], { cwd, signal })
539
+ const parsed = counted.exitCode === 0 ? Number.parseInt(counted.stdout.trim(), 10) : 0
540
+ ahead = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0
541
+ }
542
+ return {
543
+ available: true,
544
+ root,
545
+ unborn: false,
546
+ head: {
547
+ sha: headSha,
548
+ short: headRecord ? headRecord.short : headSha.slice(0, 7),
549
+ subject: headRecord ? headRecord.subject : '',
550
+ date: headRecord ? headRecord.date : '',
551
+ branch: detached ? null : branchName,
552
+ upstream: upstreamName === '' ? null : upstreamName,
553
+ ahead,
554
+ detached,
555
+ },
556
+ commits: parseCommitRecords(logged.stdout),
557
+ ...resolution,
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Resolve a revision and comparison scope into diff endpoints.
563
+ * @param {GitRunner} git runner
564
+ * @param {string} cwd absolute session working directory
565
+ * @param {string} rev validated revision expression
566
+ * @param {'commit'|'since'} scope comparison scope
567
+ * @param {AbortSignal} signal cancellation
568
+ * @returns {Promise<{base:object, target:object, baseArg:string, targetArg:string|undefined} | null>}
569
+ */
570
+ export async function resolveComparison(git, cwd, rev, scope, signal) {
571
+ const commit = await resolveCommit(git, cwd, rev, signal)
572
+ if (!commit) return null
573
+ if (scope === 'since') {
574
+ return {
575
+ base: { sha: commit.sha, short: commit.short, subject: commit.subject, emptyTree: false },
576
+ target: { kind: 'worktree' },
577
+ baseArg: commit.sha,
578
+ targetArg: undefined,
579
+ }
580
+ }
581
+ const target = { kind: 'commit', sha: commit.sha, short: commit.short, subject: commit.subject }
582
+ const parentSha = commit.parents[0]
583
+ if (!parentSha) {
584
+ const empty = await emptyTreeHash(git, cwd, signal)
585
+ return {
586
+ base: { sha: empty, short: empty.slice(0, 7), subject: 'empty tree', emptyTree: true },
587
+ target,
588
+ baseArg: empty,
589
+ targetArg: commit.sha,
590
+ }
591
+ }
592
+ const parent = await resolveCommit(git, cwd, parentSha, signal)
593
+ return {
594
+ base: parent
595
+ ? { sha: parent.sha, short: parent.short, subject: parent.subject, emptyTree: false }
596
+ : { sha: parentSha, short: parentSha.slice(0, 7), subject: '', emptyTree: false },
597
+ target,
598
+ baseArg: parentSha,
599
+ targetArg: commit.sha,
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Serve the working-tree status of the session repository.
605
+ * @param {object} ctx Host context
606
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
607
+ * @param {string} cwd session working directory
608
+ * @param {AbortSignal} signal cancellation
609
+ * @returns {Promise<object>} status payload
610
+ */
611
+ export async function readGitStatus(ctx, gitProvider, cwd, signal) {
612
+ const git = await gitProvider()
613
+ if (!git) return { available: false, reason: 'no-git' }
614
+ const root = await repositoryRoot(git, cwd, signal)
615
+ if (!root) return { available: false, reason: 'not-git' }
616
+ // Git expands directory symlinks and Windows short names. Compare both
617
+ // directory identities in the same form before applying workspace bounds.
618
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
619
+ const status = await git.run(['status', '--porcelain=v1', '--branch', '-z', '--untracked-files=all'], { cwd, signal })
620
+ if (status.exitCode !== 0) return { available: false, reason: 'status-failed', detail: status.stderr.trim().slice(0, 400) }
621
+ const branchLine = status.stdout.split('\0')[0] || ''
622
+ return {
623
+ available: true,
624
+ root,
625
+ truncated: status.truncated === true,
626
+ branch: parseBranchHeader(branchLine),
627
+ files: workspaceStatus(parsePorcelain(status.stdout), canonicalRoot, canonicalCwd),
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Serve the file list of a revision comparison (commit scope or since scope).
633
+ * @param {object} ctx Host context
634
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
635
+ * @param {{cwd:string, rev:string, scope:'commit'|'since', signal:AbortSignal}} request request facts
636
+ * @returns {Promise<object>} route git-compare payload
637
+ */
638
+ export async function readGitCompare(ctx, gitProvider, request) {
639
+ const { cwd, rev, signal } = request
640
+ const scope = request.scope === 'since' ? 'since' : 'commit'
641
+ const git = await gitProvider()
642
+ if (!git) return { available: false, reason: 'no-git' }
643
+ const root = await repositoryRoot(git, cwd, signal)
644
+ if (!root) return { available: false, reason: 'not-git' }
645
+ const comparison = await resolveComparison(git, cwd, rev, scope, signal)
646
+ if (!comparison) return { available: false, reason: 'unknown-revision' }
647
+ // Git expands directory symlinks and Windows short names; map paths through
648
+ // the same canonical identities as readGitStatus.
649
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
650
+ let records
651
+ let truncated = false
652
+ if (comparison.target.kind === 'worktree') {
653
+ const diff = await git.run(['diff', '--name-status', '-z', '--find-renames=50%', comparison.baseArg], { cwd, signal })
654
+ if (diff.exitCode !== 0) return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
655
+ truncated = diff.truncated === true
656
+ records = parseNameStatus(diff.stdout)
657
+ // The worktree diff omits untracked files; append them as additions.
658
+ const status = await git.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd, signal })
659
+ if (status.exitCode !== 0) return { available: false, reason: 'status-failed', detail: status.stderr.trim().slice(0, 400) }
660
+ if (status.truncated) truncated = true
661
+ const listed = new Set(records.map((record) => record.path))
662
+ for (const entry of parsePorcelain(status.stdout)) {
663
+ if (entry.code !== '?' || listed.has(entry.path)) continue
664
+ listed.add(entry.path)
665
+ records.push({ path: entry.path, code: 'A' })
666
+ }
667
+ } else {
668
+ const diff = await git.run(['diff', '--name-status', '-z', '--find-renames=50%', comparison.baseArg, comparison.targetArg], { cwd, signal })
669
+ if (diff.exitCode !== 0) return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
670
+ truncated = diff.truncated === true
671
+ records = parseNameStatus(diff.stdout)
672
+ }
673
+ return {
674
+ available: true,
675
+ root,
676
+ scope,
677
+ base: comparison.base,
678
+ target: comparison.target,
679
+ files: workspaceDiff(records, canonicalRoot, canonicalCwd),
680
+ truncated,
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Read one untracked file through the workspace file service.
686
+ * @param {object} ctx Host context
687
+ * @param {object} scope workspace file scope
688
+ * @param {string} path cwd-relative path
689
+ * @param {AbortSignal} signal cancellation
690
+ * @returns {Promise<{kind:'text', text:string} | {kind:'binary'} | {kind:'oversized'} | {kind:'missing'}>}
691
+ */
692
+ async function readWorkingFile(ctx, scope, path, signal) {
693
+ let stat
694
+ try {
695
+ stat = await ctx.workspaceFiles.stat(scope, path, signal)
696
+ } catch {
697
+ return { kind: 'missing' }
698
+ }
699
+ if (typeof stat?.bytes === 'number' && stat.bytes > TEXT_MAX_BYTES) return { kind: 'oversized' }
700
+ let read
701
+ try {
702
+ read = await ctx.workspaceFiles.readBytes(scope, path, {}, signal)
703
+ } catch {
704
+ return { kind: 'missing' }
705
+ }
706
+ if (!read || !(read.data instanceof Uint8Array)) return { kind: 'missing' }
707
+ if (read.data.byteLength > TEXT_MAX_BYTES || read.eof === false) return { kind: 'oversized' }
708
+ const limit = Math.min(read.data.byteLength, 8192)
709
+ for (let index = 0; index < limit; index += 1) if (read.data[index] === 0) return { kind: 'binary' }
710
+ return { kind: 'text', text: new TextDecoder('utf-8', { fatal: false }).decode(read.data) }
711
+ }
712
+
713
+ /**
714
+ * Build the all-added comparison for one untracked file.
715
+ * @param {string} text file text
716
+ * @param {string} path cwd-relative path
717
+ * @returns {object} WorkspaceFileDiff text shape
718
+ */
719
+ function untrackedComparison(text, path) {
720
+ const lines = text.split(/\r\n|\r|\n/)
721
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
722
+ if (lines.length > TEXT_MAX_LINES) return { kind: 'oversized', path, display: path }
723
+ return {
724
+ kind: 'text',
725
+ path,
726
+ display: path,
727
+ before: false,
728
+ after: true,
729
+ coarse: false,
730
+ hunks: lines.length === 0 ? [] : [{
731
+ oldStart: 0,
732
+ oldLines: 0,
733
+ newStart: 1,
734
+ newLines: lines.length,
735
+ lines: lines.map((line) => '+' + line),
736
+ }],
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Read one file's diff against the requested baseline.
742
+ * staged: HEAD ↔ index (`git diff --cached HEAD`)
743
+ * unstaged: index ↔ worktree (`git diff`)
744
+ * @param {object} ctx Host context
745
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
746
+ * @param {{sessionId:string, cwd:string, path:string, scope:'staged'|'unstaged', signal:AbortSignal}} request
747
+ * @returns {Promise<object>} WorkspaceFileDiff-shaped comparison or a status payload
748
+ */
749
+ export async function readGitDiff(ctx, gitProvider, request) {
750
+ const { cwd, path, signal } = request
751
+ const scope = request.scope === 'staged' ? 'staged' : 'unstaged'
752
+ if (binaryByExtension(path)) return { kind: 'binary', path, display: path }
753
+ const git = await gitProvider()
754
+ if (!git) return { available: false, reason: 'no-git' }
755
+ const root = await repositoryRoot(git, cwd, signal)
756
+ if (!root) return { available: false, reason: 'not-git' }
757
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
758
+ const workspaceScope = { sessionId: request.sessionId, workspaceRoot: cwd }
759
+ const args = scope === 'staged'
760
+ ? ['diff', '--cached', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', 'HEAD', '--', ':(literal)' + path]
761
+ : ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', '--', ':(literal)' + path]
762
+ let diff = await git.run(args, { cwd, signal })
763
+ // Unborn HEAD has no HEAD side; compare the index against the empty tree.
764
+ if (diff.exitCode !== 0 && scope === 'staged' && /ambiguous argument 'HEAD'|unknown revision|bad revision/i.test(diff.stderr)) {
765
+ const empty = await emptyTreeHash(git, cwd, signal)
766
+ diff = await git.run(['diff', '--cached', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', empty, '--', ':(literal)' + path], { cwd, signal })
767
+ }
768
+ if (diff.exitCode !== 0) return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
769
+ if (diff.truncated) return { kind: 'oversized', path, display: path }
770
+ const parsed = parseUnifiedDiff(diff.stdout)
771
+ if (parsed.binary) return { kind: 'binary', path, display: path }
772
+ if (parsed.multipleFiles) return { available: false, reason: 'diff-multiple-files' }
773
+ if (parsed.hunks.length === 0) {
774
+ // No textual change: tracked-but-unchanged files stop here; untracked files
775
+ // (absent from the index) get a synthesized all-added comparison.
776
+ const tracked = await git.run(['ls-files', '-z', '--', ':(literal)' + path], { cwd, signal })
777
+ if (tracked.exitCode === 0 && tracked.stdout.trim() !== '') {
778
+ return { kind: 'text', path, display: path, before: true, after: true, coarse: false, hunks: [] }
779
+ }
780
+ if (scope === 'staged') {
781
+ return { kind: 'text', path, display: path, before: true, after: true, coarse: false, hunks: [] }
782
+ }
783
+ // Untracked files have no index side; synthesize an all-added comparison.
784
+ const working = await readWorkingFile(ctx, workspaceScope, path, signal)
785
+ if (working.kind === 'oversized') return { kind: 'oversized', path, display: path }
786
+ if (working.kind === 'binary') return { kind: 'binary', path, display: path }
787
+ if (working.kind === 'missing') return { available: false, reason: 'file-unavailable' }
788
+ return untrackedComparison(working.text, path)
789
+ }
790
+ // Guard against a path that resolves outside the canonical workspace.
791
+ const repositoryPath = relative(canonicalRoot, resolve(canonicalCwd, path)).replace(/\\/g, '/')
792
+ const outside = repositoryPath === '' || repositoryPath.startsWith('..') || isAbsolute(repositoryPath)
793
+ if (outside) return { available: false, reason: 'invalid-path' }
794
+ return { kind: 'text', path, display: path, before: true, after: true, coarse: false, hunks: parsed.hunks }
795
+ }
796
+
797
+ /**
798
+ * Serve one file's HEAD↔worktree comparison (read-only).
799
+ * @param {object} ctx Host context
800
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
801
+ * @param {{sessionId:string, cwd:string, path:string, signal:AbortSignal}} request request facts
802
+ * @returns {Promise<object>} WorkspaceFileDiff-shaped comparison or a status payload
803
+ */
804
+ export async function readGitFile(ctx, gitProvider, request) {
805
+ const { sessionId, cwd, path, signal } = request
806
+ if (binaryByExtension(path)) return { kind: 'binary', path, display: path }
807
+ const git = await gitProvider()
808
+ if (!git) return { available: false, reason: 'no-git' }
809
+ const root = await repositoryRoot(git, cwd, signal)
810
+ if (!root) return { available: false, reason: 'not-git' }
811
+ // Resolve directories only: the requested file may have been deleted and
812
+ // still needs its repository-relative HEAD path to recover the old content.
813
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
814
+ const scope = { sessionId, workspaceRoot: cwd }
815
+ // A staged deletion is absent from ls-files but still has a HEAD side. Check
816
+ // the commit itself using an exact revision path, never a glob pathspec.
817
+ const head = await git.run(['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd, signal })
818
+ if (head.truncated || (head.exitCode !== 0 && head.exitCode !== 1)) {
819
+ return { available: false, reason: 'head-failed', detail: head.stderr.trim().slice(0, 400) }
820
+ }
821
+ const headRevision = head.exitCode === 0 ? head.stdout.trim() : null
822
+ const repositoryPath = relative(canonicalRoot, resolve(canonicalCwd, path)).replace(/\\/g, '/')
823
+ const committed = headRevision
824
+ ? await git.run(['cat-file', '-e', headRevision + ':' + repositoryPath], { cwd, signal })
825
+ : null
826
+ if (!committed || committed.exitCode !== 0) {
827
+ // Both untracked files and staged files before the first commit have an
828
+ // empty old side. This also works in SHA-256 repositories.
829
+ const working = await readWorkingFile(ctx, scope, path, signal)
830
+ if (working.kind === 'oversized') return { kind: 'oversized', path, display: path }
831
+ if (working.kind === 'binary') return { kind: 'binary', path, display: path }
832
+ if (working.kind === 'missing') return { available: false, reason: 'file-unavailable' }
833
+ return untrackedComparison(working.text, path)
834
+ }
835
+ let after = true
836
+ try {
837
+ const stat = await ctx.workspaceFiles.stat(scope, path, signal)
838
+ if (typeof stat?.bytes === 'number' && stat.bytes > TEXT_MAX_BYTES) return { kind: 'oversized', path, display: path }
839
+ } catch {
840
+ after = false
841
+ }
842
+ let diff = await git.run(['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', headRevision, '--', ':(literal)' + path], { cwd, signal })
843
+ if (diff.exitCode !== 0) {
844
+ return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
845
+ }
846
+ if (diff.truncated) return { kind: 'oversized', path, display: path }
847
+ if (after && /^deleted file mode \d+$/m.test(diff.stdout)) {
848
+ // git rm followed by recreating the same path leaves the current file
849
+ // untracked. A normal HEAD diff only reports the index deletion. Compare
850
+ // the committed blob against that current path without writing an index
851
+ // or temporary files; --no-index accepts the old side on standard input.
852
+ const old = await git.run(['show', headRevision + ':' + repositoryPath], { cwd, signal, maxBytes: TEXT_MAX_BYTES })
853
+ if (old.truncated) return { kind: 'oversized', path, display: path }
854
+ if (old.exitCode !== 0) return { available: false, reason: 'diff-failed', detail: old.stderr.trim().slice(0, 400) }
855
+ diff = await git.run(['diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv', '-U3', '--', '-', resolve(cwd, path)], { cwd, signal, stdin: old.stdout })
856
+ // --no-index returns 1 for a successfully generated nonempty comparison.
857
+ if (diff.exitCode !== 0 && diff.exitCode !== 1) return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
858
+ if (diff.truncated) return { kind: 'oversized', path, display: path }
859
+ }
860
+ const parsed = parseUnifiedDiff(diff.stdout)
861
+ if (parsed.binary) return { kind: 'binary', path, display: path }
862
+ if (parsed.multipleFiles) return { available: false, reason: 'diff-multiple-files' }
863
+ return {
864
+ kind: 'text',
865
+ path,
866
+ display: path,
867
+ before: true,
868
+ after,
869
+ coarse: false,
870
+ hunks: parsed.hunks,
871
+ }
872
+ }
873
+
874
+ /** Base/target identity attached to revision-scoped file comparisons. */
875
+ function comparisonEndpoints(comparison) {
876
+ return {
877
+ base: { sha: comparison.base.sha, short: comparison.base.short },
878
+ target: comparison.target.kind === 'commit'
879
+ ? { kind: 'commit', sha: comparison.target.sha, short: comparison.target.short }
880
+ : { kind: 'worktree' },
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Serve one file's comparison against an arbitrary revision (read-only).
886
+ * @param {object} ctx Host context
887
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
888
+ * @param {{sessionId:string, cwd:string, path:string, rev:string, scope:'commit'|'since', signal:AbortSignal}} request request facts
889
+ * @returns {Promise<object>} WorkspaceFileDiff-shaped comparison plus base/target
890
+ */
891
+ export async function readGitFileAgainst(ctx, gitProvider, request) {
892
+ const { sessionId, cwd, path, rev, signal } = request
893
+ const scope = request.scope === 'since' ? 'since' : 'commit'
894
+ const git = await gitProvider()
895
+ if (!git) return { available: false, reason: 'no-git' }
896
+ const root = await repositoryRoot(git, cwd, signal)
897
+ if (!root) return { available: false, reason: 'not-git' }
898
+ const comparison = await resolveComparison(git, cwd, rev, scope, signal)
899
+ if (!comparison) return { available: false, reason: 'unknown-revision' }
900
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
901
+ const workspaceScope = { sessionId, workspaceRoot: cwd }
902
+ const endpoints = comparisonEndpoints(comparison)
903
+ // Binary extension hints avoid materializing the diff at all.
904
+ if (binaryByExtension(path)) return { kind: 'binary', path, display: path, ...endpoints }
905
+ const repositoryPath = relative(canonicalRoot, resolve(canonicalCwd, path)).replace(/\\/g, '/')
906
+ const written = (sha) => git.run(['cat-file', '-e', sha + ':' + repositoryPath], { cwd, signal })
907
+ const committedBefore = await written(comparison.baseArg)
908
+ const before = committedBefore.exitCode === 0
909
+ let after
910
+ if (comparison.target.kind === 'commit') {
911
+ after = (await written(comparison.target.sha)).exitCode === 0
912
+ } else {
913
+ try {
914
+ await ctx.workspaceFiles.stat(workspaceScope, path, signal)
915
+ after = true
916
+ } catch {
917
+ after = false
918
+ }
919
+ }
920
+ if (!before && !after) return { available: false, reason: 'file-unavailable' }
921
+ if (scope === 'since' && !before) {
922
+ // Untracked (or not-yet-committed) files only exist on the worktree side.
923
+ const working = await readWorkingFile(ctx, workspaceScope, path, signal)
924
+ if (working.kind === 'oversized') return { kind: 'oversized', path, display: path, ...endpoints }
925
+ if (working.kind === 'binary') return { kind: 'binary', path, display: path, ...endpoints }
926
+ if (working.kind === 'missing') return { available: false, reason: 'file-unavailable' }
927
+ return { ...untrackedComparison(working.text, path), ...endpoints }
928
+ }
929
+ let diff
930
+ if (comparison.target.kind === 'commit') {
931
+ diff = await git.run(['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', comparison.baseArg, comparison.targetArg, '--', ':(literal)' + path], { cwd, signal })
932
+ } else {
933
+ diff = await git.run(['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '-U3', comparison.baseArg, '--', ':(literal)' + path], { cwd, signal })
934
+ }
935
+ if (diff.exitCode !== 0) {
936
+ return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
937
+ }
938
+ if (diff.truncated) return { kind: 'oversized', path, display: path, ...endpoints }
939
+ if (comparison.target.kind === 'worktree' && after && /^deleted file mode \d+$/m.test(diff.stdout)) {
940
+ // git rm followed by recreating the same path leaves the current file
941
+ // untracked. Compare the committed blob against that path without writing
942
+ // an index or temporary file; --no-index accepts the old side on stdin.
943
+ const old = await git.run(['show', comparison.baseArg + ':' + repositoryPath], { cwd, signal, maxBytes: TEXT_MAX_BYTES })
944
+ if (old.truncated) return { kind: 'oversized', path, display: path, ...endpoints }
945
+ if (old.exitCode !== 0) return { available: false, reason: 'diff-failed', detail: old.stderr.trim().slice(0, 400) }
946
+ diff = await git.run(['diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv', '-U3', '--', '-', resolve(cwd, path)], { cwd, signal, stdin: old.stdout })
947
+ // --no-index returns 1 for a successfully generated nonempty comparison.
948
+ if (diff.exitCode !== 0 && diff.exitCode !== 1) return { available: false, reason: 'diff-failed', detail: diff.stderr.trim().slice(0, 400) }
949
+ if (diff.truncated) return { kind: 'oversized', path, display: path, ...endpoints }
950
+ }
951
+ const parsed = parseUnifiedDiff(diff.stdout)
952
+ if (parsed.binary) return { kind: 'binary', path, display: path, ...endpoints }
953
+ if (parsed.multipleFiles) return { available: false, reason: 'diff-multiple-files' }
954
+ return {
955
+ kind: 'text',
956
+ path,
957
+ display: path,
958
+ before,
959
+ after,
960
+ coarse: false,
961
+ hunks: parsed.hunks,
962
+ ...endpoints,
963
+ }
964
+ }
965
+
966
+ /**
967
+ * Read one complete file as committed at a revision (read-only).
968
+ * @param {object} ctx Host context
969
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
970
+ * @param {{cwd:string, path:string, rev:string, signal:AbortSignal}} request request facts
971
+ * @returns {Promise<object>} file content payload or a status payload
972
+ */
973
+ export async function readGitContentAt(ctx, gitProvider, request) {
974
+ const { cwd, path, rev, signal } = request
975
+ const git = await gitProvider()
976
+ if (!git) return { available: false, reason: 'no-git' }
977
+ const root = await repositoryRoot(git, cwd, signal)
978
+ if (!root) return { available: false, reason: 'not-git' }
979
+ const commit = await resolveCommit(git, cwd, rev, signal)
980
+ if (!commit) return { available: false, reason: 'unknown-revision' }
981
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
982
+ const repositoryPath = relative(canonicalRoot, resolve(canonicalCwd, path)).replace(/\\/g, '/')
983
+ if (repositoryPath === '' || repositoryPath === '..' || repositoryPath.startsWith('../') || repositoryPath.startsWith('..\\') || isAbsolute(repositoryPath)) {
984
+ return { available: false, reason: 'file-unavailable' }
985
+ }
986
+ const absolutePath = resolve(cwd, path)
987
+ const object = commit.sha + ':' + repositoryPath
988
+ const typed = await git.run(['cat-file', '-t', object], { cwd, signal })
989
+ if (typed.exitCode !== 0 || typed.stdout.trim() !== 'blob') return { available: false, reason: 'file-unavailable' }
990
+ const sized = await git.run(['cat-file', '-s', object], { cwd, signal })
991
+ const bytes = sized.exitCode === 0 ? Number.parseInt(sized.stdout.trim(), 10) : Number.NaN
992
+ if (!Number.isSafeInteger(bytes) || bytes < 0) return { available: false, reason: 'file-unavailable' }
993
+ if (bytes > MAX_FILE_BYTES) return { kind: 'oversized', bytes, version: commit.sha, absolutePath }
994
+ const shown = await git.run(['show', object], { cwd, signal, maxBytes: MAX_FILE_BYTES })
995
+ if (shown.exitCode !== 0) return { available: false, reason: 'file-unavailable' }
996
+ if (shown.truncated) return { kind: 'oversized', bytes, version: commit.sha, absolutePath }
997
+ if (shown.stdout.slice(0, 8192).includes('\u0000')) {
998
+ return { kind: 'binary', bytes, version: commit.sha, absolutePath }
999
+ }
1000
+ return {
1001
+ kind: 'text',
1002
+ text: shown.stdout,
1003
+ lines: countLines(shown.stdout),
1004
+ bytes,
1005
+ version: commit.sha,
1006
+ absolutePath,
1007
+ }
1008
+ }
1009
+
1010
+
1011
+ /** Cap for one blame read (porcelain is roughly ten times the file size). */
1012
+ const BLAME_MAX_BYTES = 16 * 1024 * 1024
1013
+ /** Cap for the number of blamed lines served to the client. */
1014
+ const BLAME_MAX_LINES = 50000
1015
+ /** Default commit count for the history graph. */
1016
+ const DEFAULT_HISTORY_LIMIT = 200
1017
+ /** Hard clamp for the history graph commit count. */
1018
+ const MAX_HISTORY_LIMIT = 500
1019
+
1020
+ /**
1021
+ * Serve per-line blame metadata for one tracked file (read-only).
1022
+ * @param {object} ctx Host context
1023
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
1024
+ * @param {{cwd:string, path:string, signal:AbortSignal}} request request facts
1025
+ * @returns {Promise<object>} `{available:true, lines:[{line,sha,short,author,authorTime,summary,uncommitted}], truncated}`
1026
+ */
1027
+ export async function readGitBlame(ctx, gitProvider, request) {
1028
+ const { cwd, path, signal } = request
1029
+ if (typeof path !== 'string' || path === '') return { available: false, reason: 'invalid-path' }
1030
+ const git = await gitProvider()
1031
+ if (!git) return { available: false, reason: 'no-git' }
1032
+ const root = await repositoryRoot(git, cwd, signal)
1033
+ if (!root) return { available: false, reason: 'not-git' }
1034
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(cwd)])
1035
+ const repositoryPath = relative(canonicalRoot, resolve(canonicalCwd, path)).replace(/\\/g, '/')
1036
+ if (repositoryPath === '' || repositoryPath === '..' || repositoryPath.startsWith('../') || repositoryPath.startsWith('..\\') || isAbsolute(repositoryPath)) {
1037
+ return { available: false, reason: 'invalid-path' }
1038
+ }
1039
+ const result = await git.run(['blame', '--line-porcelain', '--', repositoryPath], { cwd, signal, maxBytes: BLAME_MAX_BYTES })
1040
+ if (result.exitCode !== 0) return { available: false, reason: 'blame-failed', detail: result.stderr.trim().slice(0, 400) }
1041
+ if (result.truncated) return { available: true, root, lines: [], truncated: true }
1042
+ const lines = []
1043
+ let current = null
1044
+ for (const raw of result.stdout.split('\n')) {
1045
+ const text = raw.replace(/\r$/, '')
1046
+ const header = /^([0-9a-f]{40,64}) \d+ (\d+)(?: \d+)?$/.exec(text)
1047
+ if (header) {
1048
+ current = { sha: header[1], line: Number.parseInt(header[2], 10) }
1049
+ continue
1050
+ }
1051
+ if (!current) continue
1052
+ if (text.startsWith('\t')) {
1053
+ if (Number.isSafeInteger(current.line) && current.line > 0) {
1054
+ lines.push({
1055
+ line: current.line,
1056
+ sha: current.sha,
1057
+ short: current.sha.slice(0, 7),
1058
+ author: current.author || '',
1059
+ authorTime: current.authorTime ?? null,
1060
+ summary: current.summary || '',
1061
+ uncommitted: /^0+$/.test(current.sha),
1062
+ })
1063
+ if (lines.length >= BLAME_MAX_LINES) return { available: true, root, lines, truncated: true }
1064
+ }
1065
+ current = null
1066
+ continue
1067
+ }
1068
+ const field = /^([a-z-]+) (.*)$/.exec(text)
1069
+ if (!field) continue
1070
+ if (field[1] === 'author') current.author = field[2]
1071
+ else if (field[1] === 'author-time') {
1072
+ const seconds = Number.parseInt(field[2], 10)
1073
+ if (Number.isFinite(seconds)) current.authorTime = seconds * 1000
1074
+ } else if (field[1] === 'summary') current.summary = field[2]
1075
+ }
1076
+ lines.sort((a, b) => a.line - b.line)
1077
+ return { available: true, root, lines, truncated: false }
1078
+ }
1079
+
1080
+ /**
1081
+ * Read the commit history used by the graph panel (all refs, parents included).
1082
+ * @param {object} ctx Host context
1083
+ * @param {() => Promise<GitRunner | null>} gitProvider runner provider
1084
+ * @param {{cwd:string, limit?:number, signal:AbortSignal}} request request facts
1085
+ * @returns {Promise<object>} `{available:true, root, commits, truncated}`
1086
+ */
1087
+ const COMMIT_INFO_FORMAT = '%H%x00%h%x00%an%x00%aI%x00%s%x00%B%x1e'
1088
+
1089
+ /**
1090
+ * Serve one commit's metadata and full message (read-only).
1091
+ * @param {object} ctx host context
1092
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
1093
+ * @param {{cwd: string, revision: string, signal?: AbortSignal}} request coordinates
1094
+ * @returns {Promise<object>} commit payload or a reason
1095
+ */
1096
+ export async function readCommitInfo(ctx, gitProvider, request) {
1097
+ const { cwd, revision, signal } = request
1098
+ if (typeof revision !== 'string' || revision === '' || revision.length > 200) return { available: false, reason: 'invalid-revision' }
1099
+ if (revision.startsWith('-') || /[\s\u0000-\u001f]/.test(revision)) return { available: false, reason: 'invalid-revision' }
1100
+ const git = await gitProvider()
1101
+ if (!git) return { available: false, reason: 'no-git' }
1102
+ const root = await repositoryRoot(git, cwd, signal)
1103
+ if (!root) return { available: false, reason: 'not-git' }
1104
+ const result = await git.run(['show', '-s', '--format=' + COMMIT_INFO_FORMAT, revision, '--'], {
1105
+ cwd,
1106
+ signal,
1107
+ maxBytes: 2 * 1024 * 1024,
1108
+ })
1109
+ if (result.exitCode !== 0) return { available: false, reason: 'commit-not-found', detail: result.stderr.trim().slice(0, 400) }
1110
+ const fields = result.stdout.replace(/\u001e$/, '').split('\u0000')
1111
+ if (fields.length < 6) return { available: false, reason: 'commit-not-found' }
1112
+ const [sha, short, author, date, subject, ...rest] = fields
1113
+ const parsed = Date.parse(date)
1114
+ return {
1115
+ available: true,
1116
+ commit: {
1117
+ sha,
1118
+ short,
1119
+ author,
1120
+ authorTime: Number.isFinite(parsed) ? parsed : null,
1121
+ date,
1122
+ summary: subject,
1123
+ body: rest.join('\u0000').trim(),
1124
+ },
1125
+ }
1126
+ }
1127
+
1128
+ export async function readGitHistory(ctx, gitProvider, request) {
1129
+ const { cwd, signal } = request
1130
+ const requested = Number.parseInt(request.limit, 10)
1131
+ const limit = Number.isFinite(requested) ? Math.min(MAX_HISTORY_LIMIT, Math.max(1, requested)) : DEFAULT_HISTORY_LIMIT
1132
+ const git = await gitProvider()
1133
+ if (!git) return { available: false, reason: 'no-git' }
1134
+ const root = await repositoryRoot(git, cwd, signal)
1135
+ if (!root) return { available: false, reason: 'not-git' }
1136
+ const logged = await git.run(['log', '--all', '--max-count=' + String(limit), '--format=' + COMMIT_FORMAT], { cwd, signal, maxBytes: 4 * 1024 * 1024 })
1137
+ if (logged.exitCode !== 0) {
1138
+ if (/does not have any commits yet|unknown revision|bad revision|ambiguous argument/i.test(logged.stderr)) {
1139
+ return { available: true, root, commits: [], truncated: false }
1140
+ }
1141
+ return { available: false, reason: 'log-failed', detail: logged.stderr.trim().slice(0, 400) }
1142
+ }
1143
+ return { available: true, root, commits: parseCommitRecords(logged.stdout), truncated: logged.truncated }
1144
+ }
1145
+
1146
+ /** Re-export for callers that only need the extension heuristic. */
1147
+ export { binaryByExtension, extname }