dsh-git-changes 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.en.md +105 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/vendor/monaco/LICENSE +21 -0
- package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
- package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
- package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
- package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
- package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
- package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
- package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
- package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
- package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
- package/assets/vendor/monaco/vs/loader.js +10 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/git-changes-diff.png +0 -0
- package/icon.svg +10 -0
- package/lib/client.js +9418 -0
- package/lib/host/assets.js +161 -0
- package/lib/host/commit-message.js +158 -0
- package/lib/host/files-index.js +206 -0
- package/lib/host/git-write.js +194 -0
- package/lib/host/git.js +1147 -0
- package/lib/host/lsp.js +842 -0
- package/lib/host/routes.js +976 -0
- package/lib/host/session.js +84 -0
- package/lib/host/util.js +107 -0
- package/lib/index.js +33 -0
- package/licenses/dsh-vscode-mode-LICENSE +21 -0
- package/licenses/vscode-theme-seti-LICENSE +21 -0
- package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -3
- package/src/client/00-const.js +79 -0
- package/src/client/05-utils.js +277 -0
- package/src/client/10-i18n.js +548 -0
- package/src/client/12-vscode-icons.js +460 -0
- package/src/client/15-theme.js +509 -0
- package/src/client/20-monaco.js +1404 -0
- package/src/client/22-lsp.js +592 -0
- package/src/client/25-tree.js +563 -0
- package/src/client/28-changes.js +1163 -0
- package/src/client/30-page.js +2259 -0
- package/src/client/35-pill.js +185 -0
- package/src/client/40-commands.js +248 -0
- package/src/client/45-css.js +357 -0
- package/src/client/47-pickers.js +457 -0
- package/src/client/50-apply.js +207 -0
- package/src/client/99-footer.js +62 -0
package/lib/host/git.js
ADDED
|
@@ -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 }
|