@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/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
- parentOf,
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,