@weniatall/dsh-file-viewer 0.2.11 → 0.3.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.
- package/README.md +36 -0
- package/lib/client.js +1535 -123
- package/lib/core.js +613 -0
- package/lib/index.js +953 -1
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -21,6 +21,9 @@ import path from 'node:path'
|
|
|
21
21
|
import z from '@deepseek-ai/schemastery'
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
|
+
GIT_COMMIT_PAGE_MAX,
|
|
25
|
+
GIT_COMMIT_PAGE_SIZE,
|
|
26
|
+
GIT_REPO_SCAN_LIMIT,
|
|
24
27
|
LANGUAGE_CATALOG,
|
|
25
28
|
MAX_BYTES,
|
|
26
29
|
MAX_DOWNLOAD_BYTES,
|
|
@@ -29,13 +32,20 @@ import {
|
|
|
29
32
|
TEXT_PROBE_BYTES,
|
|
30
33
|
appendMention,
|
|
31
34
|
baseNameOf,
|
|
35
|
+
commitPathWithinRoot,
|
|
32
36
|
contentDispositionFor,
|
|
33
37
|
detectLanguageFromText,
|
|
34
38
|
extensionOf,
|
|
35
39
|
formatDocTextToMarkdown,
|
|
40
|
+
gitChangeOf,
|
|
41
|
+
gitStatusBadge,
|
|
42
|
+
groupGitChanges,
|
|
36
43
|
insertMentionAtCaret,
|
|
37
|
-
|
|
44
|
+
isCheckoutableBranch,
|
|
45
|
+
isCandidateRepoDirectory,
|
|
38
46
|
isHiddenEntry,
|
|
47
|
+
isSafeBranchName,
|
|
48
|
+
isSafeCommitSha,
|
|
39
49
|
isSafeRelativePath,
|
|
40
50
|
isTextContent,
|
|
41
51
|
isUtf8TextContent,
|
|
@@ -45,11 +55,24 @@ import {
|
|
|
45
55
|
kindOf,
|
|
46
56
|
langOf,
|
|
47
57
|
mediaTypeOf,
|
|
58
|
+
normalizeCommitPage,
|
|
59
|
+
normalizeGitPath,
|
|
48
60
|
normalizeSafePaths,
|
|
61
|
+
parentOf,
|
|
62
|
+
parseGitBranchList,
|
|
63
|
+
parseGitBranches,
|
|
64
|
+
parseGitCommitDetail,
|
|
65
|
+
parseGitLog,
|
|
66
|
+
parseGitNameStatus,
|
|
49
67
|
parseGitStatus,
|
|
68
|
+
parseGitStatusEntries,
|
|
50
69
|
parsePatchToDiffs,
|
|
51
70
|
parsePatchToRows,
|
|
71
|
+
parseUnifiedDiffFiles,
|
|
52
72
|
rebaseGitPaths,
|
|
73
|
+
relativePathWithin,
|
|
74
|
+
repoRelativeFromRoot,
|
|
75
|
+
revisionOfBranch,
|
|
53
76
|
resolveLanguage,
|
|
54
77
|
resolveWindow,
|
|
55
78
|
sanitizeDocHtml,
|
|
@@ -60,6 +83,9 @@ import {
|
|
|
60
83
|
} from './core.js'
|
|
61
84
|
|
|
62
85
|
export {
|
|
86
|
+
GIT_COMMIT_PAGE_MAX,
|
|
87
|
+
GIT_COMMIT_PAGE_SIZE,
|
|
88
|
+
GIT_REPO_SCAN_LIMIT,
|
|
63
89
|
LANGUAGE_CATALOG,
|
|
64
90
|
MAX_BYTES,
|
|
65
91
|
MAX_DOWNLOAD_BYTES,
|
|
@@ -68,14 +94,19 @@ export {
|
|
|
68
94
|
TEXT_PROBE_BYTES,
|
|
69
95
|
appendMention,
|
|
70
96
|
baseNameOf,
|
|
97
|
+
commitPathWithinRoot,
|
|
71
98
|
contentDispositionFor,
|
|
72
99
|
detectLanguageFromText,
|
|
73
100
|
extensionOf,
|
|
74
101
|
formatDocTextToMarkdown,
|
|
102
|
+
gitChangeOf,
|
|
75
103
|
insertMentionAtCaret,
|
|
104
|
+
isCandidateRepoDirectory,
|
|
76
105
|
parentOf,
|
|
77
106
|
sanitizeDocHtml,
|
|
78
107
|
isHiddenEntry,
|
|
108
|
+
isSafeBranchName,
|
|
109
|
+
isSafeCommitSha,
|
|
79
110
|
isSafeRelativePath,
|
|
80
111
|
isTextContent,
|
|
81
112
|
isUtf8TextContent,
|
|
@@ -85,13 +116,28 @@ export {
|
|
|
85
116
|
kindOf,
|
|
86
117
|
langOf,
|
|
87
118
|
mediaTypeOf,
|
|
119
|
+
normalizeCommitPage,
|
|
120
|
+
normalizeGitPath,
|
|
88
121
|
normalizeSafePaths,
|
|
122
|
+
parseGitBranchList,
|
|
123
|
+
parseGitBranches,
|
|
124
|
+
parseGitCommitDetail,
|
|
125
|
+
parseGitLog,
|
|
126
|
+
parseGitNameStatus,
|
|
89
127
|
parseGitStatus,
|
|
128
|
+
parseGitStatusEntries,
|
|
90
129
|
parsePatchToDiffs,
|
|
91
130
|
parsePatchToRows,
|
|
131
|
+
parseUnifiedDiffFiles,
|
|
92
132
|
rebaseGitPaths,
|
|
133
|
+
relativePathWithin,
|
|
134
|
+
repoRelativeFromRoot,
|
|
93
135
|
resolveLanguage,
|
|
94
136
|
resolveWindow,
|
|
137
|
+
revisionOfBranch,
|
|
138
|
+
gitStatusBadge,
|
|
139
|
+
groupGitChanges,
|
|
140
|
+
isCheckoutableBranch,
|
|
95
141
|
sortEntries,
|
|
96
142
|
splitLines,
|
|
97
143
|
summarizeDiffs,
|
|
@@ -1101,6 +1147,902 @@ export async function getWorkspaceGitStatus(ctx, options, payload, signal) {
|
|
|
1101
1147
|
}
|
|
1102
1148
|
}
|
|
1103
1149
|
|
|
1150
|
+
/* -------------------------------------------------------------------------- *
|
|
1151
|
+
* Git panel
|
|
1152
|
+
*
|
|
1153
|
+
* The panel has to work for two shapes of workspace, and they are not the same
|
|
1154
|
+
* problem: a root that *is* a checkout (or lives inside one), and a root that
|
|
1155
|
+
* merely *holds* checkouts. Both are resolved here, and the client never sends
|
|
1156
|
+
* a filesystem path back: it sends a repository id from the discovery result,
|
|
1157
|
+
* so `git` can only ever be run in a directory this host enumerated under a
|
|
1158
|
+
* vetted root.
|
|
1159
|
+
*
|
|
1160
|
+
* Every ref and revision that travels as argv is validated first. `execFile`
|
|
1161
|
+
* does not involve a shell, but git still parses a leading dash as an option,
|
|
1162
|
+
* which is why {@link isSafeBranchName} rejects it outright.
|
|
1163
|
+
* -------------------------------------------------------------------------- */
|
|
1164
|
+
|
|
1165
|
+
/** Field/record separators git is asked for: neither may appear in a ref. */
|
|
1166
|
+
const GIT_FIELD = '%x1f'
|
|
1167
|
+
const GIT_RECORD = '%x1e'
|
|
1168
|
+
|
|
1169
|
+
/** One commit per record, with the fields the panel actually renders. */
|
|
1170
|
+
const GIT_LOG_FORMAT = `%H${GIT_FIELD}%h${GIT_FIELD}%an${GIT_FIELD}%ae${GIT_FIELD}%aI${GIT_FIELD}%cI${GIT_FIELD}%s${GIT_RECORD}`
|
|
1171
|
+
|
|
1172
|
+
/** The same fields plus the full message body, for one commit's detail. */
|
|
1173
|
+
const GIT_COMMIT_FORMAT = `%H${GIT_FIELD}%h${GIT_FIELD}%an${GIT_FIELD}%ae${GIT_FIELD}%aI${GIT_FIELD}%cI${GIT_FIELD}%s${GIT_FIELD}%B${GIT_RECORD}`
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Branch listing, tab separated: `*` marker, full ref, short name, tip, date,
|
|
1177
|
+
* upstream.
|
|
1178
|
+
*
|
|
1179
|
+
* The full ref is requested as well as the short name because a remote branch
|
|
1180
|
+
* and a local branch can share a short name (`origin/main` as a local branch is
|
|
1181
|
+
* legal), and only the full ref distinguishes what may be checked out.
|
|
1182
|
+
*/
|
|
1183
|
+
const GIT_BRANCH_FORMAT = '%(HEAD)%09%(refname)%09%(refname:short)%09%(objectname:short)%09%(committerdate:iso-strict)%09%(upstream:short)'
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Environment for every git call.
|
|
1187
|
+
*
|
|
1188
|
+
* A viewer must never block on a credential prompt or open a pager, and the
|
|
1189
|
+
* optional-locks switch keeps a read-only listing from refreshing the index of
|
|
1190
|
+
* a repository the user is working in.
|
|
1191
|
+
*
|
|
1192
|
+
* `GIT_DIR`, `GIT_WORK_TREE` and friends are removed rather than overridden:
|
|
1193
|
+
* they are inherited from whatever launched DSH, and any one of them redirects
|
|
1194
|
+
* git at a repository outside the root this plugin vetted. `cwd` alone must
|
|
1195
|
+
* decide which repository a call touches.
|
|
1196
|
+
*/
|
|
1197
|
+
const GIT_ENV = Object.freeze({
|
|
1198
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
1199
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
1200
|
+
GIT_PAGER: 'cat',
|
|
1201
|
+
LC_ALL: 'C',
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Environment variables that bind a git call to a repository or an index.
|
|
1206
|
+
*
|
|
1207
|
+
* Inheriting one of these silently relocates every command, including
|
|
1208
|
+
* `git checkout`, which is the one call in this plugin that writes. They are
|
|
1209
|
+
* deleted from the child environment so the `cwd` passed by the caller is the
|
|
1210
|
+
* only thing that selects a repository.
|
|
1211
|
+
*/
|
|
1212
|
+
const GIT_REPOSITORY_ENV = Object.freeze([
|
|
1213
|
+
'GIT_DIR',
|
|
1214
|
+
'GIT_WORK_TREE',
|
|
1215
|
+
'GIT_INDEX_FILE',
|
|
1216
|
+
'GIT_OBJECT_DIRECTORY',
|
|
1217
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
1218
|
+
'GIT_COMMON_DIR',
|
|
1219
|
+
'GIT_NAMESPACE',
|
|
1220
|
+
'GIT_PREFIX',
|
|
1221
|
+
])
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Build the child environment for one git call.
|
|
1225
|
+
*
|
|
1226
|
+
* Exported because the removal of the repository-binding variables is the
|
|
1227
|
+
* security-relevant half of confinement and is worth asserting directly.
|
|
1228
|
+
* @param {NodeJS.ProcessEnv} [base] - the ambient environment.
|
|
1229
|
+
* @returns {NodeJS.ProcessEnv} the environment a git child may see.
|
|
1230
|
+
*/
|
|
1231
|
+
export function gitChildEnv(base = process.env) {
|
|
1232
|
+
const env = { ...base, ...GIT_ENV }
|
|
1233
|
+
for (const key of GIT_REPOSITORY_ENV) delete env[key]
|
|
1234
|
+
return env
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Run one git command and report failure as data rather than an exception.
|
|
1239
|
+
*
|
|
1240
|
+
* A missing binary, a directory that is not a checkout and a repository with
|
|
1241
|
+
* no commits are all ordinary states the panel renders, so the caller decides
|
|
1242
|
+
* what each means instead of unwinding through an error path.
|
|
1243
|
+
* @param {string} cwd - absolute directory to run in.
|
|
1244
|
+
* @param {string[]} args - argv, never a shell string.
|
|
1245
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1246
|
+
* @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} the result.
|
|
1247
|
+
*/
|
|
1248
|
+
async function runGitCommand(cwd, args, signal) {
|
|
1249
|
+
try {
|
|
1250
|
+
const { stdout } = await execFileAsync('git', ['--no-pager', ...args], {
|
|
1251
|
+
cwd,
|
|
1252
|
+
encoding: 'utf8',
|
|
1253
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1254
|
+
windowsHide: true,
|
|
1255
|
+
signal,
|
|
1256
|
+
// Confinement, not convenience: an inherited GIT_DIR would point git at
|
|
1257
|
+
// a repository outside the root the caller was allowed to read.
|
|
1258
|
+
env: gitChildEnv(),
|
|
1259
|
+
})
|
|
1260
|
+
return { ok: true, stdout: stdout ?? '', stderr: '' }
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
// Cancellation is the caller's own signal, not a git failure.
|
|
1263
|
+
if (signal?.aborted) throw error
|
|
1264
|
+
// `ENOENT` means the binary is missing, which is a deployment problem and
|
|
1265
|
+
// must not be reported as "this directory is not a repository".
|
|
1266
|
+
if (error?.code === 'ENOENT') return { ok: false, stdout: '', stderr: '', missing: true }
|
|
1267
|
+
return { ok: false, stdout: '', stderr: String(error?.stderr ?? error?.message ?? '') }
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* The git runner for this deployment.
|
|
1273
|
+
*
|
|
1274
|
+
* Reads an injectable runner from the resolved config so the host tests can
|
|
1275
|
+
* exercise discovery and paging without a real repository; production never
|
|
1276
|
+
* sets it and gets the subprocess runner above.
|
|
1277
|
+
* @param {(() => object) | object} options - resolved config reader.
|
|
1278
|
+
* @returns {(cwd: string, args: string[], signal?: AbortSignal) => Promise<object>} the runner.
|
|
1279
|
+
*/
|
|
1280
|
+
function gitRunnerFrom(options) {
|
|
1281
|
+
const opts = typeof options === 'function' ? (options() ?? {}) : (options ?? {})
|
|
1282
|
+
return typeof opts?._runGit === 'function' ? opts._runGit : runGitCommand
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Read one line of `git rev-parse` output, or '' when git refused.
|
|
1287
|
+
* @param {Function} run - the git runner.
|
|
1288
|
+
* @param {string} cwd - directory to run in.
|
|
1289
|
+
* @param {string[]} args - arguments after `rev-parse`.
|
|
1290
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1291
|
+
* @returns {Promise<string>} the first output line.
|
|
1292
|
+
*/
|
|
1293
|
+
async function gitRevisionOf(run, cwd, args, signal) {
|
|
1294
|
+
const result = await run(cwd, ['rev-parse', ...args], signal)
|
|
1295
|
+
if (result?.ok !== true) return ''
|
|
1296
|
+
return String(result.stdout ?? '').split('\n')[0].trim()
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Whether a failed git call means the binary is absent rather than the
|
|
1301
|
+
* directory being something other than a checkout.
|
|
1302
|
+
*
|
|
1303
|
+
* The two used to collapse into "no repository here", which turns a broken
|
|
1304
|
+
* deployment into a confident, wrong answer. Only a spawn failure sets
|
|
1305
|
+
* `missing`; git's own "not a git repository" is an ordinary negative answer.
|
|
1306
|
+
* @param {object} result - one runner result.
|
|
1307
|
+
* @returns {boolean} true when git itself could not be started.
|
|
1308
|
+
*/
|
|
1309
|
+
function gitUnavailable(result) {
|
|
1310
|
+
return result?.missing === true
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Enumerate the checkouts visible from one root.
|
|
1315
|
+
*
|
|
1316
|
+
* The root's own checkout wins when the root sits inside one (a workspace that
|
|
1317
|
+
* is a subdirectory of a repository still reads that repository's history).
|
|
1318
|
+
* Direct subdirectories are probed next, one bounded level deep: only a
|
|
1319
|
+
* directory carrying its own `.git` marker can start a new checkout, so a
|
|
1320
|
+
* single stat rules out every ordinary directory before a git process is
|
|
1321
|
+
* spawned — which matters because this runs each time the panel opens.
|
|
1322
|
+
*
|
|
1323
|
+
* Two things are reported instead of hidden:
|
|
1324
|
+
* - `truncated`, when the probe budget ran out before every candidate was
|
|
1325
|
+
* examined, so the client can say the list is incomplete;
|
|
1326
|
+
* - `scope`, when the root is a subdirectory of the checkout rather than the
|
|
1327
|
+
* checkout itself, because a checkout there writes outside the root.
|
|
1328
|
+
*
|
|
1329
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1330
|
+
* @param {() => object} options - resolved config reader.
|
|
1331
|
+
* @param {{root?: string}} payload - client request.
|
|
1332
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1333
|
+
* @returns {Promise<{root: object, rootPath: string, repositories: object[], truncated: boolean}>} the placements.
|
|
1334
|
+
*/
|
|
1335
|
+
async function discoverRepositories(ctx, options, payload, signal) {
|
|
1336
|
+
const { root, target, absolute } = await resolveInRoot(ctx, options, { root: payload?.root, path: '' }, signal)
|
|
1337
|
+
const rootPath = absolute
|
|
1338
|
+
const run = gitRunnerFrom(options)
|
|
1339
|
+
const repositories = []
|
|
1340
|
+
const seen = new Set()
|
|
1341
|
+
let missing = false
|
|
1342
|
+
|
|
1343
|
+
const add = (id, name, toplevel, prefix, rootRelative) => {
|
|
1344
|
+
if (toplevel === '') return
|
|
1345
|
+
const key = process.platform === 'win32' ? toplevel.toLowerCase() : toplevel
|
|
1346
|
+
if (seen.has(key)) return
|
|
1347
|
+
seen.add(key)
|
|
1348
|
+
const normalizedTop = normalizeGitPath(toplevel)
|
|
1349
|
+
const normalizedRoot = normalizeGitPath(rootPath)
|
|
1350
|
+
repositories.push({
|
|
1351
|
+
id,
|
|
1352
|
+
name,
|
|
1353
|
+
path: prefix,
|
|
1354
|
+
toplevel,
|
|
1355
|
+
rootRelative,
|
|
1356
|
+
// `root` means checking a branch out here rewrites exactly the directory
|
|
1357
|
+
// the reader is browsing; `parent` means the checkout lives above it and
|
|
1358
|
+
// a checkout would touch files this root was never allowed to change.
|
|
1359
|
+
scope: normalizedTop === normalizedRoot ? 'root' : 'parent',
|
|
1360
|
+
})
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const probe = async (cwd, args) => {
|
|
1364
|
+
const result = await run(cwd, ['rev-parse', ...args], signal)
|
|
1365
|
+
if (gitUnavailable(result)) missing = true
|
|
1366
|
+
if (result?.ok !== true) return ''
|
|
1367
|
+
return String(result.stdout ?? '').split('\n')[0].trim()
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const toplevel = await probe(rootPath, ['--show-toplevel'])
|
|
1371
|
+
if (toplevel !== '') {
|
|
1372
|
+
// `--show-prefix` is git's own answer for "where is this directory inside
|
|
1373
|
+
// the checkout", which avoids comparing paths across symlinked temp dirs.
|
|
1374
|
+
const inside = await probe(rootPath, ['--show-prefix'])
|
|
1375
|
+
add('.', baseNameOf(toplevel) || toplevel, toplevel, '', inside)
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (missing) throw new ViewerError('git-unavailable', 'this host cannot run git')
|
|
1379
|
+
|
|
1380
|
+
let entries = []
|
|
1381
|
+
try {
|
|
1382
|
+
entries = await ctx.fs.listDir(target, signal)
|
|
1383
|
+
} catch {
|
|
1384
|
+
entries = []
|
|
1385
|
+
}
|
|
1386
|
+
// Directory listings arrive in filesystem order, which is not stable. Sorting
|
|
1387
|
+
// makes the truncated case reproducible instead of "whichever 32 came first".
|
|
1388
|
+
const candidates = entries
|
|
1389
|
+
.filter((entry) => entry?.type === 'directory' && isCandidateRepoDirectory(entry.name))
|
|
1390
|
+
.sort((left, right) => String(left.name).localeCompare(String(right.name)))
|
|
1391
|
+
|
|
1392
|
+
let scanned = 0
|
|
1393
|
+
let truncated = false
|
|
1394
|
+
for (const entry of candidates) {
|
|
1395
|
+
if (scanned >= GIT_REPO_SCAN_LIMIT) {
|
|
1396
|
+
// The budget bounds *probes*, not discoveries; running out means there
|
|
1397
|
+
// may be more checkouts, which the client has to be able to say.
|
|
1398
|
+
truncated = true
|
|
1399
|
+
break
|
|
1400
|
+
}
|
|
1401
|
+
scanned += 1
|
|
1402
|
+
try {
|
|
1403
|
+
const childPath = joinPath(rootPath, entry.name)
|
|
1404
|
+
const childTarget = await ctx.fs.resolve(childPath, { signal })
|
|
1405
|
+
// A symlinked directory can point outside the root, and running git
|
|
1406
|
+
// there would step over the containment fence this plugin is built on.
|
|
1407
|
+
if (childTarget.targetKey === target.targetKey || !ctx.fs.contains(target, childTarget)) continue
|
|
1408
|
+
const marker = await ctx.fs.stat(await ctx.fs.resolve(joinPath(childPath, '.git'), { signal }), signal)
|
|
1409
|
+
if (marker === undefined) continue
|
|
1410
|
+
const childPhysical = ctx.fs.processPath?.(childTarget) ?? childTarget.targetKey
|
|
1411
|
+
const childTop = await probe(childPhysical, ['--show-toplevel'])
|
|
1412
|
+
if (childTop === '') continue
|
|
1413
|
+
// A nested directory of the parent checkout reports the parent's
|
|
1414
|
+
// toplevel too; it only owns a checkout when git agrees it is the root.
|
|
1415
|
+
const childInside = await probe(childPhysical, ['--show-prefix'])
|
|
1416
|
+
if (childInside !== '') continue
|
|
1417
|
+
const placement = relativePathWithin(rootPath, childTop)
|
|
1418
|
+
if (placement !== null && placement !== entry.name) continue
|
|
1419
|
+
add(entry.name, entry.name, childTop, entry.name, '')
|
|
1420
|
+
} catch {
|
|
1421
|
+
// One unreadable child must not hide the others.
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (missing) throw new ViewerError('git-unavailable', 'this host cannot run git')
|
|
1426
|
+
|
|
1427
|
+
return { root, rootPath, repositories, truncated }
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Read a checkout's branches and checked-out state.
|
|
1432
|
+
* @param {Function} run - the git runner.
|
|
1433
|
+
* @param {object} repo - a repository from discovery.
|
|
1434
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1435
|
+
* @returns {Promise<{branches: object[], current: string, detached: boolean, empty: boolean}>} the state.
|
|
1436
|
+
*/
|
|
1437
|
+
async function readRepositoryState(run, repo, signal) {
|
|
1438
|
+
const listing = await run(repo.toplevel, ['branch', '--all', '--no-color', `--format=${GIT_BRANCH_FORMAT}`], signal)
|
|
1439
|
+
const branches = listing?.ok === true ? parseGitBranchList(listing.stdout) : []
|
|
1440
|
+
const head = await gitRevisionOf(run, repo.toplevel, ['--abbrev-ref', 'HEAD'], signal)
|
|
1441
|
+
const detached = head === 'HEAD'
|
|
1442
|
+
return {
|
|
1443
|
+
branches,
|
|
1444
|
+
current: detached || head === '' ? '' : head,
|
|
1445
|
+
detached,
|
|
1446
|
+
// No HEAD commit yet: a freshly initialized repository has no history.
|
|
1447
|
+
empty: branches.filter((branch) => branch.kind === 'local').length === 0 && head === '',
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Read a checkout's remotes so the panel can say where a branch came from and
|
|
1453
|
+
* whether it is behind.
|
|
1454
|
+
* @param {Function} run - the git runner.
|
|
1455
|
+
* @param {string} toplevel - the checkout root.
|
|
1456
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1457
|
+
* @returns {Promise<{name: string, url: string}[]>} configured remotes.
|
|
1458
|
+
*/
|
|
1459
|
+
async function readRemotes(run, toplevel, signal) {
|
|
1460
|
+
const result = await run(toplevel, ['remote', '-v'], signal)
|
|
1461
|
+
if (result?.ok !== true) return []
|
|
1462
|
+
const seen = new Map()
|
|
1463
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
1464
|
+
// `<name>\t<url> (fetch|push)`
|
|
1465
|
+
const match = /^(\S+)\t(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
|
|
1466
|
+
if (match === null) continue
|
|
1467
|
+
const [, name, url, kind] = match
|
|
1468
|
+
if (kind !== 'fetch' || seen.has(name)) continue
|
|
1469
|
+
seen.set(name, { name, url })
|
|
1470
|
+
}
|
|
1471
|
+
return [...seen.values()]
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* List every branch name in a checkout, used as the allow-list for a checkout
|
|
1476
|
+
* request so an arbitrary revision expression can never be selected.
|
|
1477
|
+
* @param {Function} run - the git runner.
|
|
1478
|
+
* @param {string} toplevel - the checkout root.
|
|
1479
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1480
|
+
* @returns {Promise<string[]>} branch names.
|
|
1481
|
+
*/
|
|
1482
|
+
async function readBranchNames(run, toplevel, signal) {
|
|
1483
|
+
const result = await run(toplevel, ['branch', '--all', '--no-color', '--format=%(refname)'], signal)
|
|
1484
|
+
if (result?.ok !== true) return []
|
|
1485
|
+
return String(result.stdout ?? '')
|
|
1486
|
+
.split('\n')
|
|
1487
|
+
.map((line) => line.trim())
|
|
1488
|
+
.filter((line) => line !== '')
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Resolve the branch a request names into the revision git should read.
|
|
1493
|
+
*
|
|
1494
|
+
* The client sends the full ref it was given (`refs/heads/main`,
|
|
1495
|
+
* `refs/remotes/origin/main`), never a bare name, so a local branch and a
|
|
1496
|
+
* remote-tracking branch with the same short name stay distinguishable and a
|
|
1497
|
+
* forged name cannot reach a different ref than the one listed.
|
|
1498
|
+
*
|
|
1499
|
+
* @param {Function} run - the git runner.
|
|
1500
|
+
* @param {string} toplevel - the checkout root.
|
|
1501
|
+
* @param {unknown} requested - the client's branch reference.
|
|
1502
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1503
|
+
* @returns {Promise<{ref: string, kind: string, shortName: string}>} the resolved branch.
|
|
1504
|
+
*/
|
|
1505
|
+
async function resolveBranchRef(run, toplevel, requested, signal) {
|
|
1506
|
+
const wanted = typeof requested === 'string' ? requested : ''
|
|
1507
|
+
if (wanted === '') return { ref: 'HEAD', kind: 'head', shortName: 'HEAD' }
|
|
1508
|
+
if (!isSafeBranchName(wanted) || wanted.startsWith('refs/tags/')) {
|
|
1509
|
+
throw new ViewerError('invalid-branch', '分支名不合法')
|
|
1510
|
+
}
|
|
1511
|
+
const known = await readBranchNames(run, toplevel, signal)
|
|
1512
|
+
if (!known.includes(wanted)) throw new ViewerError('unknown-branch', '该分支不存在')
|
|
1513
|
+
const kind = wanted.startsWith('refs/remotes/') ? 'remote' : 'local'
|
|
1514
|
+
// `%(refname:short)` strips exactly the namespace prefix git itself uses.
|
|
1515
|
+
const shortName = wanted.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\//, '')
|
|
1516
|
+
return { ref: wanted, kind, shortName }
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Resolve the repository a request names, re-discovering it from the root.
|
|
1521
|
+
*
|
|
1522
|
+
* The client sends an id it was given, never a path, so a forged request cannot
|
|
1523
|
+
* aim git at a directory outside the vetted root.
|
|
1524
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1525
|
+
* @param {() => object} options - resolved config reader.
|
|
1526
|
+
* @param {{root?: string, repo?: string}} payload - client request.
|
|
1527
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1528
|
+
* @returns {Promise<{root: object, repo: object, run: Function}>} the located repository.
|
|
1529
|
+
*/
|
|
1530
|
+
async function locateRepository(ctx, options, payload, signal) {
|
|
1531
|
+
const { root, repositories } = await discoverRepositories(ctx, options, payload, signal)
|
|
1532
|
+
if (repositories.length === 0) {
|
|
1533
|
+
throw new ViewerError('not-a-git-repo', '该目录及其子目录都不是 Git 仓库')
|
|
1534
|
+
}
|
|
1535
|
+
const requested = typeof payload?.repo === 'string' && payload.repo !== '' ? payload.repo : repositories[0].id
|
|
1536
|
+
const repo = repositories.find((candidate) => candidate.id === requested)
|
|
1537
|
+
if (repo === undefined) throw new ViewerError('unknown-repository', '请求的仓库不在当前根目录内')
|
|
1538
|
+
return { root, repo, run: gitRunnerFrom(options) }
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* List the repositories under one root, each with its branches.
|
|
1543
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1544
|
+
* @param {() => object} options - resolved config reader.
|
|
1545
|
+
* @param {{root?: string}} payload - client request.
|
|
1546
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1547
|
+
* @returns {Promise<{repos: object[], truncated: boolean}>} the repositories.
|
|
1548
|
+
*/
|
|
1549
|
+
export async function listGitRepositories(ctx, options, payload, signal) {
|
|
1550
|
+
const { repositories, truncated } = await discoverRepositories(ctx, options, payload, signal)
|
|
1551
|
+
const run = gitRunnerFrom(options)
|
|
1552
|
+
const repos = []
|
|
1553
|
+
for (const repo of repositories) {
|
|
1554
|
+
const state = await readRepositoryState(run, repo, signal)
|
|
1555
|
+
const remotes = await readRemotes(run, repo.toplevel, signal)
|
|
1556
|
+
repos.push({
|
|
1557
|
+
id: repo.id,
|
|
1558
|
+
name: repo.name,
|
|
1559
|
+
path: repo.path,
|
|
1560
|
+
// `root` vs `parent` decides whether a checkout stays inside the
|
|
1561
|
+
// directory the reader is browsing, so the client can warn before it.
|
|
1562
|
+
scope: repo.scope,
|
|
1563
|
+
current: state.current,
|
|
1564
|
+
detached: state.detached,
|
|
1565
|
+
empty: state.empty,
|
|
1566
|
+
remotes,
|
|
1567
|
+
branches: state.branches.map((branch) => ({
|
|
1568
|
+
// `refname` is the stable identifier: a local branch may share its
|
|
1569
|
+
// short name with a remote-tracking branch, and only the full ref
|
|
1570
|
+
// says which one a checkout or a history read refers to.
|
|
1571
|
+
name: branch.name,
|
|
1572
|
+
shortName: branch.shortName,
|
|
1573
|
+
kind: branch.kind,
|
|
1574
|
+
remote: branch.remote,
|
|
1575
|
+
current: branch.current,
|
|
1576
|
+
sha: branch.sha,
|
|
1577
|
+
date: branch.date,
|
|
1578
|
+
upstream: branch.upstream,
|
|
1579
|
+
// Browsing a remote branch is read-only; checking it out silently
|
|
1580
|
+
// creates a detached HEAD or a new local branch.
|
|
1581
|
+
checkoutable: branch.kind !== 'remote',
|
|
1582
|
+
})),
|
|
1583
|
+
})
|
|
1584
|
+
}
|
|
1585
|
+
return { repos, truncated }
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Page one branch's commit history.
|
|
1590
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1591
|
+
* @param {() => object} options - resolved config reader.
|
|
1592
|
+
* @param {{root?: string, repo?: string, branch?: string, offset?: number, limit?: number}} payload - client request.
|
|
1593
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1594
|
+
* @returns {Promise<object>} the page plus the totals the pager needs.
|
|
1595
|
+
*/
|
|
1596
|
+
export async function listGitCommits(ctx, options, payload, signal) {
|
|
1597
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1598
|
+
const page = normalizeCommitPage(payload)
|
|
1599
|
+
|
|
1600
|
+
// No branch named: follow whatever is checked out. A remote-tracking branch
|
|
1601
|
+
// is a legitimate choice here, which is why the resolution is shared with
|
|
1602
|
+
// checkout instead of reading a bare `abbrev-ref`.
|
|
1603
|
+
const resolved = await resolveBranchRef(run, repo.toplevel, payload?.branch, signal)
|
|
1604
|
+
const branch = resolved.ref === 'HEAD' ? '' : resolved.ref
|
|
1605
|
+
const rev = resolved.ref
|
|
1606
|
+
|
|
1607
|
+
const count = await run(repo.toplevel, ['rev-list', '--count', rev, '--'], signal)
|
|
1608
|
+
if (count?.ok !== true) {
|
|
1609
|
+
// An empty checkout has no history to page; that is a state, not a fault.
|
|
1610
|
+
return {
|
|
1611
|
+
repository: repo.id, branch, branchKind: resolved.kind, shortName: resolved.shortName,
|
|
1612
|
+
total: 0, offset: 0, limit: page.limit, commits: [], hasMore: false,
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
const parsedTotal = Number.parseInt(String(count.stdout ?? '').trim(), 10)
|
|
1616
|
+
|
|
1617
|
+
const log = await run(repo.toplevel, [
|
|
1618
|
+
'log', rev,
|
|
1619
|
+
`--max-count=${page.limit}`,
|
|
1620
|
+
`--skip=${page.offset}`,
|
|
1621
|
+
'--no-color',
|
|
1622
|
+
'--no-decorate',
|
|
1623
|
+
`--format=${GIT_LOG_FORMAT}`,
|
|
1624
|
+
'--',
|
|
1625
|
+
], signal)
|
|
1626
|
+
if (log?.ok !== true) throw new ViewerError('git-failed', '无法读取提交记录')
|
|
1627
|
+
|
|
1628
|
+
const commits = parseGitLog(log.stdout)
|
|
1629
|
+
const safeTotal = Number.isSafeInteger(parsedTotal) && parsedTotal >= 0 ? parsedTotal : page.offset + commits.length
|
|
1630
|
+
return {
|
|
1631
|
+
repository: repo.id,
|
|
1632
|
+
// The full ref, so the client can keep selecting the same branch; plus what
|
|
1633
|
+
// kind it is, because a remote branch is browse-only.
|
|
1634
|
+
branch,
|
|
1635
|
+
branchKind: resolved.kind,
|
|
1636
|
+
shortName: resolved.shortName,
|
|
1637
|
+
total: safeTotal,
|
|
1638
|
+
offset: page.offset,
|
|
1639
|
+
limit: page.limit,
|
|
1640
|
+
commits,
|
|
1641
|
+
hasMore: page.offset + commits.length < safeTotal,
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Read one commit's metadata and the files it changed.
|
|
1647
|
+
*
|
|
1648
|
+
* The diff is taken against the first parent, which is what "the changes in
|
|
1649
|
+
* this commit" means for a merge as well; a root commit is compared against the
|
|
1650
|
+
* empty tree instead.
|
|
1651
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1652
|
+
* @param {() => object} options - resolved config reader.
|
|
1653
|
+
* @param {{root?: string, repo?: string, sha?: string}} payload - client request.
|
|
1654
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1655
|
+
* @returns {Promise<{repository: string, commit: object, files: object[]}>} the detail.
|
|
1656
|
+
*/
|
|
1657
|
+
export async function readGitCommitFiles(ctx, options, payload, signal) {
|
|
1658
|
+
const sha = String(payload?.sha ?? '').toLowerCase()
|
|
1659
|
+
if (!isSafeCommitSha(sha)) throw new ViewerError('invalid-commit', '提交标识不合法')
|
|
1660
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1661
|
+
|
|
1662
|
+
const header = await run(repo.toplevel, [
|
|
1663
|
+
'log', '-1', '--no-color', `--format=${GIT_COMMIT_FORMAT}`, sha, '--',
|
|
1664
|
+
], signal)
|
|
1665
|
+
const parsed = header?.ok === true ? parseGitCommitDetail(header.stdout) : null
|
|
1666
|
+
if (parsed === null) throw new ViewerError('unknown-commit', '找不到该提交')
|
|
1667
|
+
|
|
1668
|
+
const parents = await run(repo.toplevel, ['rev-list', '--parents', '--max-count=1', sha, '--'], signal)
|
|
1669
|
+
const parentSha = parents?.ok === true ? String(parents.stdout ?? '').trim().split(/\s+/)[1] ?? '' : ''
|
|
1670
|
+
const diffArgs = parentSha === '' || !isSafeCommitSha(parentSha)
|
|
1671
|
+
? ['diff-tree', '--root', '--no-commit-id', '-r', '-z', '--name-status', sha]
|
|
1672
|
+
: ['diff-tree', '--no-commit-id', '-r', '-z', '--name-status', '-M', parentSha, sha]
|
|
1673
|
+
const diff = await run(repo.toplevel, diffArgs, signal)
|
|
1674
|
+
// A failed diff is not an empty commit. Reporting zero files here would tell
|
|
1675
|
+
// the reader the commit changed nothing, which is a different claim entirely.
|
|
1676
|
+
if (diff?.ok !== true) throw new ViewerError('git-failed', '无法读取该提交的改动文件')
|
|
1677
|
+
const changes = parseGitNameStatus(diff.stdout)
|
|
1678
|
+
|
|
1679
|
+
const placement = { prefix: repo.path, rootRelative: repo.rootRelative }
|
|
1680
|
+
const files = changes.map((change) => {
|
|
1681
|
+
const current = commitPathWithinRoot(placement, change.path)
|
|
1682
|
+
const previous = change.oldPath === null ? null : commitPathWithinRoot(placement, change.oldPath)
|
|
1683
|
+
const view = gitChangeOf(change.status)
|
|
1684
|
+
return {
|
|
1685
|
+
// Root-relative, so clicking the row can open it in the same preview
|
|
1686
|
+
// pane the file tree uses; `repoPath` keeps git's own wording.
|
|
1687
|
+
path: current.path,
|
|
1688
|
+
repoPath: change.path,
|
|
1689
|
+
oldPath: previous === null ? null : previous.path,
|
|
1690
|
+
status: change.status,
|
|
1691
|
+
letter: view.letter,
|
|
1692
|
+
change: view.label,
|
|
1693
|
+
tone: view.tone,
|
|
1694
|
+
outsideRoot: current.outsideRoot,
|
|
1695
|
+
}
|
|
1696
|
+
})
|
|
1697
|
+
|
|
1698
|
+
return {
|
|
1699
|
+
repository: repo.id,
|
|
1700
|
+
commit: { ...parsed.commit, body: parsed.body },
|
|
1701
|
+
// The first parent, when there is one. A deletion's contents live here, so
|
|
1702
|
+
// the client needs the hash to open "what the file was before this commit"
|
|
1703
|
+
// instead of asking for a path the commit no longer contains.
|
|
1704
|
+
parentSha: parentSha !== '' && isSafeCommitSha(parentSha) ? parentSha : '',
|
|
1705
|
+
files,
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Read one file as it existed in a commit.
|
|
1711
|
+
*
|
|
1712
|
+
* This is what makes "click a changed file" honest: the preview shows the bytes
|
|
1713
|
+
* of that commit, not whatever the working tree happens to contain now. A file
|
|
1714
|
+
* deleted by the commit, or a path that has since moved, still renders, and a
|
|
1715
|
+
* commit on another branch no longer previews the wrong content.
|
|
1716
|
+
*
|
|
1717
|
+
* `rev` is the commit's own blob path, so the request is bounded to a commit
|
|
1718
|
+
* that `readGitCommitFiles` already listed under this repository — the client
|
|
1719
|
+
* cannot ask for an arbitrary revision, only a concrete hash.
|
|
1720
|
+
*
|
|
1721
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1722
|
+
* @param {() => object} options - resolved config reader.
|
|
1723
|
+
* @param {{root?: string, repo?: string, sha?: string, path?: string, offset?: number, limit?: number}} payload - client request.
|
|
1724
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1725
|
+
* @returns {Promise<object>} the same shape `read` returns, so the preview reuses it.
|
|
1726
|
+
*/
|
|
1727
|
+
export async function readGitCommitFile(ctx, options, payload, signal) {
|
|
1728
|
+
const sha = String(payload?.sha ?? '').toLowerCase()
|
|
1729
|
+
if (!isSafeCommitSha(sha)) throw new ViewerError('invalid-commit', '提交标识不合法')
|
|
1730
|
+
const filePath = typeof payload?.path === 'string' ? payload.path : ''
|
|
1731
|
+
if (filePath === '') throw new ViewerError('not-found', '缺少文件路径')
|
|
1732
|
+
// A deletion removed the path from this commit; its contents are still in the
|
|
1733
|
+
// parent. The caller may name that parent explicitly, and it is verified to be
|
|
1734
|
+
// a commit of this repository before it is used.
|
|
1735
|
+
const requestedFrom = payload?.from === undefined ? '' : String(payload.from).toLowerCase()
|
|
1736
|
+
if (requestedFrom !== '' && !isSafeCommitSha(requestedFrom)) {
|
|
1737
|
+
throw new ViewerError('invalid-commit', '提交标识不合法')
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1741
|
+
const from = requestedFrom === '' ? sha : requestedFrom
|
|
1742
|
+
if (from !== sha) {
|
|
1743
|
+
// Only a commit that actually exists here may be read: the hash came from
|
|
1744
|
+
// the client, and a random object name must not become a read primitive.
|
|
1745
|
+
const verify = await run(repo.toplevel, ['cat-file', '-e', `${from}^{commit}`], signal)
|
|
1746
|
+
if (verify?.ok !== true) throw new ViewerError('unknown-commit', '找不到该提交')
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// The requested path is root-relative; git wants it repository-relative, and
|
|
1750
|
+
// the placement says which way to translate. A path that does not resolve
|
|
1751
|
+
// back inside the root is refused rather than guessed at.
|
|
1752
|
+
const placement = { prefix: repo.path, rootRelative: repo.rootRelative }
|
|
1753
|
+
const repoPath = repoRelativeFromRoot(placement, filePath)
|
|
1754
|
+
if (repoPath === null) throw new ViewerError('outside-root', '该文件不在当前根目录内')
|
|
1755
|
+
|
|
1756
|
+
// `--` separates the revision from the path, so a path that looks like a
|
|
1757
|
+
// revision expression cannot be reinterpreted as one.
|
|
1758
|
+
const result = await run(repo.toplevel, ['show', `${from}:${repoPath}`], signal)
|
|
1759
|
+
if (result?.ok !== true) {
|
|
1760
|
+
const stderr = String(result?.stderr ?? '')
|
|
1761
|
+
// A path absent from that commit is the ordinary "this file did not exist
|
|
1762
|
+
// yet / was already deleted" case, distinct from a broken repository.
|
|
1763
|
+
if (/does not exist|exists on disk, but not in|invalid object name|Path .* does not exist/i.test(stderr)) {
|
|
1764
|
+
throw new ViewerError('not-found', '该提交中不存在这个文件')
|
|
1765
|
+
}
|
|
1766
|
+
throw new ViewerError('git-failed', '无法读取该提交中的文件')
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const text = String(result.stdout ?? '')
|
|
1770
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_TEXT_BYTES) {
|
|
1771
|
+
throw new ViewerError('too-large', 'the file in this commit is too large to read as text')
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const name = baseNameOf(filePath)
|
|
1775
|
+
const { lines, eol } = splitLines(text)
|
|
1776
|
+
const window = resolveWindow(lines.length, payload?.offset, payload?.limit)
|
|
1777
|
+
const fileKind = kindOf(filePath)
|
|
1778
|
+
// A blob from history has no file on disk to probe, so the name decides the
|
|
1779
|
+
// kind and the content decides the language.
|
|
1780
|
+
const detected = resolveLanguage({ override: payload?.lang, path: filePath, sample: text })
|
|
1781
|
+
const lang = detected.lang
|
|
1782
|
+
return {
|
|
1783
|
+
root: repo.id,
|
|
1784
|
+
path: filePath,
|
|
1785
|
+
name,
|
|
1786
|
+
// Kept so the header can show where the bytes came from; the client labels
|
|
1787
|
+
// the pane as history rather than the working tree.
|
|
1788
|
+
commit: from,
|
|
1789
|
+
displayPath: `${name} @ ${from.slice(0, 7)}`,
|
|
1790
|
+
kind: fileKind,
|
|
1791
|
+
...(lang === undefined ? {} : { lang }),
|
|
1792
|
+
langSource: detected.source,
|
|
1793
|
+
langChoice: isKnownLanguage(payload?.lang) ? payload.lang : 'auto',
|
|
1794
|
+
eol,
|
|
1795
|
+
totalLines: lines.length,
|
|
1796
|
+
offset: window.offset,
|
|
1797
|
+
end: window.end,
|
|
1798
|
+
hasBefore: window.hasBefore,
|
|
1799
|
+
hasAfter: window.hasAfter,
|
|
1800
|
+
lines: windowRows(lines, window),
|
|
1801
|
+
...(isWholeDocumentKind(fileKind) && text.length <= MAX_PREVIEW_BYTES ? { text } : {}),
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Read the working tree's pending changes, grouped for a source-control view.
|
|
1807
|
+
*
|
|
1808
|
+
* Staged and unstaged entries are kept apart because they answer different
|
|
1809
|
+
* questions, and untracked files are their own group because they are not part
|
|
1810
|
+
* of any commit yet. Paths are rebased onto the selected root, and the number
|
|
1811
|
+
* that fell outside it is reported rather than silently dropped.
|
|
1812
|
+
*
|
|
1813
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1814
|
+
* @param {() => object} options - resolved config reader.
|
|
1815
|
+
* @param {{root?: string, repo?: string}} payload - client request.
|
|
1816
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1817
|
+
* @returns {Promise<object>} the grouped changes.
|
|
1818
|
+
*/
|
|
1819
|
+
export async function listGitWorkspaceChanges(ctx, options, payload, signal) {
|
|
1820
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1821
|
+
// `-z` keeps non-ASCII paths and renames unambiguous; `--untracked-files=all`
|
|
1822
|
+
// lists files inside a new directory instead of just the directory.
|
|
1823
|
+
const result = await run(repo.toplevel, [
|
|
1824
|
+
// `-z` keeps non-ASCII paths and renames unambiguous; listing every
|
|
1825
|
+
// untracked file (rather than its directory) is what makes them openable.
|
|
1826
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=all',
|
|
1827
|
+
], signal)
|
|
1828
|
+
if (result?.ok !== true) throw new ViewerError('git-failed', '无法读取工作区状态')
|
|
1829
|
+
|
|
1830
|
+
const entries = parseGitStatusEntries(result.stdout)
|
|
1831
|
+
const placement = { prefix: repo.path, rootRelative: repo.rootRelative }
|
|
1832
|
+
const groups = groupGitChanges(entries, (repoPath) => commitPathWithinRoot(placement, repoPath))
|
|
1833
|
+
|
|
1834
|
+
// The current branch is part of the answer: "changes on main" is not the same
|
|
1835
|
+
// fact as "changes on dev", and the panel shows both together.
|
|
1836
|
+
const head = await gitRevisionOf(run, repo.toplevel, ['--abbrev-ref', 'HEAD'], signal)
|
|
1837
|
+
return {
|
|
1838
|
+
repository: repo.id,
|
|
1839
|
+
current: head === 'HEAD' ? '' : head,
|
|
1840
|
+
detached: head === 'HEAD',
|
|
1841
|
+
staged: groups.staged,
|
|
1842
|
+
unstaged: groups.unstaged,
|
|
1843
|
+
untracked: groups.untracked,
|
|
1844
|
+
conflicted: groups.conflicted,
|
|
1845
|
+
outside: groups.outside,
|
|
1846
|
+
total: groups.staged.length + groups.unstaged.length + groups.untracked.length + groups.conflicted.length,
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Read one file's working-tree diff, staged or unstaged.
|
|
1852
|
+
*
|
|
1853
|
+
* `git diff` with no revision shows the unstaged side; `--cached` shows what a
|
|
1854
|
+
* commit would capture. Both are offered because a file can appear in each.
|
|
1855
|
+
*
|
|
1856
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1857
|
+
* @param {() => object} options - resolved config reader.
|
|
1858
|
+
* @param {{root?: string, repo?: string, path?: string, staged?: boolean}} payload - client request.
|
|
1859
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1860
|
+
* @returns {Promise<object>} the patch plus parsed rows.
|
|
1861
|
+
*/
|
|
1862
|
+
export async function readGitWorkspaceDiff(ctx, options, payload, signal) {
|
|
1863
|
+
const filePath = typeof payload?.path === 'string' ? payload.path : ''
|
|
1864
|
+
if (filePath === '') throw new ViewerError('not-found', '缺少文件路径')
|
|
1865
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1866
|
+
|
|
1867
|
+
const placement = { prefix: repo.path, rootRelative: repo.rootRelative }
|
|
1868
|
+
const repoPath = repoRelativeFromRoot(placement, filePath)
|
|
1869
|
+
if (repoPath === null) throw new ViewerError('outside-root', '该文件不在当前根目录内')
|
|
1870
|
+
|
|
1871
|
+
const staged = payload?.staged === true
|
|
1872
|
+
const args = staged
|
|
1873
|
+
? ['diff', '--cached', '--no-color', '--', repoPath]
|
|
1874
|
+
: ['diff', '--no-color', '--', repoPath]
|
|
1875
|
+
const result = await run(repo.toplevel, args, signal)
|
|
1876
|
+
|
|
1877
|
+
if (result?.ok === true && String(result.stdout ?? '').trim() !== '') {
|
|
1878
|
+
const patch = String(result.stdout)
|
|
1879
|
+
const files = parseUnifiedDiffFiles(patch)
|
|
1880
|
+
const rows = files.length > 0 ? files[0].rows : parsePatchToRows(patch)
|
|
1881
|
+
const diffs = parsePatchToDiffs(patch, filePath)
|
|
1882
|
+
return { hasDiff: true, staged, path: filePath, patch, rows, diffs, summary: summarizeDiffs(diffs) }
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// An untracked file has no diff at all: every line is an addition. Rendering
|
|
1886
|
+
// it as one keeps an untracked row useful instead of opening an empty pane.
|
|
1887
|
+
const tracked = await run(repo.toplevel, ['ls-files', '--error-unmatch', '--', repoPath], signal)
|
|
1888
|
+
if (tracked?.ok !== true && staged === false) {
|
|
1889
|
+
const file = await run(repo.toplevel, ['show', `:0:${repoPath}`], signal)
|
|
1890
|
+
void file
|
|
1891
|
+
const read = await ctx.fs.readText(
|
|
1892
|
+
await ctx.fs.resolve(joinPath(repo.toplevel, repoPath), { signal }),
|
|
1893
|
+
signal,
|
|
1894
|
+
).catch(() => '')
|
|
1895
|
+
if (typeof read === 'string' && read !== '') {
|
|
1896
|
+
const rows = parsePatchToRows('', read)
|
|
1897
|
+
return { hasDiff: true, staged: false, untracked: true, path: filePath, rows, patch: '', diffs: [] }
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
return { hasDiff: false, staged, path: filePath, rows: [], diffs: [] }
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Read one commit's complete patch, every file in one view.
|
|
1906
|
+
*
|
|
1907
|
+
* The changed-file list answers "what did this commit touch"; the diff answers
|
|
1908
|
+
* "what exactly changed inside it". Both come from the same revision pair as
|
|
1909
|
+
* the list, so the two views cannot disagree about which parent is the baseline.
|
|
1910
|
+
*
|
|
1911
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1912
|
+
* @param {() => object} options - resolved config reader.
|
|
1913
|
+
* @param {{root?: string, repo?: string, sha?: string, path?: string}} payload - client request.
|
|
1914
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1915
|
+
* @returns {Promise<object>} per-file patches.
|
|
1916
|
+
*/
|
|
1917
|
+
export async function readGitCommitDiff(ctx, options, payload, signal) {
|
|
1918
|
+
const sha = String(payload?.sha ?? '').toLowerCase()
|
|
1919
|
+
if (!isSafeCommitSha(sha)) throw new ViewerError('invalid-commit', '提交标识不合法')
|
|
1920
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
1921
|
+
|
|
1922
|
+
const parents = await run(repo.toplevel, ['rev-list', '--parents', '--max-count=1', sha, '--'], signal)
|
|
1923
|
+
const parentSha = parents?.ok === true ? String(parents.stdout ?? '').trim().split(/\s+/)[1] ?? '' : ''
|
|
1924
|
+
const hasParent = parentSha !== '' && isSafeCommitSha(parentSha)
|
|
1925
|
+
|
|
1926
|
+
// One path narrows the patch to that file; no path returns the whole commit.
|
|
1927
|
+
const placement = { prefix: repo.path, rootRelative: repo.rootRelative }
|
|
1928
|
+
const wanted = typeof payload?.path === 'string' && payload.path !== ''
|
|
1929
|
+
? repoRelativeFromRoot(placement, payload.path)
|
|
1930
|
+
: null
|
|
1931
|
+
if (wanted === null && typeof payload?.path === 'string' && payload.path !== '') {
|
|
1932
|
+
throw new ViewerError('outside-root', '该文件不在当前根目录内')
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
const base = hasParent
|
|
1936
|
+
? ['diff', '--no-color', '-M', parentSha, sha]
|
|
1937
|
+
: ['show', '--no-color', '--format=', '--first-parent', sha]
|
|
1938
|
+
const args = wanted === null ? base : [...base, '--', wanted]
|
|
1939
|
+
const result = await run(repo.toplevel, args, signal)
|
|
1940
|
+
if (result?.ok !== true) throw new ViewerError('git-failed', '无法读取该提交的改动内容')
|
|
1941
|
+
|
|
1942
|
+
const patch = String(result.stdout ?? '')
|
|
1943
|
+
const files = parseUnifiedDiffFiles(patch).map((file) => {
|
|
1944
|
+
// The patch's own paths are repository-relative; the client addresses
|
|
1945
|
+
// files by root-relative path.
|
|
1946
|
+
const current = commitPathWithinRoot(placement, file.path)
|
|
1947
|
+
const previous = file.oldPath === '' ? null : commitPathWithinRoot(placement, file.oldPath)
|
|
1948
|
+
const badge = gitChangeOf(file.status)
|
|
1949
|
+
return {
|
|
1950
|
+
path: current.path,
|
|
1951
|
+
repoPath: file.path,
|
|
1952
|
+
oldPath: previous === null ? null : previous.path,
|
|
1953
|
+
status: file.status,
|
|
1954
|
+
letter: badge.letter,
|
|
1955
|
+
change: badge.label,
|
|
1956
|
+
tone: badge.tone,
|
|
1957
|
+
outsideRoot: current.outsideRoot,
|
|
1958
|
+
added: file.added,
|
|
1959
|
+
removed: file.removed,
|
|
1960
|
+
rows: file.rows,
|
|
1961
|
+
}
|
|
1962
|
+
})
|
|
1963
|
+
|
|
1964
|
+
const added = files.reduce((sum, file) => sum + file.added, 0)
|
|
1965
|
+
const removed = files.reduce((sum, file) => sum + file.removed, 0)
|
|
1966
|
+
return {
|
|
1967
|
+
repository: repo.id,
|
|
1968
|
+
sha,
|
|
1969
|
+
parentSha: hasParent ? parentSha : '',
|
|
1970
|
+
patch,
|
|
1971
|
+
files,
|
|
1972
|
+
// Counted here so the header can show `+N −M` without re-walking rows.
|
|
1973
|
+
summary: { added, removed },
|
|
1974
|
+
outside: files.filter((file) => file.outsideRoot).length,
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* Check out one branch in a repository.
|
|
1980
|
+
*
|
|
1981
|
+
* This is the only writing operation the panel performs, and it refuses
|
|
1982
|
+
* anything that is not an existing branch of the located checkout: the branch
|
|
1983
|
+
* name is validated as a ref, then compared against git's own branch list, so
|
|
1984
|
+
* a detached checkout cannot be created by accident.
|
|
1985
|
+
*
|
|
1986
|
+
* When the root is a *subdirectory* of the checkout, the branch lives above the
|
|
1987
|
+
* directory the reader is browsing and checking it out rewrites files this
|
|
1988
|
+
* plugin was never allowed to read, let alone change. That case is refused
|
|
1989
|
+
* unless the caller explicitly passes `allowOutsideRoot`, which the UI only
|
|
1990
|
+
* sends after showing the reader what the operation actually covers.
|
|
1991
|
+
*
|
|
1992
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
1993
|
+
* @param {() => object} options - resolved config reader.
|
|
1994
|
+
* @param {{root?: string, repo?: string, branch?: string, allowOutsideRoot?: boolean}} payload - client request.
|
|
1995
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
1996
|
+
* @returns {Promise<{ok: true, branch: string, previous: string, changed: boolean, scope: string}>} the new state.
|
|
1997
|
+
*/
|
|
1998
|
+
export async function checkoutGitBranch(ctx, options, payload, signal) {
|
|
1999
|
+
const branch = payload?.branch
|
|
2000
|
+
if (!isSafeBranchName(branch)) throw new ViewerError('invalid-branch', '分支名不合法')
|
|
2001
|
+
// A remote-tracking branch is not a local branch: `git checkout origin/main`
|
|
2002
|
+
// produces a detached HEAD (or a new local branch, depending on config)
|
|
2003
|
+
// rather than switching to something the panel could then show as current.
|
|
2004
|
+
if (String(branch).startsWith('refs/remotes/')) {
|
|
2005
|
+
throw new ViewerError('remote-branch-readonly', '远程分支只能查看,不能直接检出')
|
|
2006
|
+
}
|
|
2007
|
+
const { repo, run } = await locateRepository(ctx, options, payload, signal)
|
|
2008
|
+
|
|
2009
|
+
// A checkout above the root would touch files outside it; that needs an
|
|
2010
|
+
// explicit, informed consent from the caller, not a default.
|
|
2011
|
+
if (repo.scope === 'parent' && payload?.allowOutsideRoot !== true) {
|
|
2012
|
+
throw new ViewerError('checkout-outside-root', '该分支所在的仓库位于当前根目录的上级,检出会影响根目录以外的文件', {
|
|
2013
|
+
scope: repo.scope,
|
|
2014
|
+
})
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const known = await readBranchNames(run, repo.toplevel, signal)
|
|
2018
|
+
if (!known.includes(branch)) throw new ViewerError('unknown-branch', '该分支不存在')
|
|
2019
|
+
|
|
2020
|
+
const head = await gitRevisionOf(run, repo.toplevel, ['--abbrev-ref', 'HEAD'], signal)
|
|
2021
|
+
const previous = head === 'HEAD' ? '' : head
|
|
2022
|
+
// `previous` is a short name while `branch` is a full ref; compare like with
|
|
2023
|
+
// like or a checkout of the current branch would look like a change.
|
|
2024
|
+
const branchShort = String(branch).replace(/^refs\/heads\//, '')
|
|
2025
|
+
if (previous === branchShort) {
|
|
2026
|
+
return { ok: true, branch, previous, changed: false, scope: repo.scope }
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// Hooks and aliases are user code this plugin was never granted the right to
|
|
2030
|
+
// run: a viewer that silently executes a repository's `post-checkout` turns
|
|
2031
|
+
// a read-only panel into an arbitrary-execution surface. An empty
|
|
2032
|
+
// `core.hooksPath` disables hooks for this one invocation only.
|
|
2033
|
+
const result = await run(repo.toplevel, ['-c', 'core.hooksPath=', 'checkout', branchShort], signal)
|
|
2034
|
+
if (result?.ok !== true) {
|
|
2035
|
+
const stderr = String(result?.stderr ?? '')
|
|
2036
|
+
// git refuses a checkout that would overwrite local edits. That is the
|
|
2037
|
+
// common, explainable outcome, so it gets its own code and message.
|
|
2038
|
+
if (/would be overwritten|local changes|Please commit your changes|Please stash/i.test(stderr)) {
|
|
2039
|
+
throw new ViewerError('git-dirty', '工作区有未提交的改动,无法切换分支')
|
|
2040
|
+
}
|
|
2041
|
+
throw new ViewerError('git-failed', '切换分支失败')
|
|
2042
|
+
}
|
|
2043
|
+
return { ok: true, branch, previous, changed: true, scope: repo.scope }
|
|
2044
|
+
}
|
|
2045
|
+
|
|
1104
2046
|
/**
|
|
1105
2047
|
* Retrieve current safe access paths configuration along with built-in workspaces.
|
|
1106
2048
|
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
@@ -1150,6 +2092,16 @@ const METHODS = Object.freeze({
|
|
|
1150
2092
|
read: readText,
|
|
1151
2093
|
diff: getFileDiff,
|
|
1152
2094
|
status: getWorkspaceGitStatus,
|
|
2095
|
+
// The Git panel: discovery, paged history, one commit's files, checkout.
|
|
2096
|
+
gitRepos: listGitRepositories,
|
|
2097
|
+
gitCommits: listGitCommits,
|
|
2098
|
+
gitCommitFiles: readGitCommitFiles,
|
|
2099
|
+
gitCommitFile: readGitCommitFile,
|
|
2100
|
+
// One commit's complete patch, and the working tree's pending changes.
|
|
2101
|
+
gitCommitDiff: readGitCommitDiff,
|
|
2102
|
+
gitStatusChanges: listGitWorkspaceChanges,
|
|
2103
|
+
gitWorkspaceDiff: readGitWorkspaceDiff,
|
|
2104
|
+
gitCheckout: checkoutGitBranch,
|
|
1153
2105
|
getSafePaths: getSafePathsConfig,
|
|
1154
2106
|
updateSafePaths: updateSafePathsConfig,
|
|
1155
2107
|
bytes: readBytes,
|