dsh-gitbar-lite 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +246 -2
- package/cordis.patch.yml +19 -0
- package/docs/CONTRACT.md +394 -0
- package/icon.svg +15 -0
- package/lib/client.js +2154 -0
- package/lib/host/diff.js +216 -0
- package/lib/host/git.js +211 -0
- package/lib/host/parse.js +267 -0
- package/lib/host/repo.js +729 -0
- package/lib/host/routes.js +448 -0
- package/lib/host/store.js +360 -0
- package/lib/host/workspace.js +84 -0
- package/lib/index.js +160 -0
- package/lib/shared.js +125 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -4
package/lib/host/repo.js
ADDED
|
@@ -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
|
+
}
|