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.
- 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/diff.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff text assembly.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is pure: strings and Buffers in, strings out. That matters
|
|
5
|
+
* because the archived copy in the trash is the *only* record of a discarded
|
|
6
|
+
* change — `git restore` has already thrown the change away by the time anyone
|
|
7
|
+
* reads it — so the assembly has to be verifiable without a repository, and
|
|
8
|
+
* `test/diff.test.mjs` pins it line by line.
|
|
9
|
+
*
|
|
10
|
+
* Two jobs:
|
|
11
|
+
*
|
|
12
|
+
* 1. `synthesizeNewFileDiff` fills the one hole `git diff HEAD` leaves.
|
|
13
|
+
* Untracked paths are invisible to a diff against HEAD (they are not in
|
|
14
|
+
* HEAD and not in the index), yet discarding one deletes it, so without a
|
|
15
|
+
* synthesized diff the trash entry would say "one file, no content".
|
|
16
|
+
*
|
|
17
|
+
* 2. `limitDiff` caps what gets archived. A discarding user is having a bad
|
|
18
|
+
* day already; a 200 MB generated file must not turn into a 200 MB JSON
|
|
19
|
+
* write that then fails. The cap cuts on a *line* boundary, never
|
|
20
|
+
* mid-character, and records how many lines it dropped.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-gitbar-lite/host/diff
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Bytes scanned when deciding whether a file is binary. */
|
|
26
|
+
const SNIFF_BYTES = 8000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a file should be treated as binary, using git's own heuristic: a NUL
|
|
30
|
+
* byte within the first 8000 bytes.
|
|
31
|
+
*
|
|
32
|
+
* @param {Buffer | Uint8Array} bytes
|
|
33
|
+
*/
|
|
34
|
+
export function looksBinary(bytes) {
|
|
35
|
+
const end = Math.min(bytes.length, SNIFF_BYTES)
|
|
36
|
+
for (let index = 0; index < end; index += 1) {
|
|
37
|
+
if (bytes[index] === 0) return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `100644`/`100755` git mode text for a `fs.Stats`-like object. */
|
|
43
|
+
export function modeTextFor(stats) {
|
|
44
|
+
const mode = typeof stats?.mode === 'number' ? stats.mode : 0
|
|
45
|
+
return (mode & 0o111) !== 0 ? '100755' : '100644'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A unified diff for a file git does not track, shaped like the one git would
|
|
50
|
+
* have produced.
|
|
51
|
+
*
|
|
52
|
+
* Trailing `\r` is stripped from each line, matching what git shows for a
|
|
53
|
+
* repository with `text=auto` (this machine's global config has
|
|
54
|
+
* `core.autocrlf=true`, so a CRLF worktree file would otherwise diff
|
|
55
|
+
* differently from every tracked file next to it).
|
|
56
|
+
*
|
|
57
|
+
* @param {object} input
|
|
58
|
+
* @param {string} input.path repository-relative path
|
|
59
|
+
* @param {string} input.content decoded file content (ignored when binary)
|
|
60
|
+
* @param {boolean} [input.binary]
|
|
61
|
+
* @param {string} [input.mode] git mode text, from `modeTextFor`
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function synthesizeNewFileDiff({ path, content, binary = false, mode = '100644' }) {
|
|
65
|
+
const header = [`diff --git a/${path} b/${path}`, `new file mode ${mode}`]
|
|
66
|
+
if (binary) return `${[...header, `Binary files /dev/null and b/${path} differ`].join('\n')}\n`
|
|
67
|
+
|
|
68
|
+
const body = ['--- /dev/null', `+++ b/${path}`]
|
|
69
|
+
if (content === '') return `${[...header, ...body].join('\n')}\n`
|
|
70
|
+
|
|
71
|
+
const endsWithNewline = content.endsWith('\n')
|
|
72
|
+
const lines = content.split('\n')
|
|
73
|
+
if (endsWithNewline) lines.pop()
|
|
74
|
+
const textLines = lines.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
|
75
|
+
|
|
76
|
+
const hunks = [`@@ -0,0 +1,${textLines.length} @@`, ...textLines.map((line) => `+${line}`)]
|
|
77
|
+
if (!endsWithNewline) hunks.push('\')
|
|
78
|
+
return `${[...header, ...body, ...hunks].join('\n')}\n`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cap a diff on a line boundary.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {number} maxBytes ceiling for the returned text, in UTF-8 bytes
|
|
86
|
+
* @returns {{ text: string, truncated: boolean, droppedLines: number }}
|
|
87
|
+
*/
|
|
88
|
+
export function limitDiff(text, maxBytes) {
|
|
89
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return { text, truncated: false, droppedLines: 0 }
|
|
90
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return { text, truncated: false, droppedLines: 0 }
|
|
91
|
+
|
|
92
|
+
const lines = text.split('\n')
|
|
93
|
+
const kept = []
|
|
94
|
+
let used = 0
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const cost = Buffer.byteLength(line, 'utf8') + 1
|
|
97
|
+
if (used + cost > maxBytes) break
|
|
98
|
+
kept.push(line)
|
|
99
|
+
used += cost
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dropped = lines.length - kept.length
|
|
103
|
+
const marker = `[dsh-gitbar-lite] diff truncated: ${dropped} more lines, ${Buffer.byteLength(text, 'utf8') - used} more bytes\n`
|
|
104
|
+
return { text: `${kept.join('\n')}\n${marker}`, truncated: true, droppedLines: dropped }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The path each block of a multi-file diff belongs to, for the reader's table
|
|
109
|
+
* of contents.
|
|
110
|
+
*
|
|
111
|
+
* `diff --git a/x b/y` is genuinely ambiguous when a path contains a space, so
|
|
112
|
+
* the more specific lines are preferred, in the order git emits them:
|
|
113
|
+
* `rename to` first, then the `+++`/`---` pair, then the header as a fallback.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} text a full (possibly multi-file) diff
|
|
116
|
+
* @returns {{ path: string, text: string }[]}
|
|
117
|
+
*/
|
|
118
|
+
export function splitDiffByFile(text) {
|
|
119
|
+
if (text === '') return []
|
|
120
|
+
|
|
121
|
+
const lines = text.split('\n')
|
|
122
|
+
const starts = []
|
|
123
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
124
|
+
if (lines[index].startsWith('diff --git ')) starts.push(index)
|
|
125
|
+
}
|
|
126
|
+
if (starts.length === 0) return []
|
|
127
|
+
|
|
128
|
+
const blocks = []
|
|
129
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
130
|
+
const from = starts[index]
|
|
131
|
+
const to = index + 1 < starts.length ? starts[index + 1] : lines.length
|
|
132
|
+
const block = lines.slice(from, to)
|
|
133
|
+
blocks.push({ path: pathOfBlock(block), text: block.join('\n') })
|
|
134
|
+
}
|
|
135
|
+
return blocks
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Recover the new path from one `diff --git` block. */
|
|
139
|
+
function pathOfBlock(block) {
|
|
140
|
+
for (const line of block) {
|
|
141
|
+
if (line.startsWith('rename to ')) return line.slice('rename to '.length).trim()
|
|
142
|
+
}
|
|
143
|
+
for (const line of block) {
|
|
144
|
+
if (line.startsWith('+++ ')) {
|
|
145
|
+
const value = line.slice(4)
|
|
146
|
+
if (value === '/dev/null') continue
|
|
147
|
+
const path = value.startsWith('b/') ? value.slice(2) : value
|
|
148
|
+
const tab = path.indexOf('\t')
|
|
149
|
+
return (tab < 0 ? path : path.slice(0, tab)).trim()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const line of block) {
|
|
153
|
+
if (line.startsWith('--- ')) {
|
|
154
|
+
const value = line.slice(4)
|
|
155
|
+
if (value === '/dev/null') continue
|
|
156
|
+
const path = value.startsWith('a/') ? value.slice(2) : value
|
|
157
|
+
const tab = path.indexOf('\t')
|
|
158
|
+
return (tab < 0 ? path : path.slice(0, tab)).trim()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const header = block.find((line) => line.startsWith('diff --git '))
|
|
162
|
+
if (header === undefined) return ''
|
|
163
|
+
// Last resort: `diff --git a/x b/y` with no quoting (we always run with
|
|
164
|
+
// quotepath=false, so a path with spaces simply makes this ambiguous).
|
|
165
|
+
const body = header.slice('diff --git '.length)
|
|
166
|
+
const halves = body.split(' ')
|
|
167
|
+
const right = halves.length >= 2 ? halves[halves.length - 1] : body
|
|
168
|
+
return right.startsWith('b/') ? right.slice(2) : right
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Join per-path diffs into the single blob a "discard all changes" entry
|
|
173
|
+
* archives, and report the per-file stats.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ path: string, text: string }[]} parts
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function joinDiffParts(parts) {
|
|
179
|
+
return parts.map((part) => (part.text.endsWith('\n') ? part.text : `${part.text}\n`)).join('')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Count the `+`/`-` lines one diff block contributes, mirroring what
|
|
184
|
+
* `--numstat` reports (so the UI's numbers match whether they came from git or
|
|
185
|
+
* from a synthesized untracked diff).
|
|
186
|
+
*
|
|
187
|
+
* @param {string} text
|
|
188
|
+
* @returns {{ added: number, deleted: number }}
|
|
189
|
+
*/
|
|
190
|
+
export function diffTotals(text) {
|
|
191
|
+
let added = 0
|
|
192
|
+
let deleted = 0
|
|
193
|
+
let inHunk = false
|
|
194
|
+
for (const line of text.split('\n')) {
|
|
195
|
+
if (line.startsWith('@@')) {
|
|
196
|
+
inHunk = true
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
if (!inHunk) continue
|
|
200
|
+
if (line.startsWith('diff --git ')) {
|
|
201
|
+
inHunk = false
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (line.startsWith('+')) added += 1
|
|
205
|
+
else if (line.startsWith('-')) deleted += 1
|
|
206
|
+
}
|
|
207
|
+
return { added, deleted }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Number of lines a brand-new file contributes (its `+` count). */
|
|
211
|
+
export function countContentLines(content) {
|
|
212
|
+
if (content === '') return 0
|
|
213
|
+
const endsWithNewline = content.endsWith('\n')
|
|
214
|
+
const count = content.split('\n').length - (endsWithNewline ? 1 : 0)
|
|
215
|
+
return count
|
|
216
|
+
}
|
package/lib/host/git.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git process layer.
|
|
3
|
+
*
|
|
4
|
+
* Everything this plugin knows about a repository comes through here, and every
|
|
5
|
+
* command goes through `ctx.subprocess` rather than `node:child_process`:
|
|
6
|
+
*
|
|
7
|
+
* - the harness resolves the executable for us (`resolveExecutable` checks an
|
|
8
|
+
* absolute path exists, walks the scrubbed PATH for a bare name, and
|
|
9
|
+
* rejects a relative path with separators);
|
|
10
|
+
* - the child is contained (a Windows Job object here), gets a bounded output
|
|
11
|
+
* buffer with spill, and is killed through `graceMs` rather than leaking;
|
|
12
|
+
* - the environment is scrubbed of `*TOKEN*`/`*SECRET*`-shaped variables
|
|
13
|
+
* before we even add ours.
|
|
14
|
+
*
|
|
15
|
+
* The environment we add on top is the one `@deepseek-ai/dsh-workspace-changes`
|
|
16
|
+
* uses for the same job: no terminal prompt (a credential prompt would hang a
|
|
17
|
+
* headless child forever), no optional index locks, no user config in the way,
|
|
18
|
+
* and a stable locale so the output we parse is the output we expect.
|
|
19
|
+
*
|
|
20
|
+
* Note the documented boundary: the harness file sandbox wraps the *shell*
|
|
21
|
+
* executors (pwsh/bash), not this service. A git child therefore runs with the
|
|
22
|
+
* OS user's full rights. That is why every path this plugin passes to git is
|
|
23
|
+
* derived from a repository root the host itself resolved, never from a raw
|
|
24
|
+
* client string.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-gitbar-lite/host/git
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ERROR_CODES, GIT_ENV } from '../shared.js'
|
|
30
|
+
|
|
31
|
+
/** Milliseconds a git child gets to exit after termination starts. */
|
|
32
|
+
const TERMINATE_GRACE_MS = 2000
|
|
33
|
+
|
|
34
|
+
/** Retained stderr tail for diagnostics. */
|
|
35
|
+
const STDERR_TAIL_BYTES = 16 * 1024
|
|
36
|
+
|
|
37
|
+
/** Default stdout ceiling for one command; diffs go through a larger cap. */
|
|
38
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
/** `git rev-parse` exits 128 when the directory is not inside a repository. */
|
|
41
|
+
export const NOT_A_REPOSITORY_EXIT = 128
|
|
42
|
+
|
|
43
|
+
/** The one stderr signature that means "not a repository" rather than "broken". */
|
|
44
|
+
const NOT_A_REPOSITORY = /not a git repository/i
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A git command that ran and failed, carrying the command and its output.
|
|
48
|
+
*
|
|
49
|
+
* Kept distinct from a spawn/timeout failure so callers can classify the
|
|
50
|
+
* message (a conflict, a rejected push, a missing credential) without
|
|
51
|
+
* re-parsing a generic error string.
|
|
52
|
+
*/
|
|
53
|
+
export class GitFailure extends Error {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} command the human-readable command, e.g. `git commit`
|
|
56
|
+
* @param {{ exitCode: number | null, stdout: string, stderr: string }} result
|
|
57
|
+
*/
|
|
58
|
+
constructor(command, result) {
|
|
59
|
+
const detail = String(result.stderr ?? '').trim() || String(result.stdout ?? '').trim()
|
|
60
|
+
super(detail === '' ? `${command} failed (exit ${result.exitCode})` : `${command} failed: ${detail}`)
|
|
61
|
+
this.name = 'GitFailure'
|
|
62
|
+
this.command = command
|
|
63
|
+
this.exitCode = result.exitCode
|
|
64
|
+
this.stdout = result.stdout
|
|
65
|
+
this.stderr = result.stderr
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the git runner for one plugin instance.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} ctx host context (must have `subprocess` injected)
|
|
73
|
+
* @param {object} config resolved plugin config
|
|
74
|
+
*/
|
|
75
|
+
export function createGit(ctx, config) {
|
|
76
|
+
/** Resolved once per plugin instance: the executable cannot change under us. */
|
|
77
|
+
let resolved
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the git executable, honouring an explicit `gitPath` override.
|
|
81
|
+
*
|
|
82
|
+
* A failure here is tagged with `E_NO_GIT` so the route layer reports "git is
|
|
83
|
+
* not installed (or the configured path is wrong)" rather than a generic
|
|
84
|
+
* internal error the user cannot act on.
|
|
85
|
+
*
|
|
86
|
+
* @returns {Promise<string>} absolute path to the executable
|
|
87
|
+
*/
|
|
88
|
+
async function executable(signal) {
|
|
89
|
+
if (resolved !== undefined) return resolved
|
|
90
|
+
const configured = typeof config.gitPath === 'string' ? config.gitPath.trim() : ''
|
|
91
|
+
const name = configured === '' ? 'git' : configured
|
|
92
|
+
try {
|
|
93
|
+
resolved = await ctx.subprocess.resolveExecutable(name, undefined, signal)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const wrapped = new Error(
|
|
96
|
+
configured === ''
|
|
97
|
+
? `找不到 git 可执行文件:${String(error?.message ?? error)}`
|
|
98
|
+
: `配置的 git 路径不可用(${configured}):${String(error?.message ?? error)}`,
|
|
99
|
+
)
|
|
100
|
+
wrapped.code = ERROR_CODES.NO_GIT
|
|
101
|
+
throw wrapped
|
|
102
|
+
}
|
|
103
|
+
return resolved
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run one git command.
|
|
108
|
+
*
|
|
109
|
+
* Never throws for a non-zero exit code — that is data, and callers use `ok()`
|
|
110
|
+
* when they want it to be an error. Throws only when the child could not be
|
|
111
|
+
* started, or when the timeout/abort fired.
|
|
112
|
+
*
|
|
113
|
+
* @param {readonly string[]} args arguments after the executable
|
|
114
|
+
* @param {object} [options]
|
|
115
|
+
* @param {string} [options.cwd] working directory (must be an absolute path)
|
|
116
|
+
* @param {AbortSignal} [options.signal] caller's cancellation
|
|
117
|
+
* @param {string} [options.stdin] text written to the child's stdin
|
|
118
|
+
* @param {number} [options.maxBytes] stdout ceiling for this command
|
|
119
|
+
* @returns {Promise<{ exitCode: number | null, stdout: string, stderr: string, truncated: boolean }>}
|
|
120
|
+
*/
|
|
121
|
+
async function run(args, options = {}) {
|
|
122
|
+
const executablePath = await executable(options.signal)
|
|
123
|
+
const timeoutMs = Number.isFinite(config.timeoutMs) ? config.timeoutMs : 30000
|
|
124
|
+
// A zero/negative timeout means "no deadline of our own": still pass the
|
|
125
|
+
// caller's signal so an aborted request does not leave a child running.
|
|
126
|
+
const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
|
|
127
|
+
const signal =
|
|
128
|
+
timeout === undefined
|
|
129
|
+
? options.signal
|
|
130
|
+
: options.signal === undefined
|
|
131
|
+
? timeout
|
|
132
|
+
: AbortSignal.any([options.signal, timeout])
|
|
133
|
+
|
|
134
|
+
const handle = ctx.subprocess.spawn({
|
|
135
|
+
argv: [executablePath, ...args],
|
|
136
|
+
cwd: options.cwd,
|
|
137
|
+
stdio: {
|
|
138
|
+
stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
|
|
139
|
+
stdout: { maxBytes: options.maxBytes ?? DEFAULT_MAX_OUTPUT_BYTES },
|
|
140
|
+
stderr: { maxBytes: STDERR_TAIL_BYTES },
|
|
141
|
+
},
|
|
142
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
143
|
+
...(signal === undefined ? {} : { signal }),
|
|
144
|
+
env: { ...GIT_ENV, ...(options.env ?? {}) },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const outcome = await handle.done
|
|
148
|
+
|
|
149
|
+
if (signal !== undefined && signal.aborted) {
|
|
150
|
+
const why = timeout !== undefined && timeout.aborted ? `timed out after ${timeoutMs}ms` : 'was aborted'
|
|
151
|
+
throw new GitFailure(`git ${args[0] ?? ''}`.trim(), { exitCode: null, stdout: '', stderr: why })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
|
|
155
|
+
const err = handle.collected.stderr?.readFrom(0) ?? { text: '', lossy: false }
|
|
156
|
+
return { exitCode: outcome.exitCode, stdout: out.text, stderr: err.text, truncated: out.lossy === true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run a git command and treat a non-zero exit as a failure.
|
|
161
|
+
*
|
|
162
|
+
* @param {readonly string[]} args
|
|
163
|
+
* @param {object} [options] same as `run`
|
|
164
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string, truncated: boolean }>}
|
|
165
|
+
*/
|
|
166
|
+
async function must(args, options = {}) {
|
|
167
|
+
const result = await run(args, options)
|
|
168
|
+
if (result.exitCode !== 0) throw new GitFailure(`git ${args.join(' ')}`, result)
|
|
169
|
+
return result
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The repository root containing `cwd`, or `undefined` when there is none.
|
|
174
|
+
*
|
|
175
|
+
* The "not a repository" case is identified the way the shipped
|
|
176
|
+
* `workspace-changes` plugin identifies it — exit 128 plus that exact stderr
|
|
177
|
+
* — so a genuinely broken repository still surfaces as an error instead of
|
|
178
|
+
* being reported as "no repository here".
|
|
179
|
+
*
|
|
180
|
+
* @param {string} cwd absolute directory to probe
|
|
181
|
+
* @param {AbortSignal} [signal]
|
|
182
|
+
* @returns {Promise<string | undefined>}
|
|
183
|
+
*/
|
|
184
|
+
async function repoRootOf(cwd, signal) {
|
|
185
|
+
const result = await run(['rev-parse', '--show-toplevel'], { cwd, signal })
|
|
186
|
+
if (result.exitCode === NOT_A_REPOSITORY_EXIT && NOT_A_REPOSITORY.test(result.stderr)) return undefined
|
|
187
|
+
if (result.exitCode !== 0) throw new GitFailure('git rev-parse --show-toplevel', result)
|
|
188
|
+
const first = result.stdout.split('\n')[0]
|
|
189
|
+
const root = typeof first === 'string' ? first.trim() : ''
|
|
190
|
+
return root === '' ? undefined : root
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `true` when `candidate` is the root of a real working tree.
|
|
195
|
+
*
|
|
196
|
+
* Used to validate a user-chosen directory before it is persisted, so a bad
|
|
197
|
+
* pick fails here with a message instead of poisoning the binding file.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} candidate absolute path
|
|
200
|
+
* @param {AbortSignal} [signal]
|
|
201
|
+
*/
|
|
202
|
+
async function isRepoRoot(candidate, signal) {
|
|
203
|
+
const result = await run(['rev-parse', '--show-toplevel', '--is-inside-work-tree'], { cwd: candidate, signal })
|
|
204
|
+
if (result.exitCode !== 0) return undefined
|
|
205
|
+
const [toplevel, inside] = result.stdout.split('\n').map((line) => line.trim())
|
|
206
|
+
if (inside !== 'true' || typeof toplevel !== 'string' || toplevel === '') return undefined
|
|
207
|
+
return toplevel
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { executable, run, must, repoRootOf, isRepoRoot }
|
|
211
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsers for the two git outputs this plugin reads.
|
|
3
|
+
*
|
|
4
|
+
* Both are pure: no `ctx`, no filesystem, no clock. That is deliberate — the
|
|
5
|
+
* status parser is the single place where a wrong assumption turns into a
|
|
6
|
+
* plausible-looking but wrong file list, and `test/status.test.mjs` pins it
|
|
7
|
+
* against bytes captured from real repositories (including a rename, a staged
|
|
8
|
+
* delete, an intent-to-add, a non-ASCII path, and a conflicted index).
|
|
9
|
+
*
|
|
10
|
+
* Format reference (all read from `git status --porcelain=v2 --branch -z` with
|
|
11
|
+
* `core.quotepath=false`):
|
|
12
|
+
*
|
|
13
|
+
* # branch.oid <oid>|(initial)
|
|
14
|
+
* # branch.head <name>|(detached)
|
|
15
|
+
* # branch.upstream <name> (only when an upstream is configured)
|
|
16
|
+
* # branch.ab +<ahead> -<behind> (only when an upstream is configured)
|
|
17
|
+
* 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
|
18
|
+
* 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\0<origPath>
|
|
19
|
+
* u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
|
20
|
+
* ? <path>
|
|
21
|
+
* ! <path>
|
|
22
|
+
*
|
|
23
|
+
* With `-z` every record is NUL-terminated and paths are raw bytes, never
|
|
24
|
+
* quoted — which is why the parsers split on NUL and then count *fields* from
|
|
25
|
+
* the left instead of splitting on spaces: a path may contain spaces, and with
|
|
26
|
+
* `-z` git gives us no quoting to lean on.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-gitbar-lite/host/parse
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { FILE_STATUS } from '../shared.js'
|
|
32
|
+
|
|
33
|
+
/** Split a `-z` stream into records, dropping the trailing empty field. */
|
|
34
|
+
function splitNul(raw) {
|
|
35
|
+
const text = typeof raw === 'string' ? raw : ''
|
|
36
|
+
if (text === '') return []
|
|
37
|
+
const fields = text.split('\0')
|
|
38
|
+
if (fields.length > 0 && fields[fields.length - 1] === '') fields.pop()
|
|
39
|
+
return fields
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Peel `count` space-separated tokens off the front of `text`.
|
|
44
|
+
*
|
|
45
|
+
* @returns {{ tokens: string[], rest: string }} `rest` is everything after the
|
|
46
|
+
* last token, verbatim (so it may be empty, and may itself contain spaces).
|
|
47
|
+
*/
|
|
48
|
+
function takeTokens(text, count) {
|
|
49
|
+
const tokens = []
|
|
50
|
+
let rest = text
|
|
51
|
+
for (let index = 0; index < count; index += 1) {
|
|
52
|
+
const space = rest.indexOf(' ')
|
|
53
|
+
if (space < 0) return { tokens, rest: '' }
|
|
54
|
+
tokens.push(rest.slice(0, space))
|
|
55
|
+
rest = rest.slice(space + 1)
|
|
56
|
+
}
|
|
57
|
+
return { tokens, rest }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bucket one `XY` pair into the status the UI shows.
|
|
62
|
+
*
|
|
63
|
+
* `U` anywhere, and the `AA`/`DD` both-added/both-deleted pairs, are conflicts:
|
|
64
|
+
* git reports those on `u` records, but the same letters can appear on an
|
|
65
|
+
* ordinary record once a merge has left the index dirty, so both are checked.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} type record tag: `1`, `2`, `u`, `?` or `!`
|
|
68
|
+
* @param {string} index the `X` (index) status character
|
|
69
|
+
* @param {string} worktree the `Y` (worktree) status character
|
|
70
|
+
*/
|
|
71
|
+
export function classify(type, index, worktree) {
|
|
72
|
+
if (type === '?') return FILE_STATUS.UNTRACKED
|
|
73
|
+
const conflicted =
|
|
74
|
+
type === 'u' ||
|
|
75
|
+
index === 'U' ||
|
|
76
|
+
worktree === 'U' ||
|
|
77
|
+
(index === 'A' && worktree === 'A') ||
|
|
78
|
+
(index === 'D' && worktree === 'D')
|
|
79
|
+
if (conflicted) return FILE_STATUS.CONFLICTED
|
|
80
|
+
if (index === 'R' || index === 'C') return FILE_STATUS.RENAMED
|
|
81
|
+
if (index === 'A') return FILE_STATUS.ADDED
|
|
82
|
+
if (index === 'D' || worktree === 'D') return FILE_STATUS.DELETED
|
|
83
|
+
return FILE_STATUS.MODIFIED
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build the normalized file entry both halves speak about. */
|
|
87
|
+
function makeEntry({ type, xy, path, origPath }) {
|
|
88
|
+
const index = xy.slice(0, 1)
|
|
89
|
+
const worktree = xy.slice(1, 2)
|
|
90
|
+
const kind = classify(type, index, worktree)
|
|
91
|
+
const untracked = type === '?'
|
|
92
|
+
const ignored = type === '!'
|
|
93
|
+
return {
|
|
94
|
+
/** Repository-relative path, git's own spelling (forward slashes). */
|
|
95
|
+
path,
|
|
96
|
+
/** Present only for a rename/copy: the path it came from. */
|
|
97
|
+
...(origPath === undefined ? {} : { origPath }),
|
|
98
|
+
index,
|
|
99
|
+
worktree,
|
|
100
|
+
kind,
|
|
101
|
+
untracked,
|
|
102
|
+
ignored,
|
|
103
|
+
conflicted: kind === FILE_STATUS.CONFLICTED,
|
|
104
|
+
/**
|
|
105
|
+
* Whether *all* of this file's change is in the index.
|
|
106
|
+
*
|
|
107
|
+
* This is what the client's tick box mirrors: a ticked file is fully
|
|
108
|
+
* staged, so `git commit` (plain, no pathspec) commits exactly the ticked
|
|
109
|
+
* set. An untracked file is by definition not staged.
|
|
110
|
+
*/
|
|
111
|
+
staged: !untracked && !ignored && index !== '.',
|
|
112
|
+
/** Both sides dirty — the tick box renders as indeterminate. */
|
|
113
|
+
partial: !untracked && !ignored && index !== '.' && worktree !== '.',
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse `git status --porcelain=v2 --branch -z`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} raw the command's stdout
|
|
121
|
+
* @returns {{
|
|
122
|
+
* branch: {
|
|
123
|
+
* oid: string | null, head: string | null, upstream: string | null,
|
|
124
|
+
* ahead: number, behind: number, detached: boolean, unborn: boolean,
|
|
125
|
+
* },
|
|
126
|
+
* files: ReturnType<typeof makeEntry>[],
|
|
127
|
+
* }}
|
|
128
|
+
*/
|
|
129
|
+
export function parseStatus(raw) {
|
|
130
|
+
const branch = {
|
|
131
|
+
oid: null,
|
|
132
|
+
head: null,
|
|
133
|
+
upstream: null,
|
|
134
|
+
ahead: 0,
|
|
135
|
+
behind: 0,
|
|
136
|
+
detached: false,
|
|
137
|
+
unborn: false,
|
|
138
|
+
}
|
|
139
|
+
const files = []
|
|
140
|
+
const fields = splitNul(raw)
|
|
141
|
+
|
|
142
|
+
for (let cursor = 0; cursor < fields.length; cursor += 1) {
|
|
143
|
+
const field = fields[cursor]
|
|
144
|
+
if (field === '') continue
|
|
145
|
+
const tag = field.slice(0, 1)
|
|
146
|
+
|
|
147
|
+
if (tag === '#') {
|
|
148
|
+
const body = field.slice(2)
|
|
149
|
+
const space = body.indexOf(' ')
|
|
150
|
+
const key = space < 0 ? body : body.slice(0, space)
|
|
151
|
+
const value = space < 0 ? '' : body.slice(space + 1)
|
|
152
|
+
if (key === 'branch.oid') {
|
|
153
|
+
branch.oid = value
|
|
154
|
+
branch.unborn = value === '(initial)'
|
|
155
|
+
} else if (key === 'branch.head') {
|
|
156
|
+
branch.head = value
|
|
157
|
+
branch.detached = value === '(detached)'
|
|
158
|
+
} else if (key === 'branch.upstream') {
|
|
159
|
+
branch.upstream = value
|
|
160
|
+
} else if (key === 'branch.ab') {
|
|
161
|
+
const match = /^\+(\d+)\s+-(\d+)$/.exec(value)
|
|
162
|
+
if (match !== null) {
|
|
163
|
+
branch.ahead = Number(match[1])
|
|
164
|
+
branch.behind = Number(match[2])
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (tag === '1') {
|
|
171
|
+
const { tokens, rest } = takeTokens(field.slice(2), 7)
|
|
172
|
+
if (tokens.length < 7) continue
|
|
173
|
+
files.push(makeEntry({ type: '1', xy: tokens[0], path: rest }))
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (tag === '2') {
|
|
178
|
+
const { tokens, rest } = takeTokens(field.slice(2), 8)
|
|
179
|
+
if (tokens.length < 8) continue
|
|
180
|
+
// With -z the original path is its own NUL-terminated field, so consume it.
|
|
181
|
+
const origPath = fields[cursor + 1]
|
|
182
|
+
cursor += 1
|
|
183
|
+
files.push(makeEntry({ type: '2', xy: tokens[0], path: rest, origPath }))
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (tag === 'u') {
|
|
188
|
+
// `u XY sub m1 m2 m3 mW h1 h2 h3 <path>` — nine fixed fields after the
|
|
189
|
+
// tag, then the path. Counting ten here consumes the first word of the
|
|
190
|
+
// path and yields an entry whose path is empty, which is a *silent* wrong
|
|
191
|
+
// answer: the conflict simply vanishes from the list.
|
|
192
|
+
const { tokens, rest } = takeTokens(field.slice(2), 9)
|
|
193
|
+
if (tokens.length < 9) continue
|
|
194
|
+
files.push(makeEntry({ type: 'u', xy: tokens[0], path: rest }))
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (tag === '?' || tag === '!') {
|
|
199
|
+
files.push(makeEntry({ type: tag, xy: tag === '?' ? '??' : '!!', path: field.slice(2) }))
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { branch, files }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse `git diff --numstat -z --find-renames`.
|
|
209
|
+
*
|
|
210
|
+
* Records are `<added>\t<deleted>\t<path>`, except a rename, where the path is
|
|
211
|
+
* empty and the next two NUL fields carry the old and new path. A binary file
|
|
212
|
+
* reports `-` for both counts.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} raw the command's stdout
|
|
215
|
+
* @returns {Map<string, { path: string, origPath?: string, added: number, deleted: number, binary: boolean }>}
|
|
216
|
+
* keyed by the new path
|
|
217
|
+
*/
|
|
218
|
+
export function parseNumstat(raw) {
|
|
219
|
+
const result = new Map()
|
|
220
|
+
const fields = splitNul(raw)
|
|
221
|
+
let cursor = 0
|
|
222
|
+
|
|
223
|
+
while (cursor < fields.length) {
|
|
224
|
+
const record = fields[cursor]
|
|
225
|
+
cursor += 1
|
|
226
|
+
if (record === '') continue
|
|
227
|
+
|
|
228
|
+
const firstTab = record.indexOf('\t')
|
|
229
|
+
if (firstTab < 0) continue
|
|
230
|
+
const secondTab = record.indexOf('\t', firstTab + 1)
|
|
231
|
+
if (secondTab < 0) continue
|
|
232
|
+
|
|
233
|
+
const addedText = record.slice(0, firstTab)
|
|
234
|
+
const deletedText = record.slice(firstTab + 1, secondTab)
|
|
235
|
+
let path = record.slice(secondTab + 1)
|
|
236
|
+
let origPath
|
|
237
|
+
|
|
238
|
+
if (path === '') {
|
|
239
|
+
origPath = fields[cursor]
|
|
240
|
+
path = fields[cursor + 1]
|
|
241
|
+
cursor += 2
|
|
242
|
+
if (path === undefined) break
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const binary = addedText === '-'
|
|
246
|
+
result.set(path, {
|
|
247
|
+
path,
|
|
248
|
+
...(origPath === undefined ? {} : { origPath }),
|
|
249
|
+
added: binary ? 0 : Number(addedText) || 0,
|
|
250
|
+
deleted: binary ? 0 : Number(deletedText) || 0,
|
|
251
|
+
binary,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse `git diff --name-only -z`, used to learn the path list a bare
|
|
260
|
+
* `git diff HEAD` would cover without materializing the diff itself.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} raw the command's stdout
|
|
263
|
+
* @returns {string[]}
|
|
264
|
+
*/
|
|
265
|
+
export function parseNameOnly(raw) {
|
|
266
|
+
return splitNul(raw)
|
|
267
|
+
}
|