dsh-gitbar-lite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Repository operations: everything the panel asks for, expressed as git.
3
+ *
4
+ * The owning rule here is that **a tick in the file list is the real index**.
5
+ * Ticking runs `git add`, unticking runs `git restore --staged`, and Commit runs
6
+ * a plain `git commit` with no pathspec — so what the panel shows, what
7
+ * `git status` in a terminal shows, and what gets committed cannot drift apart.
8
+ * A pathspec-limited commit would look the same in the panel while quietly
9
+ * ignoring whatever the user had staged by hand.
10
+ *
11
+ * Two consequences worth stating up front, because they are user-visible:
12
+ *
13
+ * - A file whose change is only half staged (changed in the index and again
14
+ * in the worktree) cannot be represented by one tick. It renders as
15
+ * indeterminate and either tick re-states it as a whole.
16
+ * - Discarding is two steps, in this order: **archive, then restore**. The
17
+ * archive is the only copy of the change, and `git restore` destroys the
18
+ * original, so the order is not an optimization.
19
+ *
20
+ * @module dsh-gitbar-lite/host/repo
21
+ */
22
+
23
+ import { readFile, readdir, rm, stat } from 'node:fs/promises'
24
+ import { basename, join, resolve, sep } from 'node:path'
25
+
26
+ import { DISCARD_SCOPE, ERROR_CODES, FILE_STATUS, PRIMARY_ACTIONS } from '../shared.js'
27
+ import {
28
+ countContentLines,
29
+ diffTotals,
30
+ joinDiffParts,
31
+ limitDiff,
32
+ looksBinary,
33
+ modeTextFor,
34
+ synthesizeNewFileDiff,
35
+ } from './diff.js'
36
+ import { parseNumstat, parseStatus } from './parse.js'
37
+
38
+ /** Directories never worth descending into while hunting for repositories. */
39
+ const SKIP_DIRECTORIES = new Set([
40
+ '.git',
41
+ '.hg',
42
+ '.svn',
43
+ 'node_modules',
44
+ 'bower_components',
45
+ '.venv',
46
+ 'venv',
47
+ '__pycache__',
48
+ '.next',
49
+ '.nuxt',
50
+ '.cache',
51
+ 'dist',
52
+ 'build',
53
+ 'out',
54
+ 'target',
55
+ 'vendor',
56
+ '.gradle',
57
+ '.m2',
58
+ '.nuget',
59
+ 'obj',
60
+ 'bin',
61
+ '.tox',
62
+ '.pytest_cache',
63
+ 'coverage',
64
+ ])
65
+
66
+ /** Ceiling on directories visited by one repository scan. */
67
+ const SCAN_VISIT_LIMIT = 3000
68
+
69
+ /** Concurrent reads when sizing untracked files. */
70
+ const READ_CONCURRENCY = 8
71
+
72
+ /** A typed error the route layer turns into `{ ok:false, error }` verbatim. */
73
+ export class RepoError extends Error {
74
+ /**
75
+ * @param {string} code one of `ERROR_CODES`
76
+ * @param {string} message already user-facing (the client shows it as-is)
77
+ */
78
+ constructor(code, message) {
79
+ super(message)
80
+ this.name = 'RepoError'
81
+ this.code = code
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Wrap a path as a literal git pathspec.
87
+ *
88
+ * Without this, a file named `docs` would let `git restore -- docs` reach
89
+ * `docs/anything` as well, and a file named `a[1].txt` would be treated as a
90
+ * pattern. `:(literal)` says "this exact path, no pattern, no prefix walk",
91
+ * which is the only semantics a per-file action can safely have.
92
+ */
93
+ export function literal(path) {
94
+ return `:(literal)${path}`
95
+ }
96
+
97
+ /** Every path a status entry occupies: the new one and, for a rename, the old. */
98
+ function entryPaths(entry) {
99
+ return entry.origPath === undefined ? [entry.path] : [entry.path, entry.origPath]
100
+ }
101
+
102
+ /** Join a repository-relative path onto its root, refusing an escape attempt. */
103
+ function absoluteIn(repoRoot, path) {
104
+ const absolute = resolve(repoRoot, path)
105
+ const root = resolve(repoRoot)
106
+ if (absolute !== root && !absolute.startsWith(root.endsWith(sep) ? root : root + sep)) {
107
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, `path escapes the repository: ${path}`)
108
+ }
109
+ return absolute
110
+ }
111
+
112
+ /** Read one untracked file, bounded, for stats and for the synthesized diff. */
113
+ async function readUntracked(repoRoot, path, maxFileBytes) {
114
+ let stats
115
+ try {
116
+ stats = await stat(absoluteIn(repoRoot, path))
117
+ } catch {
118
+ return { missing: true, binary: false, mode: '100644', content: '', lines: 0 }
119
+ }
120
+ if (!stats.isFile()) return { missing: true, binary: false, mode: '100644', content: '', lines: 0 }
121
+
122
+ const mode = modeTextFor(stats)
123
+ if (stats.size > maxFileBytes) return { oversized: true, binary: false, mode, content: '', lines: 0, bytes: stats.size }
124
+
125
+ let bytes
126
+ try {
127
+ bytes = await readFile(absoluteIn(repoRoot, path))
128
+ } catch {
129
+ return { missing: true, binary: false, mode, content: '', lines: 0 }
130
+ }
131
+ if (looksBinary(bytes)) return { binary: true, mode, content: '', lines: 0, bytes: bytes.length }
132
+ const content = bytes.toString('utf8')
133
+ return { binary: false, mode, content, lines: countContentLines(content), bytes: bytes.length }
134
+ }
135
+
136
+ /** Run `work` over `items` with a small concurrency ceiling, preserving order. */
137
+ async function mapLimit(items, limit, work) {
138
+ const results = new Array(items.length)
139
+ let cursor = 0
140
+ const workers = new Array(Math.min(limit, items.length)).fill(undefined).map(async () => {
141
+ for (;;) {
142
+ const index = cursor
143
+ cursor += 1
144
+ if (index >= items.length) return
145
+ results[index] = await work(items[index], index)
146
+ }
147
+ })
148
+ await Promise.all(workers)
149
+ return results
150
+ }
151
+
152
+ /** Turn a git failure into something a user can act on. */
153
+ export function classifyGitFailure(error, fallbackCode = ERROR_CODES.GIT_FAILED) {
154
+ // stdout matters as much as stderr here: `git commit` with nothing staged
155
+ // exits 1 and says "nothing to commit, working tree clean" on **stdout**,
156
+ // leaving stderr empty — so a stderr-only matcher would report that as an
157
+ // unexplained internal failure.
158
+ const text = `${error?.stderr ?? ''}\n${error?.stdout ?? ''}\n${error?.message ?? ''}`
159
+ if (/would be overwritten by merge|local changes/i.test(text)) {
160
+ return new RepoError(ERROR_CODES.CONFLICT, '有未提交的本地改动会被覆盖,请先处理这些文件。')
161
+ }
162
+ if (/non-fast-forward|fetch first|\[rejected\]|failed to push some refs/i.test(text)) {
163
+ return new RepoError(ERROR_CODES.PUSH_REJECTED, '推送被拒绝:远端有你本地没有的提交,先 Fetch 一下再推。')
164
+ }
165
+ if (/Authentication failed|could not read Username|Permission denied \(publickey\)|terminal prompts disabled|Invalid username or password|403/i.test(text)) {
166
+ return new RepoError(ERROR_CODES.AUTH, '推送认证失败:本机 git 没有可用的凭据,先在终端里成功 push 一次,或配上凭据助手。')
167
+ }
168
+ if (/no upstream branch|has no upstream branch/i.test(text)) {
169
+ return new RepoError(ERROR_CODES.NO_UPSTREAM, '这个分支还没有上游,先发布一次。')
170
+ }
171
+ if (/nothing to commit|no changes added to commit/i.test(text)) {
172
+ return new RepoError(ERROR_CODES.NOTHING_STAGED, '没有勾选任何改动:先在文件列表里勾上要提交的文件。')
173
+ }
174
+ if (/CONFLICT|unmerged|needs merge|Committing is not possible/i.test(text)) {
175
+ return new RepoError(ERROR_CODES.CONFLICT, '仓库里还有未解决的冲突,先解决它们再提交。')
176
+ }
177
+ return new RepoError(fallbackCode, String(error?.message ?? error))
178
+ }
179
+
180
+ /**
181
+ * Build the repository facade for one plugin instance.
182
+ *
183
+ * @param {object} git the runner from `createGit`
184
+ * @param {object} config resolved plugin config
185
+ */
186
+ export function createRepo(git, config) {
187
+ /**
188
+ * Identity, branch and working-tree status of one repository.
189
+ *
190
+ * @param {string} repoRoot
191
+ * @param {AbortSignal} [signal]
192
+ */
193
+ async function readStatus(repoRoot, signal) {
194
+ // The four probes are independent, so they share one round of latency.
195
+ // `GIT_OPTIONAL_LOCKS=0` (set by the runner) is what makes the index reads
196
+ // safe to overlap.
197
+ const [statusResult, numstatResult, remotesResult, headResult] = await Promise.all([
198
+ git.run(['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { cwd: repoRoot, signal }),
199
+ git.run(['diff', '--numstat', '-z', '--find-renames', 'HEAD'], { cwd: repoRoot, signal }),
200
+ git.run(['remote'], { cwd: repoRoot, signal }),
201
+ git.run(['log', '-1', '--pretty=%H%x00%h%x00%s'], { cwd: repoRoot, signal }),
202
+ ])
203
+
204
+ if (statusResult.exitCode !== 0) {
205
+ throw classifyGitFailure({ ...statusResult, message: `git status failed: ${statusResult.stderr.trim()}` })
206
+ }
207
+
208
+ const { branch, files } = parseStatus(statusResult.stdout)
209
+ const remotes =
210
+ remotesResult.exitCode === 0
211
+ ? remotesResult.stdout
212
+ .split('\n')
213
+ .map((line) => line.trim())
214
+ .filter((line) => line !== '')
215
+ : []
216
+
217
+ let head = null
218
+ if (headResult.exitCode === 0) {
219
+ const [oid, short, ...rest] = headResult.stdout.replace(/\n$/, '').split('\0')
220
+ if (typeof oid === 'string' && oid !== '') head = { oid, short, subject: rest.join('\0') }
221
+ }
222
+
223
+ // `git diff HEAD` cannot run in a repository with no commits, and every
224
+ // untracked file is new anyway — the synthesized diffs carry the numbers.
225
+ const stats = numstatResult.exitCode === 0 ? parseNumstat(numstatResult.stdout) : new Map()
226
+
227
+ const untrackedPaths = files.filter((file) => file.untracked).map((file) => file.path)
228
+ const untrackedDetails = await mapLimit(untrackedPaths, READ_CONCURRENCY, (path) =>
229
+ readUntracked(repoRoot, path, config.maxFileBytes),
230
+ )
231
+ const untrackedByPath = new Map(untrackedPaths.map((path, index) => [path, untrackedDetails[index]]))
232
+
233
+ const enriched = files.map((file) => {
234
+ const fromGit = stats.get(file.path)
235
+ const detail = untrackedByPath.get(file.path)
236
+ const binary = fromGit?.binary === true || detail?.binary === true
237
+ return {
238
+ ...file,
239
+ added: fromGit !== undefined ? fromGit.added : detail?.lines ?? 0,
240
+ deleted: fromGit !== undefined ? fromGit.deleted : 0,
241
+ binary,
242
+ ...(file.untracked && detail?.oversized === true ? { oversized: true } : {}),
243
+ ...(file.untracked ? { synthesized: true } : {}),
244
+ }
245
+ })
246
+
247
+ const counts = enriched.reduce(
248
+ (sum, file) => ({
249
+ files: sum.files + 1,
250
+ staged: sum.staged + (file.staged ? 1 : 0),
251
+ partial: sum.partial + (file.partial ? 1 : 0),
252
+ conflicted: sum.conflicted + (file.conflicted ? 1 : 0),
253
+ additions: sum.additions + (file.added ?? 0),
254
+ deletions: sum.deletions + (file.deleted ?? 0),
255
+ }),
256
+ { files: 0, staged: 0, partial: 0, conflicted: 0, additions: 0, deletions: 0 },
257
+ )
258
+
259
+ const primary =
260
+ branch.ahead > 0
261
+ ? PRIMARY_ACTIONS.PUSH
262
+ : branch.upstream === null && remotes.length > 0
263
+ ? PRIMARY_ACTIONS.PUBLISH
264
+ : PRIMARY_ACTIONS.FETCH
265
+
266
+ return {
267
+ repoRoot,
268
+ repoName: basename(repoRoot),
269
+ branch,
270
+ head,
271
+ remotes,
272
+ files: enriched,
273
+ counts,
274
+ primary,
275
+ }
276
+ }
277
+
278
+ /** The status entries for one repository, plus its branch info. */
279
+ async function statusOf(repoRoot, signal) {
280
+ const result = await git.run(['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
281
+ cwd: repoRoot,
282
+ signal,
283
+ })
284
+ if (result.exitCode !== 0) {
285
+ throw classifyGitFailure({ ...result, message: `git status failed: ${result.stderr.trim()}` })
286
+ }
287
+ return parseStatus(result.stdout)
288
+ }
289
+
290
+ /**
291
+ * Stage or unstage exactly the given paths.
292
+ *
293
+ * @param {string} repoRoot
294
+ * @param {readonly string[]} paths status paths as git spelled them
295
+ * @param {boolean} staged
296
+ * @param {AbortSignal} [signal]
297
+ */
298
+ async function setStaged(repoRoot, paths, staged, signal) {
299
+ if (paths.length === 0) return
300
+ const args = staged
301
+ ? // `-A` so a deletion is staged too; without it `git add` refuses a path
302
+ // that no longer exists.
303
+ ['add', '-A', '--', ...paths.map(literal)]
304
+ : ['restore', '--staged', '--', ...paths.map(literal)]
305
+ const result = await git.run(args, { cwd: repoRoot, signal })
306
+ if (result.exitCode !== 0) {
307
+ throw classifyGitFailure({ ...result, message: `git ${args[0]} failed: ${result.stderr.trim()}` })
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Commit what is staged, with no pathspec.
313
+ *
314
+ * The message goes in over stdin (`-F -`) rather than as `-m`: a summary with
315
+ * a quote, a newline or a leading dash is then just text, not an argument, and
316
+ * a multi-line Description arrives as the commit body with no extra escaping.
317
+ *
318
+ * @param {string} repoRoot
319
+ * @param {{ summary: string, description?: string }} message
320
+ * @param {AbortSignal} [signal]
321
+ */
322
+ async function commit(repoRoot, message, signal) {
323
+ const summary = String(message?.summary ?? '').trim()
324
+ if (summary === '') throw new RepoError(ERROR_CODES.EMPTY_SUMMARY, 'Summary 不能为空。')
325
+ const description = String(message?.description ?? '').trim()
326
+ const text = description === '' ? `${summary}\n` : `${summary}\n\n${description}\n`
327
+
328
+ const result = await git.run(['commit', '-F', '-'], { cwd: repoRoot, stdin: text, signal })
329
+ if (result.exitCode !== 0) {
330
+ throw classifyGitFailure({ ...result, message: `git commit failed: ${result.stderr.trim()}` })
331
+ }
332
+
333
+ const head = await git.run(['log', '-1', '--pretty=%H%x00%h%x00%s'], { cwd: repoRoot, signal })
334
+ if (head.exitCode !== 0) return { summary, oid: null, short: null }
335
+ const [oid, short, ...subject] = head.stdout.replace(/\n$/, '').split('\0')
336
+ return { summary, oid: oid ?? null, short: short ?? null, subject: subject.join('\0') }
337
+ }
338
+
339
+ /** The remote to push to: `origin` when it exists, else the first one. */
340
+ function pickRemote(remotes) {
341
+ if (remotes.length === 0) {
342
+ throw new RepoError(ERROR_CODES.NO_REMOTE, '这个仓库没有配置远端(remote),没法 push 或 fetch。')
343
+ }
344
+ return remotes.includes('origin') ? 'origin' : remotes[0]
345
+ }
346
+
347
+ /**
348
+ * Push the current branch, publishing it when it has no upstream yet.
349
+ *
350
+ * @param {string} repoRoot
351
+ * @param {AbortSignal} [signal]
352
+ */
353
+ async function push(repoRoot, signal) {
354
+ const { branch } = await statusOf(repoRoot, signal)
355
+ const remotesResult = await git.run(['remote'], { cwd: repoRoot, signal })
356
+ const remotes =
357
+ remotesResult.exitCode === 0
358
+ ? remotesResult.stdout
359
+ .split('\n')
360
+ .map((line) => line.trim())
361
+ .filter((line) => line !== '')
362
+ : []
363
+ const remote = pickRemote(remotes)
364
+
365
+ const args =
366
+ branch.upstream === null ? ['push', '--set-upstream', remote, 'HEAD'] : ['push', remote]
367
+ const result = await git.run(args, { cwd: repoRoot, signal, maxBytes: 1024 * 1024 })
368
+ if (result.exitCode !== 0) {
369
+ throw classifyGitFailure({ ...result, message: `git push failed: ${result.stderr.trim()}` })
370
+ }
371
+ return { remote, published: branch.upstream === null, output: `${result.stdout}${result.stderr}`.trim() }
372
+ }
373
+
374
+ /**
375
+ * Fetch, pruning remote-tracking branches the remote has deleted — the same
376
+ * behaviour GitHub Desktop's Fetch has.
377
+ *
378
+ * @param {string} repoRoot
379
+ * @param {AbortSignal} [signal]
380
+ */
381
+ async function fetch(repoRoot, signal) {
382
+ const remotesResult = await git.run(['remote'], { cwd: repoRoot, signal })
383
+ const remotes =
384
+ remotesResult.exitCode === 0
385
+ ? remotesResult.stdout
386
+ .split('\n')
387
+ .map((line) => line.trim())
388
+ .filter((line) => line !== '')
389
+ : []
390
+ pickRemote(remotes)
391
+
392
+ const result = await git.run(['fetch', '--prune'], { cwd: repoRoot, signal, maxBytes: 1024 * 1024 })
393
+ if (result.exitCode !== 0) {
394
+ throw classifyGitFailure({ ...result, message: `git fetch failed: ${result.stderr.trim()}` })
395
+ }
396
+ return { output: `${result.stdout}${result.stderr}`.trim() }
397
+ }
398
+
399
+ /**
400
+ * One path's unified diff against HEAD, for the preview tab.
401
+ *
402
+ * @param {string} repoRoot
403
+ * @param {string} path
404
+ * @param {AbortSignal} [signal]
405
+ */
406
+ async function fileDiff(repoRoot, path, signal) {
407
+ const scoped = await git.run(
408
+ ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', literal(path)],
409
+ { cwd: repoRoot, signal },
410
+ )
411
+ if (scoped.exitCode !== 0) {
412
+ throw classifyGitFailure({ ...scoped, message: `git status failed: ${scoped.stderr.trim()}` })
413
+ }
414
+ const { files } = parseStatus(scoped.stdout)
415
+ const entry = files[0]
416
+
417
+ if (entry === undefined) {
418
+ return { path, kind: 'clean', text: '', truncated: false, added: 0, deleted: 0, binary: false }
419
+ }
420
+ if (entry.conflicted) {
421
+ throw new RepoError(ERROR_CODES.CONFLICT, '这个文件有未解决的冲突,diff 不能作为一次更改来看。')
422
+ }
423
+
424
+ if (entry.untracked) {
425
+ const detail = await readUntracked(repoRoot, path, config.maxFileBytes)
426
+ const text = synthesizeNewFileDiff({
427
+ path,
428
+ content: detail.content,
429
+ binary: detail.binary,
430
+ mode: detail.mode,
431
+ })
432
+ const limited = limitDiff(text, config.maxDiffBytes)
433
+ return {
434
+ path,
435
+ kind: FILE_STATUS.UNTRACKED,
436
+ text: limited.text,
437
+ truncated: limited.truncated,
438
+ added: detail.lines,
439
+ deleted: 0,
440
+ binary: detail.binary,
441
+ }
442
+ }
443
+
444
+ const args = ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...entryPaths(entry).map(literal)]
445
+ const result = await git.run(args, { cwd: repoRoot, signal, maxBytes: config.maxDiffBytes + 64 * 1024 })
446
+ if (result.exitCode !== 0) {
447
+ throw classifyGitFailure({ ...result, message: `git diff failed: ${result.stderr.trim()}` })
448
+ }
449
+ const limited = limitDiff(result.stdout, config.maxDiffBytes)
450
+ const totals = diffTotals(limited.text)
451
+ return {
452
+ path,
453
+ kind: entry.kind,
454
+ origPath: entry.origPath,
455
+ text: limited.text,
456
+ truncated: limited.truncated || result.truncated,
457
+ added: totals.added,
458
+ deleted: totals.deleted,
459
+ binary: /^Binary files /m.test(result.stdout),
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Discard changes: archive the diff, then restore the files.
465
+ *
466
+ * @param {object} input
467
+ * @param {string} input.repoRoot
468
+ * @param {string} [input.workspacePath]
469
+ * @param {string} [input.sessionId]
470
+ * @param {readonly string[] | null} input.paths `null` means every changed file
471
+ * @param {boolean} input.hardDelete skip the archive entirely
472
+ * @param {(entry: object, diffText: string) => Promise<object>} input.archive
473
+ * @param {AbortSignal} [input.signal]
474
+ */
475
+ async function discard(input) {
476
+ const { repoRoot, paths, hardDelete, archive, signal } = input
477
+ const status = await statusOf(repoRoot, signal)
478
+
479
+ const chosen =
480
+ paths === null
481
+ ? status.files
482
+ : status.files.filter((file) => paths.includes(file.path) || (file.origPath !== undefined && paths.includes(file.origPath)))
483
+
484
+ if (chosen.length === 0) {
485
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, '没有匹配到任何要舍弃的更改(工作区可能已经干净了)。')
486
+ }
487
+
488
+ const conflicted = chosen.filter((file) => file.conflicted)
489
+ if (conflicted.length > 0) {
490
+ throw new RepoError(
491
+ ERROR_CODES.CONFLICT,
492
+ `有 ${conflicted.length} 个文件处于冲突状态,舍弃更改不能处理它们,请先解决冲突。`,
493
+ )
494
+ }
495
+
496
+ const tracked = chosen.filter((file) => !file.untracked)
497
+ const untracked = chosen.filter((file) => file.untracked)
498
+
499
+ // ---- 1. Build the archive, before anything is destroyed. ----------------
500
+
501
+ const trackedPathspec = tracked.flatMap(entryPaths).map(literal)
502
+
503
+ // The blob and the per-file numbers come from two calls, not N: one diff for
504
+ // the archive body, one numstat for the table the trash list shows. Asking
505
+ // git once per file would be N process spawns on Windows for a table it can
506
+ // already compute in one.
507
+ let trackedDiff = ''
508
+ let trackedStats = new Map()
509
+ if (tracked.length > 0) {
510
+ const [diffResult, numstatResult] = await Promise.all([
511
+ git.run(['diff', '--no-color', '--find-renames', 'HEAD', '--', ...trackedPathspec], {
512
+ cwd: repoRoot,
513
+ signal,
514
+ maxBytes: config.maxDiffBytes + 64 * 1024,
515
+ }),
516
+ git.run(['diff', '--numstat', '-z', '--find-renames', 'HEAD', '--', ...trackedPathspec], {
517
+ cwd: repoRoot,
518
+ signal,
519
+ maxBytes: 1024 * 1024,
520
+ }),
521
+ ])
522
+ if (diffResult.exitCode !== 0) {
523
+ throw classifyGitFailure({ ...diffResult, message: `git diff failed: ${diffResult.stderr.trim()}` })
524
+ }
525
+ trackedDiff = diffResult.stdout
526
+ if (numstatResult.exitCode === 0) trackedStats = parseNumstat(numstatResult.stdout)
527
+ }
528
+
529
+ // Untracked files are invisible to `git diff HEAD` but discarding one
530
+ // deletes it, so their content is captured here or lost.
531
+ const untrackedPaths = untracked.map((file) => file.path)
532
+ const details = await mapLimit(untrackedPaths, READ_CONCURRENCY, (path) =>
533
+ readUntracked(repoRoot, path, config.maxFileBytes),
534
+ )
535
+ const detailByPath = new Map(untrackedPaths.map((path, index) => [path, details[index]]))
536
+
537
+ const parts = []
538
+ if (trackedDiff !== '') parts.push({ path: '', text: trackedDiff })
539
+ for (const file of untracked) {
540
+ const detail = detailByPath.get(file.path)
541
+ parts.push({
542
+ path: file.path,
543
+ text: synthesizeNewFileDiff({
544
+ path: file.path,
545
+ content: detail?.content ?? '',
546
+ binary: detail?.binary === true,
547
+ mode: detail?.mode ?? '100644',
548
+ }),
549
+ })
550
+ }
551
+
552
+ const assembled = joinDiffParts(parts)
553
+ const limited = limitDiff(assembled, config.maxDiffBytes)
554
+
555
+ // ---- 2. Record it (unless the user asked for it to be gone for good). --
556
+
557
+ let entry = null
558
+ if (!hardDelete) {
559
+ const head = await git.run(['log', '-1', '--pretty=%H%x00%h%x00%s'], { cwd: repoRoot, signal })
560
+ let base = null
561
+ if (head.exitCode === 0) {
562
+ const [oid, short, ...subject] = head.stdout.replace(/\n$/, '').split('\0')
563
+ if (typeof oid === 'string' && oid !== '') base = { oid, short, subject: subject.join('\0') }
564
+ }
565
+
566
+ const files = chosen.map((file) => {
567
+ if (file.untracked) {
568
+ const detail = detailByPath.get(file.path)
569
+ return {
570
+ path: file.path,
571
+ kind: file.kind,
572
+ added: detail?.lines ?? 0,
573
+ deleted: 0,
574
+ binary: detail?.binary === true,
575
+ synthesized: true,
576
+ ...(detail?.oversized === true ? { oversized: true } : {}),
577
+ }
578
+ }
579
+ const fromGit = trackedStats.get(file.path)
580
+ return {
581
+ path: file.path,
582
+ ...(file.origPath === undefined ? {} : { origPath: file.origPath }),
583
+ kind: file.kind,
584
+ added: fromGit?.added ?? 0,
585
+ deleted: fromGit?.deleted ?? 0,
586
+ binary: fromGit?.binary === true,
587
+ }
588
+ })
589
+
590
+ entry = await archive(
591
+ {
592
+ repoRoot,
593
+ repoName: basename(repoRoot),
594
+ workspacePath: input.workspacePath ?? null,
595
+ sessionId: input.sessionId ?? null,
596
+ scope: paths === null ? DISCARD_SCOPE.ALL : DISCARD_SCOPE.FILE,
597
+ branch: { head: status.branch.head, detached: status.branch.detached },
598
+ base,
599
+ files,
600
+ bytes: Buffer.byteLength(limited.text, 'utf8'),
601
+ truncated: limited.truncated,
602
+ droppedLines: limited.droppedLines,
603
+ },
604
+ limited.text,
605
+ )
606
+ }
607
+
608
+ // ---- 3. Restore. -------------------------------------------------------
609
+
610
+ if (tracked.length > 0) {
611
+ const pathspec = tracked.flatMap(entryPaths).map(literal)
612
+ const result = await git.run(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...pathspec], {
613
+ cwd: repoRoot,
614
+ signal,
615
+ })
616
+ if (result.exitCode !== 0) {
617
+ throw classifyGitFailure({ ...result, message: `git restore failed: ${result.stderr.trim()}` })
618
+ }
619
+ }
620
+
621
+ // Untracked paths are not in HEAD, so `git restore` cannot reach them: a
622
+ // mixed pathspec makes the whole command fail (verified), which is why they
623
+ // are handled here instead of in one call.
624
+ const deleted = []
625
+ for (const file of untracked) {
626
+ try {
627
+ await rm(absoluteIn(repoRoot, file.path), { force: true })
628
+ deleted.push(file.path)
629
+ } catch (error) {
630
+ throw new RepoError(ERROR_CODES.INTERNAL, `删除未跟踪文件失败:${file.path}(${String(error?.message ?? error)})`)
631
+ }
632
+ }
633
+ await pruneEmptyDirectories(repoRoot, untracked.map((file) => file.path))
634
+
635
+ return {
636
+ scope: paths === null ? DISCARD_SCOPE.ALL : DISCARD_SCOPE.FILE,
637
+ hardDelete: hardDelete === true,
638
+ restored: tracked.map((file) => file.path),
639
+ deleted,
640
+ trashed: entry,
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Remove directories the discard just emptied, walking up to — but never
646
+ * including — the repository root.
647
+ */
648
+ async function pruneEmptyDirectories(repoRoot, paths) {
649
+ const root = resolve(repoRoot)
650
+ const candidates = new Set()
651
+ for (const path of paths) {
652
+ let parent = resolve(root, path)
653
+ for (;;) {
654
+ parent = resolve(parent, '..')
655
+ if (parent === root || !parent.startsWith(root)) break
656
+ candidates.add(parent)
657
+ }
658
+ }
659
+ const deepestFirst = [...candidates].sort((left, right) => right.length - left.length)
660
+ for (const directory of deepestFirst) {
661
+ try {
662
+ const remaining = await readdir(directory)
663
+ if (remaining.length === 0) await rm(directory, { force: true })
664
+ } catch {
665
+ // A directory that is gone, or not empty, or not ours to remove: all fine.
666
+ }
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Candidate repositories for the picker: the workspace root first, then
672
+ * repositories anywhere beneath it, breadth-first and depth-capped.
673
+ *
674
+ * @param {string} workspacePath
675
+ * @param {AbortSignal} [signal]
676
+ */
677
+ async function scanRepos(workspacePath, signal) {
678
+ const root = resolve(workspacePath)
679
+ const depth = Number.isFinite(config.scanDepth) && config.scanDepth > 0 ? config.scanDepth : 3
680
+ const found = []
681
+ const queue = [{ path: root, depth: 0 }]
682
+ let visited = 0
683
+
684
+ // The root itself may or may not be a repository; either way it is offered,
685
+ // because "the workspace is a repository" is the common case and a wasted
686
+ // probe costs one process.
687
+ while (queue.length > 0 && visited < SCAN_VISIT_LIMIT) {
688
+ const current = queue.shift()
689
+ visited += 1
690
+ let isRepo = false
691
+ try {
692
+ isRepo =
693
+ (await stat(join(current.path, '.git')).then(
694
+ () => true,
695
+ () => false,
696
+ )) === true
697
+ } catch {
698
+ isRepo = false
699
+ }
700
+ if (isRepo) {
701
+ found.push({
702
+ path: current.path,
703
+ name: basename(current.path),
704
+ isWorkspaceRoot: current.depth === 0,
705
+ })
706
+ // Nested repositories inside a repository are almost always vendored
707
+ // checkouts; do not descend.
708
+ continue
709
+ }
710
+ if (current.depth >= depth) continue
711
+ let entries
712
+ try {
713
+ entries = await readdir(current.path, { withFileTypes: true })
714
+ } catch {
715
+ continue
716
+ }
717
+ for (const entry of entries) {
718
+ if (!entry.isDirectory()) continue
719
+ if (entry.name.startsWith('.')) continue
720
+ if (SKIP_DIRECTORIES.has(entry.name)) continue
721
+ queue.push({ path: join(current.path, entry.name), depth: current.depth + 1 })
722
+ }
723
+ }
724
+
725
+ return { scanned: visited, truncated: queue.length > 0, repos: found }
726
+ }
727
+
728
+ return { readStatus, statusOf, setStaged, commit, push, fetch, fileDiff, discard, scanRepos }
729
+ }