dsh-plugin-git-commit-push 0.0.0-stage → 1.0.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 +473 -0
- package/README.md +381 -2
- package/SKILL.md +79 -0
- package/capture-git-format.mjs +93 -0
- package/cordis.patch.yml +35 -0
- package/e2e-check.mjs +26 -0
- package/git-commit-push.config.json +17 -0
- package/icon.svg +1 -0
- package/index.js +908 -0
- package/lib/analyze.js +517 -0
- package/lib/config.js +300 -0
- package/lib/git.js +562 -0
- package/lib/profile-edit.mjs +118 -0
- package/lib/schema.js +111 -0
- package/lib/skill.js +95 -0
- package/lib/survey.js +225 -0
- package/lib/test-fixture.mjs +64 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
- package/self-test-git.mjs +442 -0
- package/self-test.mjs +913 -0
- package/setup.ps1 +226 -0
- package/setup.sh +190 -0
package/lib/git.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only place this plugin talks to git.
|
|
3
|
+
*
|
|
4
|
+
* Design rules that must not be relaxed:
|
|
5
|
+
*
|
|
6
|
+
* 1. FIXED ARGV UP FRONT. Every git invocation is `git -C <root> --no-pager
|
|
7
|
+
* -c color.ui=false <args...>` with `-c core.quotepath=false` so non-ASCII
|
|
8
|
+
* paths survive as UTF-8 instead of octal escapes. Arguments are passed as
|
|
9
|
+
* an argv array (never a shell string), so a path or branch name can never
|
|
10
|
+
* become a second command.
|
|
11
|
+
* 2. NO DESTRUCTIVE VERBS. `push --force`, `reset --hard`, `clean`,
|
|
12
|
+
* `checkout --`, `config` and `restore` are absent from this module by
|
|
13
|
+
* construction. If you need one, it does not belong in this plugin.
|
|
14
|
+
* 3. CAPTURED OUTPUT IS BOUNDED. Every read has a byte cap; a monorepo-sized
|
|
15
|
+
* diff cannot be pulled into the Host's heap or into the model's context.
|
|
16
|
+
* 4. THE USER'S GIT CONFIG IS NEVER WRITTEN. No `config` call exists here, and
|
|
17
|
+
* `GIT_OPTIONAL_LOCKS=0` keeps read commands from taking index locks.
|
|
18
|
+
*
|
|
19
|
+
* The pinned commit identity is supplied per command with `-c user.name=...`
|
|
20
|
+
* `-c user.email=...` ONLY when the config asks for it, so a machine without a
|
|
21
|
+
* global identity can still commit without this plugin mutating the user's
|
|
22
|
+
* global or repository configuration.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process'
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { readdir } from 'node:fs/promises'
|
|
27
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
/** A git invocation the plugin refused to run, or that failed. */
|
|
30
|
+
export class GitError extends Error {
|
|
31
|
+
constructor(message, code, command, stderr = '') {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'GitError'
|
|
34
|
+
this.code = code
|
|
35
|
+
this.command = command
|
|
36
|
+
this.stderr = stderr
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Upper bound on captured stdout for any single read command. */
|
|
41
|
+
const READ_CAP = 1 << 20 // 1 MiB
|
|
42
|
+
/** Default budget for a local, non-network command. */
|
|
43
|
+
const LOCAL_TIMEOUT_MS = 30_000
|
|
44
|
+
/** Push/pull talk to a remote; give them room before giving up. */
|
|
45
|
+
const NETWORK_TIMEOUT_MS = 90_000
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The platform's bit bucket.
|
|
49
|
+
*
|
|
50
|
+
* `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` are set to this to run git with the
|
|
51
|
+
* user's configuration OUT of the picture. Windows has no `/dev/null` — passing
|
|
52
|
+
* it there is not merely ignored, it makes git look for a file literally named
|
|
53
|
+
* `/dev/null` — so each platform gets its own spelling.
|
|
54
|
+
*/
|
|
55
|
+
export const DEV_NULL = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
56
|
+
|
|
57
|
+
/** Environment that pins a deterministic, user-config-free git. FOR TESTS. */
|
|
58
|
+
export function isolatedGitEnv() {
|
|
59
|
+
return {
|
|
60
|
+
...process.env,
|
|
61
|
+
LC_ALL: 'C',
|
|
62
|
+
LANG: 'C',
|
|
63
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
64
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
65
|
+
GIT_CONFIG_GLOBAL: DEV_NULL,
|
|
66
|
+
GIT_CONFIG_SYSTEM: DEV_NULL,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Environment for the plugin's own git calls.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately NOT `isolatedGitEnv()`: the user's global configuration is left
|
|
74
|
+
* in place here, because that is where their credential helper, `pull.rebase`
|
|
75
|
+
* and `core.autocrlf` live. Blanking it would silently change how their own
|
|
76
|
+
* repositories behave — the opposite of this plugin's contract.
|
|
77
|
+
*
|
|
78
|
+
* `LC_ALL=C` is load-bearing: `classifyPushFailure` matches English stderr, and
|
|
79
|
+
* a localized git would report a non-fast-forward rejection as an unknown
|
|
80
|
+
* reason, silently disabling the rebase retry. `GIT_TERMINAL_PROMPT=0` turns a
|
|
81
|
+
* missing credential into a clean failure instead of a hung process waiting on
|
|
82
|
+
* a stdin we never opened.
|
|
83
|
+
*/
|
|
84
|
+
export function pluginGitEnv() {
|
|
85
|
+
return {
|
|
86
|
+
...process.env,
|
|
87
|
+
LC_ALL: 'C',
|
|
88
|
+
LANG: 'C',
|
|
89
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
90
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run one git command and resolve with raw stdout.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} root repository root (or any dir inside it)
|
|
98
|
+
* @param {readonly string[]} args git arguments, already split
|
|
99
|
+
* @param {{ timeoutMs?: number, identity?: { name?: string, email?: string } }} [options]
|
|
100
|
+
* @returns {Promise<string>} stdout
|
|
101
|
+
*/
|
|
102
|
+
export function git(root, args, options = {}) {
|
|
103
|
+
const { timeoutMs = LOCAL_TIMEOUT_MS, identity } = options
|
|
104
|
+
|
|
105
|
+
const argv = ['-C', root, '--no-pager', '-c', 'color.ui=false', '-c', 'core.quotepath=false']
|
|
106
|
+
if (identity?.name !== undefined && identity.name !== '') argv.push('-c', `user.name=${identity.name}`)
|
|
107
|
+
if (identity?.email !== undefined && identity.email !== '') argv.push('-c', `user.email=${identity.email}`)
|
|
108
|
+
argv.push(...args)
|
|
109
|
+
|
|
110
|
+
return new Promise((resolvePromise, reject) => {
|
|
111
|
+
let child
|
|
112
|
+
try {
|
|
113
|
+
child = spawn('git', argv, {
|
|
114
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
env: pluginGitEnv(),
|
|
117
|
+
})
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reject(new GitError(`cannot run git: ${error.message}`, 'git-unavailable', args.join(' ')))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let stdout = ''
|
|
124
|
+
let stderr = ''
|
|
125
|
+
let settled = false
|
|
126
|
+
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
if (settled) return
|
|
129
|
+
settled = true
|
|
130
|
+
child.kill('SIGKILL')
|
|
131
|
+
reject(new GitError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'timeout', args.join(' '), stderr))
|
|
132
|
+
}, timeoutMs)
|
|
133
|
+
|
|
134
|
+
child.stdout.on('data', (chunk) => {
|
|
135
|
+
if (stdout.length < READ_CAP) stdout += chunk.toString('utf8')
|
|
136
|
+
})
|
|
137
|
+
child.stderr.on('data', (chunk) => {
|
|
138
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString('utf8')
|
|
139
|
+
})
|
|
140
|
+
child.on('error', (error) => {
|
|
141
|
+
if (settled) return
|
|
142
|
+
settled = true
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
reject(new GitError(`cannot run git: ${error.message}`, 'git-unavailable', args.join(' '), stderr))
|
|
145
|
+
})
|
|
146
|
+
child.on('close', (code) => {
|
|
147
|
+
if (settled) return
|
|
148
|
+
settled = true
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
if (code === 0) {
|
|
151
|
+
resolvePromise(stdout)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
reject(new GitError(
|
|
155
|
+
stderr.trim() !== '' ? stderr.trim() : `git ${args[0] ?? ''} exited with ${String(code)}`,
|
|
156
|
+
'git-failed',
|
|
157
|
+
args.join(' '),
|
|
158
|
+
stderr,
|
|
159
|
+
))
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether a usable `git` executable exists at all.
|
|
166
|
+
*
|
|
167
|
+
* `isRepo` deliberately swallows every error, which would otherwise report a
|
|
168
|
+
* machine with no git installed as "this project is not a Git repository" — a
|
|
169
|
+
* misleading answer that sends the user looking in the wrong place. This probe
|
|
170
|
+
* tells the two apart.
|
|
171
|
+
*/
|
|
172
|
+
export async function gitAvailable() {
|
|
173
|
+
// A directory that certainly exists: `git --version` does not read it, but
|
|
174
|
+
// spawn still needs a valid cwd.
|
|
175
|
+
const root = process.platform === 'win32' ? (process.env.SystemRoot ?? process.cwd()) : '/'
|
|
176
|
+
try {
|
|
177
|
+
await git(root, ['--version'], { timeoutMs: 8_000 })
|
|
178
|
+
return true
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return !(error instanceof GitError && error.code === 'git-unavailable')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether `dir` resolves to the top level of a git work tree. */
|
|
185
|
+
export async function isRepo(dir, signal) {
|
|
186
|
+
if (signal?.aborted) throw new GitError('aborted', 'aborted', 'rev-parse')
|
|
187
|
+
try {
|
|
188
|
+
const out = await git(dir, ['rev-parse', '--is-inside-work-tree'], { timeoutMs: 8_000 })
|
|
189
|
+
return out.trim() === 'true'
|
|
190
|
+
} catch {
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @returns {Promise<string>} the repository top level containing `dir` */
|
|
196
|
+
export function repoRoot(dir) {
|
|
197
|
+
return git(dir, ['rev-parse', '--show-toplevel'], { timeoutMs: 8_000 }).then(out => out.trim())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Immediate child directories that are themselves work-tree roots.
|
|
202
|
+
*
|
|
203
|
+
* A session whose working directory is a container (a home directory, or a
|
|
204
|
+
* folder of projects) must not be silently committed to. Instead we offer the
|
|
205
|
+
* candidates so the caller can name the repository it meant.
|
|
206
|
+
*
|
|
207
|
+
* @returns {Promise<string[]>} up to 8 candidate repository roots
|
|
208
|
+
*/
|
|
209
|
+
export async function childRepoRoots(dir) {
|
|
210
|
+
let entries
|
|
211
|
+
try {
|
|
212
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
213
|
+
} catch {
|
|
214
|
+
return []
|
|
215
|
+
}
|
|
216
|
+
const roots = []
|
|
217
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
218
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
219
|
+
if (roots.length >= 8) break
|
|
220
|
+
const candidate = join(dir, entry.name)
|
|
221
|
+
try {
|
|
222
|
+
const out = await git(candidate, ['rev-parse', '--show-toplevel'], { timeoutMs: 4_000 })
|
|
223
|
+
const root = out.trim()
|
|
224
|
+
if (root !== '' && !roots.includes(root)) roots.push(root)
|
|
225
|
+
} catch {
|
|
226
|
+
// Ordinary directory; keep looking.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return roots
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the repository this call should operate on.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} cwd the session working directory
|
|
236
|
+
* @param {string | undefined} requested an explicit override from the caller
|
|
237
|
+
* @returns {Promise<{ root: string } | { notRepo: true, candidates: string[] }>}
|
|
238
|
+
*/
|
|
239
|
+
export async function resolveRepo(cwd, requested) {
|
|
240
|
+
const start = requested !== undefined && requested !== ''
|
|
241
|
+
? (isAbsolute(requested) ? requested : resolve(cwd, requested))
|
|
242
|
+
: cwd
|
|
243
|
+
if (await isRepo(start)) return { root: await repoRoot(start) }
|
|
244
|
+
return { notRepo: true, candidates: await childRepoRoots(start) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The current branch name, or 'HEAD' when detached. */
|
|
248
|
+
export async function currentBranch(root) {
|
|
249
|
+
return (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether the current branch already has an upstream. */
|
|
253
|
+
export async function hasUpstream(root) {
|
|
254
|
+
try {
|
|
255
|
+
await git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { timeoutMs: 8_000 })
|
|
256
|
+
return true
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parse `status --porcelain=v1 -z`.
|
|
264
|
+
*
|
|
265
|
+
* Each record is `XY <path>`; a rename/copy record carries the ORIGIN path as
|
|
266
|
+
* the next NUL field, which must be consumed so it is not mistaken for an entry.
|
|
267
|
+
*
|
|
268
|
+
* @returns {{ xy: string, path: string, origPath?: string }[]}
|
|
269
|
+
*/
|
|
270
|
+
export function parseStatusZ(raw) {
|
|
271
|
+
const tokens = raw.split('\u0000')
|
|
272
|
+
const entries = []
|
|
273
|
+
let index = 0
|
|
274
|
+
while (index < tokens.length) {
|
|
275
|
+
const token = tokens[index]
|
|
276
|
+
index += 1
|
|
277
|
+
if (token === undefined || token === '') continue
|
|
278
|
+
const xy = token.slice(0, 2)
|
|
279
|
+
const path = token.slice(3)
|
|
280
|
+
const entry = { xy, path }
|
|
281
|
+
// `-z` reverses the rename pair to `to\0from\0`, and git emits the extra
|
|
282
|
+
// source field whenever a rename_source exists — which includes a
|
|
283
|
+
// WORKTREE-side rename (XY ` R`), not only a staged one. Testing just
|
|
284
|
+
// `xy[0]` would leave `old\0` to be parsed as a bogus entry.
|
|
285
|
+
if ((xy[0] === 'R' || xy[0] === 'C' || xy[1] === 'R' || xy[1] === 'C')
|
|
286
|
+
&& tokens[index] !== undefined && tokens[index] !== '') {
|
|
287
|
+
entry.origPath = tokens[index]
|
|
288
|
+
index += 1
|
|
289
|
+
}
|
|
290
|
+
entries.push(entry)
|
|
291
|
+
}
|
|
292
|
+
return entries
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Working-tree status including untracked files, as individual entries. */
|
|
296
|
+
export async function status(root) {
|
|
297
|
+
const raw = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
298
|
+
return parseStatusZ(raw)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Per-file line counts for the working tree, staged and unstaged together.
|
|
303
|
+
*
|
|
304
|
+
* `git diff --numstat -z` emits, per record:
|
|
305
|
+
*
|
|
306
|
+
* added TAB deleted TAB NUL preimage NUL postimage NUL
|
|
307
|
+
*
|
|
308
|
+
* i.e. the counts come FIRST and are followed by the NUL-framed path. For a
|
|
309
|
+
* rename the preimage (old path) precedes the postimage (new path). Every
|
|
310
|
+
* consumer keys this map by the CURRENT path — `survey` fills missing keys from
|
|
311
|
+
* the status entries and the card looks up `stats.get(entry.path)` — so the
|
|
312
|
+
* postimage is the key and the preimage is skipped. Getting this backwards
|
|
313
|
+
* silently loses the line counts of every renamed file.
|
|
314
|
+
*
|
|
315
|
+
* `HEAD` may not exist yet (a repository with no commits), in which case the
|
|
316
|
+
* numstat call is impossible and every entry is reported as new.
|
|
317
|
+
*
|
|
318
|
+
* @returns {Promise<Map<string, { added: number, deleted: number, binary: boolean }>>}
|
|
319
|
+
*/
|
|
320
|
+
export async function numstat(root, cached) {
|
|
321
|
+
const args = ['diff', '--numstat', '-z']
|
|
322
|
+
if (cached) args.push('--cached')
|
|
323
|
+
args.push('HEAD')
|
|
324
|
+
let raw
|
|
325
|
+
try {
|
|
326
|
+
raw = await git(root, args)
|
|
327
|
+
} catch {
|
|
328
|
+
return new Map()
|
|
329
|
+
}
|
|
330
|
+
const stats = new Map()
|
|
331
|
+
const tokens = raw.split('\u0000')
|
|
332
|
+
let index = 0
|
|
333
|
+
while (index < tokens.length) {
|
|
334
|
+
const header = tokens[index]
|
|
335
|
+
index += 1
|
|
336
|
+
if (header === undefined || header === '') continue
|
|
337
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(header)
|
|
338
|
+
if (match === null) continue
|
|
339
|
+
let path = match[3]
|
|
340
|
+
if (path === '') {
|
|
341
|
+
// Rename: the next field is the preimage, the one after it is the postimage.
|
|
342
|
+
index += 1
|
|
343
|
+
path = tokens[index] ?? ''
|
|
344
|
+
index += 1
|
|
345
|
+
}
|
|
346
|
+
const added = match[1] === '-' ? 0 : Number(match[1])
|
|
347
|
+
const deleted = match[2] === '-' ? 0 : Number(match[2])
|
|
348
|
+
stats.set(path, { added, deleted, binary: match[1] === '-' || match[2] === '-' })
|
|
349
|
+
}
|
|
350
|
+
return stats
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The raw `--unified=0` working-tree diff, or `''` when it cannot be read.
|
|
355
|
+
*
|
|
356
|
+
* Returns the DIFF TEXT, not a digest of it: the two consumers want different
|
|
357
|
+
* views of the same bytes (`declaredSymbols` looks at added lines,
|
|
358
|
+
* `removedDeclarationCount` at removed declaration lines), so sampling here
|
|
359
|
+
* would either lose information one of them needs or duplicate the parsing.
|
|
360
|
+
*
|
|
361
|
+
* `--no-textconv` matters: a repository with a textconv filter would otherwise
|
|
362
|
+
* run an external command during what is supposed to be a read-only survey.
|
|
363
|
+
*
|
|
364
|
+
* @returns {Promise<string>}
|
|
365
|
+
*/
|
|
366
|
+
export async function rawDiff(root) {
|
|
367
|
+
try {
|
|
368
|
+
return await git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', 'HEAD'], { timeoutMs: 20_000 })
|
|
369
|
+
} catch {
|
|
370
|
+
// No HEAD yet (a repository with no commits), or the read failed: an empty
|
|
371
|
+
// diff is the honest answer, and callers treat it as "no evidence".
|
|
372
|
+
return ''
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The first `limit` added lines of the working-tree diff, trimmed.
|
|
378
|
+
*
|
|
379
|
+
* Diff *headers* are filtered out so `+++ b/path` can never be mistaken for an
|
|
380
|
+
* added line of content.
|
|
381
|
+
*
|
|
382
|
+
* @returns {Promise<string[]>}
|
|
383
|
+
*/
|
|
384
|
+
export async function addedLineSample(root, limit = 60) {
|
|
385
|
+
const diff = await rawDiff(root)
|
|
386
|
+
const sample = []
|
|
387
|
+
for (const line of diff.split('\n')) {
|
|
388
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue
|
|
389
|
+
const text = line.slice(1).trim()
|
|
390
|
+
if (text === '' || text.startsWith('/*') || text.startsWith('*') || text.startsWith('//')) continue
|
|
391
|
+
sample.push(text)
|
|
392
|
+
if (sample.length >= limit) break
|
|
393
|
+
}
|
|
394
|
+
return sample
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Recent commit subjects, newest first, used only to match the repo's tone. */
|
|
398
|
+
export async function recentSubjects(root, count = 8) {
|
|
399
|
+
try {
|
|
400
|
+
const raw = await git(root, ['log', '-n', String(count), '--pretty=format:%s'], { timeoutMs: 8_000 })
|
|
401
|
+
return raw.split('\n').filter(line => line !== '')
|
|
402
|
+
} catch {
|
|
403
|
+
return []
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Resolve HEAD's short hash, or undefined in a repository with no commits. */
|
|
408
|
+
export async function headShort(root) {
|
|
409
|
+
try {
|
|
410
|
+
return (await git(root, ['rev-parse', '--short', 'HEAD'], { timeoutMs: 8_000 })).trim()
|
|
411
|
+
} catch {
|
|
412
|
+
return undefined
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Stage everything (the working tree) exactly as the skill does. */
|
|
417
|
+
export function stageAll(root, signal) {
|
|
418
|
+
if (signal?.aborted) throw new GitError('aborted', 'aborted', 'add')
|
|
419
|
+
return git(root, ['add', '-A'])
|
|
420
|
+
}
|
|
421
|
+
/** Commit what is staged. `-m` is always passed as its own argv element. */
|
|
422
|
+
export async function commit(root, message, identity) {
|
|
423
|
+
return git(root, ['commit', '-m', message], { identity, timeoutMs: 60_000 })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Validate a user- or caller-supplied tag name.
|
|
428
|
+
*
|
|
429
|
+
* Rejection is deliberate rather than sanitizing: silently rewriting the name
|
|
430
|
+
* the user asked for would create a tag they did not choose. `git check-ref-format`
|
|
431
|
+
* is the authority.
|
|
432
|
+
*
|
|
433
|
+
* @returns {Promise<string | undefined>} an error message, or undefined when valid
|
|
434
|
+
*/
|
|
435
|
+
export async function tagNameError(root, tag) {
|
|
436
|
+
if (typeof tag !== 'string' || tag.trim() === '') return 'tag name is empty'
|
|
437
|
+
if (tag.startsWith('-')) return 'tag name must not start with "-"'
|
|
438
|
+
if (/\s/.test(tag)) return 'tag name must not contain whitespace'
|
|
439
|
+
try {
|
|
440
|
+
await git(root, ['check-ref-format', `refs/tags/${tag}`], { timeoutMs: 8_000 })
|
|
441
|
+
return undefined
|
|
442
|
+
} catch {
|
|
443
|
+
return `"${tag}" is not a valid git tag name`
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Whether a tag of this name already exists locally. */
|
|
448
|
+
export async function tagExists(root, tag) {
|
|
449
|
+
try {
|
|
450
|
+
const out = await git(root, ['tag', '--list', tag], { timeoutMs: 8_000 })
|
|
451
|
+
return out.trim() !== ''
|
|
452
|
+
} catch {
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Create a lightweight tag. */
|
|
458
|
+
export function createTag(root, tag) {
|
|
459
|
+
return git(root, ['tag', tag], { timeoutMs: 15_000 })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Push the current branch.
|
|
464
|
+
*
|
|
465
|
+
* With no upstream this uses `-u origin <branch>`; otherwise it is a plain
|
|
466
|
+
* `push`. `tag` pushes exactly that one tag alongside the branch, as a second
|
|
467
|
+
* command, so a tag rejection cannot be confused with a branch rejection.
|
|
468
|
+
*
|
|
469
|
+
* @returns {Promise<{ ok: true } | { ok: false, code: string, stderr: string, reason: string }>}
|
|
470
|
+
*/
|
|
471
|
+
export async function push(root, branch, options = {}) {
|
|
472
|
+
const { tag, hasUpstream: upstream = false } = options
|
|
473
|
+
const args = ['push']
|
|
474
|
+
if (!upstream) args.push('-u', 'origin', branch)
|
|
475
|
+
if (typeof tag === 'string' && tag !== '') args.push(`refs/tags/${tag}`)
|
|
476
|
+
try {
|
|
477
|
+
await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
478
|
+
return { ok: true }
|
|
479
|
+
} catch (error) {
|
|
480
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : ''
|
|
481
|
+
return {
|
|
482
|
+
ok: false,
|
|
483
|
+
code: error.code ?? 'git-failed',
|
|
484
|
+
stderr,
|
|
485
|
+
reason: classifyPushFailure(stderr) ?? error.message,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Turn a push failure's stderr into one machine-branchable reason. */
|
|
491
|
+
export function classifyPushFailure(stderr) {
|
|
492
|
+
if (stderr === '') return undefined
|
|
493
|
+
if (/non-fast-forward|fetch first|\[rejected\]|failed to push some refs/i.test(stderr)) return 'non-fast-forward'
|
|
494
|
+
if (/Authentication failed|could not read Username|Permission denied|terminal prompts disabled|HTTP 40[13]|access denied/i.test(stderr)) return 'auth'
|
|
495
|
+
if (/Could not resolve host|unable to access|Connection (refused|timed out)|network/i.test(stderr)) return 'network'
|
|
496
|
+
if (/protected branch|pre-receive hook declined|GH00\d/i.test(stderr)) return 'remote-policy'
|
|
497
|
+
return 'rejected'
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Whether a rebase is currently in progress.
|
|
502
|
+
*
|
|
503
|
+
* Used to honour the promise that this plugin hands the repository back exactly
|
|
504
|
+
* as it found it: `git rebase --abort` must never be aimed at a rebase the user
|
|
505
|
+
* was already in the middle of.
|
|
506
|
+
*/
|
|
507
|
+
export async function rebaseInProgress(root) {
|
|
508
|
+
try {
|
|
509
|
+
const gitDir = (await git(root, ['rev-parse', '--git-path', 'rebase-merge'], { timeoutMs: 8_000 })).trim()
|
|
510
|
+
if (existsSync(gitDir)) return true
|
|
511
|
+
const applyDir = (await git(root, ['rev-parse', '--git-path', 'rebase-apply'], { timeoutMs: 8_000 })).trim()
|
|
512
|
+
return existsSync(applyDir)
|
|
513
|
+
} catch {
|
|
514
|
+
// Unknown: assume the worst and let the caller refuse to abort.
|
|
515
|
+
return true
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Reconcile a rejected push, then retry it once.
|
|
521
|
+
*
|
|
522
|
+
* Only `pull --rebase` is permitted here. A rebase that cannot finish is
|
|
523
|
+
* aborted so the repository is handed back in the state it was found in — and
|
|
524
|
+
* the abort is only ever issued when THIS function's pull is what started the
|
|
525
|
+
* rebase, so a rebase the user already had running is never discarded.
|
|
526
|
+
*
|
|
527
|
+
* @returns {Promise<{ ok: boolean, reason?: string, stderr?: string, rebased?: boolean }>}
|
|
528
|
+
*/
|
|
529
|
+
export async function pushAfterRebase(root, branch, options = {}) {
|
|
530
|
+
const first = await push(root, branch, options)
|
|
531
|
+
if (first.ok) return { ok: true, rebased: false }
|
|
532
|
+
|
|
533
|
+
if (first.reason !== 'non-fast-forward') {
|
|
534
|
+
return { ok: false, reason: first.reason, stderr: first.stderr }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// A pre-existing rebase means the pull cannot proceed cleanly anyway, and
|
|
538
|
+
// aborting it would destroy the user's own operation.
|
|
539
|
+
if (await rebaseInProgress(root)) {
|
|
540
|
+
return { ok: false, reason: 'rebase-in-progress', stderr: first.stderr }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
await git(root, ['pull', '--rebase', '--no-edit'], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : ''
|
|
547
|
+
const conflict = /CONFLICT|could not apply|Merge conflict/i.test(stderr) || /CONFLICT|Merge conflict/i.test(error.message)
|
|
548
|
+
// Hand the repository back clean — but only for the rebase we started.
|
|
549
|
+
if (await rebaseInProgress(root)) {
|
|
550
|
+
try {
|
|
551
|
+
await git(root, ['rebase', '--abort'], { timeoutMs: 20_000 })
|
|
552
|
+
} catch {
|
|
553
|
+
// The abort failed; the report below is the truth either way.
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { ok: false, reason: conflict ? 'rebase-conflict' : 'pull-failed', stderr }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const second = await push(root, branch, options)
|
|
560
|
+
if (second.ok) return { ok: true, rebased: true }
|
|
561
|
+
return { ok: false, reason: second.reason, stderr: second.stderr, rebased: true }
|
|
562
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile manifest edit shared by the Windows and macOS/Linux installers.
|
|
3
|
+
*
|
|
4
|
+
* Editing a user's profile `package.json` is the one step that must not go
|
|
5
|
+
* wrong, and doing it in a shell is where portability bugs live (`/dev/null` vs
|
|
6
|
+
* `NUL`, `sed -i` flag differences, JSON escaping). The installers therefore
|
|
7
|
+
* delegate this single step here, so both platforms run the SAME reviewed code
|
|
8
|
+
* and a fix lands on both at once.
|
|
9
|
+
*
|
|
10
|
+
* Guarantees, in order of importance:
|
|
11
|
+
*
|
|
12
|
+
* 1. It edits the parsed object IN PLACE and re-serializes it, so any field
|
|
13
|
+
* this script does not know about survives untouched (key order included).
|
|
14
|
+
* 2. It writes with NO BOM and a trailing newline. A BOM makes the file
|
|
15
|
+
* unparseable by `JSON.parse`, which would break the profile's boot.
|
|
16
|
+
* 3. It is idempotent: the plugin's dependency and bundle entries are removed
|
|
17
|
+
* then re-added exactly once, so running it twice cannot stack duplicates.
|
|
18
|
+
* 4. It verifies its own output by re-parsing the written bytes before
|
|
19
|
+
* reporting success.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node lib/profile-edit.mjs --profile-dir <dir> --package <name> --link <dir> [--remove]
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
/** Parse `--flag value` pairs without pulling in a dependency. */
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = {}
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const token = argv[index]
|
|
32
|
+
if (!token.startsWith('--')) continue
|
|
33
|
+
const key = token.slice(2)
|
|
34
|
+
const next = argv[index + 1]
|
|
35
|
+
if (next === undefined || next.startsWith('--')) {
|
|
36
|
+
args[key] = true
|
|
37
|
+
} else {
|
|
38
|
+
args[key] = next
|
|
39
|
+
index += 1
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return args
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = parseArgs(process.argv.slice(2))
|
|
46
|
+
const profileDir = typeof args['profile-dir'] === 'string' ? args['profile-dir'] : undefined
|
|
47
|
+
const packageName = typeof args.package === 'string' ? args.package : undefined
|
|
48
|
+
const linkTarget = typeof args.link === 'string' ? args.link : undefined
|
|
49
|
+
const remove = args.remove === true
|
|
50
|
+
|
|
51
|
+
if (profileDir === undefined || packageName === undefined || (!remove && linkTarget === undefined)) {
|
|
52
|
+
console.error('usage: node profile-edit.mjs --profile-dir <dir> --package <name> --link <dir> [--remove]')
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
57
|
+
|
|
58
|
+
let manifest
|
|
59
|
+
try {
|
|
60
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(`cannot read the profile manifest at ${manifestPath}: ${error.message}`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A BOM survives into the first key of the object under some parsers; drop it.
|
|
67
|
+
if (manifest !== null && typeof manifest === 'object' && Object.hasOwn(manifest, '\uFEFF')) {
|
|
68
|
+
delete manifest['\uFEFF']
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (manifest.dsh === null || typeof manifest.dsh !== 'object') {
|
|
72
|
+
console.error(`${manifestPath} has no dsh section; refusing to guess where the plugin belongs`)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
if (manifest.dsh.profile === null || typeof manifest.dsh.profile !== 'object') {
|
|
76
|
+
console.error(`${manifestPath} has no dsh.profile section`)
|
|
77
|
+
process.exit(1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 1. dependencies: drop any previous entry for this package, then re-add once.
|
|
81
|
+
const dependencies = { ...(manifest.dependencies ?? {}) }
|
|
82
|
+
delete dependencies[packageName]
|
|
83
|
+
if (!remove) dependencies[packageName] = `link:${linkTarget}`
|
|
84
|
+
manifest.dependencies = dependencies
|
|
85
|
+
|
|
86
|
+
// 2. bundles: same idempotence, and always a real array (never a bare string,
|
|
87
|
+
// which is what a single-element array can collapse to in some editors).
|
|
88
|
+
const currentBundles = manifest.dsh.profile.bundles
|
|
89
|
+
const bundles = (Array.isArray(currentBundles) ? currentBundles : currentBundles === undefined ? [] : [currentBundles])
|
|
90
|
+
.filter(entry => typeof entry === 'string' && entry !== packageName)
|
|
91
|
+
if (!remove) bundles.push(packageName)
|
|
92
|
+
manifest.dsh.profile.bundles = bundles
|
|
93
|
+
|
|
94
|
+
// 3. Write without a BOM. `JSON.stringify(x, null, 2)` is what makes the diff
|
|
95
|
+
// readable for the user, who will review this file.
|
|
96
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`
|
|
97
|
+
writeFileSync(manifestPath, text, { encoding: 'utf8' })
|
|
98
|
+
|
|
99
|
+
// 4. Prove the bytes we just wrote are valid JSON and carry the intended state.
|
|
100
|
+
const verify = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
101
|
+
if (verify.dependencies?.[packageName] !== undefined && remove) {
|
|
102
|
+
console.error('verification failed: the dependency is still present after removal')
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
if (!remove && verify.dependencies?.[packageName] !== `link:${linkTarget}`) {
|
|
106
|
+
console.error('verification failed: the dependency was not written as expected')
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
if (remove && verify.dsh.profile.bundles.includes(packageName)) {
|
|
110
|
+
console.error('verification failed: the bundle is still listed after removal')
|
|
111
|
+
process.exit(1)
|
|
112
|
+
}
|
|
113
|
+
if (!remove && !verify.dsh.profile.bundles.includes(packageName)) {
|
|
114
|
+
console.error('verification failed: the bundle was not listed')
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`package.json updated (bundles: ${verify.dsh.profile.bundles.join(', ')})`)
|