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.
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Parser and integration tests that need a REAL git repository.
3
+ *
4
+ * Two things are tested here that nothing else can reach:
5
+ *
6
+ * 1. The exact `-z` byte framing of `status` and `--numstat`, including the
7
+ * rename field order. Getting that order backwards loses the line counts of
8
+ * every renamed file while still looking correct in code review.
9
+ * 2. That the module graph loads at all. A name imported from a file that does
10
+ * not export it is an ESM LINK-TIME error, so it only surfaces when
11
+ * something actually imports the module — which is why survey.js and
12
+ * index.js are imported explicitly below.
13
+ *
14
+ * Run it with node (any platform):
15
+ *
16
+ * node self-test-git.mjs
17
+ *
18
+ * On Windows with the bundled runtime:
19
+ * & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" self-test-git.mjs
20
+ */
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { numstat, parseStatusZ, rebaseInProgress, status, tagNameError } from './lib/git.js'
24
+ import { survey } from './lib/survey.js'
25
+ import { TOOL_DEFINITION, parseCommitCommand, run, toolDefinitionProblems } from './index.js'
26
+ import { createFixtureRepo } from './lib/test-fixture.mjs'
27
+
28
+ let passed = 0
29
+ const failures = []
30
+
31
+ async function check(label, fn) {
32
+ try {
33
+ await fn()
34
+ passed += 1
35
+ console.log(` ok ${label}`)
36
+ } catch (error) {
37
+ failures.push({ label, error })
38
+ console.log(` FAIL ${label}\n ${error.message}`)
39
+ }
40
+ }
41
+
42
+ console.log('\nmodule graph + registration-time schema validity')
43
+
44
+ await check('every plugin module loads (catches wrong-module imports)', () => {
45
+ for (const value of [survey, run, TOOL_DEFINITION, parseCommitCommand, toolDefinitionProblems]) {
46
+ assert.ok(value !== undefined && value !== null)
47
+ }
48
+ })
49
+
50
+ await check('the tool definition satisfies the registry contract', () => {
51
+ const problems = toolDefinitionProblems()
52
+ assert.deepEqual(problems, [], `registration would throw: ${problems.join(' | ')}`)
53
+ })
54
+
55
+ await check('parameters is a real JSON Schema object root', () => {
56
+ const { parameters } = TOOL_DEFINITION
57
+ assert.equal(parameters.type, 'object')
58
+ assert.equal(Array.isArray(parameters.required), true)
59
+ assert.equal(typeof parameters.properties, 'object')
60
+ assert.equal(parameters.additionalProperties, false)
61
+ for (const [name, node] of Object.entries(parameters.properties)) {
62
+ assert.ok(['string', 'number', 'boolean', 'array', 'object'].includes(node.type), `${name} has no usable type`)
63
+ assert.equal(node.required, undefined, `${name} uses the defineTool-only per-property required`)
64
+ }
65
+ })
66
+
67
+ await check('no schema node anywhere uses a boolean required', () => {
68
+ const offenders = []
69
+ const walk = (node, path) => {
70
+ if (typeof node !== 'object' || node === null) return
71
+ if ('required' in node && node.required !== undefined && !Array.isArray(node.required)) {
72
+ offenders.push(`${path}.required = ${JSON.stringify(node.required)}`)
73
+ }
74
+ for (const [key, child] of Object.entries(node.properties ?? {})) walk(child, `${path}.${key}`)
75
+ if (node.items !== undefined) walk(node.items, `${path}[]`)
76
+ }
77
+ walk(TOOL_DEFINITION.parameters, 'parameters')
78
+ walk(TOOL_DEFINITION.output.schema, 'output.schema')
79
+ assert.deepEqual(offenders, [])
80
+ })
81
+
82
+ console.log('\nreal git: status --porcelain -z')
83
+
84
+ const fixture = createFixtureRepo()
85
+ fixture.write('src/keep.js', 'export const keep = 1\n')
86
+ fixture.write('src/old-name.js', 'export const moved = 1\n')
87
+ fixture.write('src/gone.js', 'export const gone = 1\n')
88
+ fixture.write('src/中文.js', 'export const zh = 1\n')
89
+ fixture.commitAll('feat: base')
90
+
91
+ // Order matters: edit the file FIRST, then rename it. A rename produces a
92
+ // git-detected rename only when the preimage is a TRACKED file; writing after
93
+ // the rename would create a new untracked file instead and the test would be
94
+ // asserting against the wrong scenario.
95
+ fixture.write('src/keep.js', 'export const keep = 2\n')
96
+ fixture.rename('src/keep.js', 'src/kept.js')
97
+ fixture.git(['mv', 'src/old-name.js', 'src/new-name.js'])
98
+ fixture.remove('src/gone.js')
99
+ fixture.write('src/brand-new.js', 'export const fresh = 1\n')
100
+ fixture.write('src/中文.js', 'export const zh = 2\n')
101
+ fixture.write('src/two words.js', 'export const two = 1\n')
102
+
103
+ await check('the parser agrees with raw git byte-for-byte', async () => {
104
+ // Independent of the parser: ask git for the same information in the
105
+ // machine-readable NON-NUL form and require both to describe the same set of
106
+ // paths. This is what makes the test meaningful rather than a restatement of
107
+ // the implementation.
108
+ //
109
+ // `-c core.quotepath=false` is required here: with git's default, a non-ASCII
110
+ // path is octal-escaped in the non-NUL form (`"src/\344\270\255..."`) while the
111
+ // `-z` form always emits it raw, and the two lists would never match.
112
+ const rawNul = await fixture.git(['status', '--porcelain=v1', '-z', '--untracked-files=all'])
113
+ const plain = fixture.git([
114
+ '-c', 'core.quotepath=false', 'status', '--porcelain=v1', '--untracked-files=all',
115
+ ])
116
+ const plainPaths = plain.split('\n').filter(line => line !== '').map(line => {
117
+ let rest = line.slice(3)
118
+ // `R old -> new` in the non-NUL form; the CURRENT path is the target.
119
+ const arrow = rest.lastIndexOf(' -> ')
120
+ if (arrow !== -1) rest = rest.slice(arrow + 4)
121
+ // git quotes a path that contains a space, and escapes a literal quote or
122
+ // backslash inside it.
123
+ if (rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2) {
124
+ rest = rest.slice(1, -1).replace(/\\(["\\])/g, '$1')
125
+ }
126
+ return rest
127
+ }).sort()
128
+
129
+ const parsedPaths = parseStatusZ(rawNul).map(entry => entry.path).sort()
130
+ assert.deepEqual(parsedPaths, plainPaths, 'the -z parser disagrees with git\'s own non-NUL listing')
131
+
132
+ // Show the raw framing once, so a future mismatch is diagnosable from the log.
133
+ console.log(` raw -z tokens: ${JSON.stringify(rawNul.split('\u0000'))}`)
134
+ })
135
+
136
+ await check('status parses every entry, including renames and spaced paths', async () => {
137
+ const raw = await status(fixture.dir)
138
+ const byPath = new Map(raw.map(entry => [entry.path, entry]))
139
+
140
+ // A STAGED rename (`git mv`) is always detected as a rename, and its origin
141
+ // must be consumed rather than parsed as a second entry.
142
+ assert.equal(byPath.get('src/new-name.js')?.xy, 'R ', 'a staged rename must be seen as a rename')
143
+ assert.equal(byPath.get('src/new-name.js')?.origPath, 'src/old-name.js', 'a staged rename must carry its origin')
144
+ assert.equal(byPath.get('src/old-name.js'), undefined, 'the rename preimage must not also appear as an entry')
145
+
146
+ assert.equal(byPath.get('src/gone.js')?.xy, ' D')
147
+ assert.equal(byPath.get('src/brand-new.js')?.xy, '??')
148
+ assert.equal(byPath.get('src/two words.js')?.xy, '??', 'a path with a space must survive -z intact')
149
+ assert.equal(byPath.get('src/中文.js')?.xy, ' M', 'non-ascii paths must survive -z intact')
150
+
151
+ // The bug this guards: an unconsumed origin field becomes a phantom entry.
152
+ const phantom = raw.filter(entry => entry.path.endsWith('.js') && !entry.path.startsWith('src/'))
153
+ assert.deepEqual(phantom, [], `phantom entries parsed from rename origins: ${JSON.stringify(phantom)}`)
154
+ })
155
+
156
+ await check('a WORKTREE-side rename is detected when git detects it', async () => {
157
+ // git's rename detection here is a similarity heuristic, NOT a guarantee: a
158
+ // file that is both renamed and heavily edited is reported as ` D` + `??`
159
+ // instead. So this asserts a property that must hold EITHER WAY — an entry
160
+ // for the origin may not survive as a phantom — and additionally that when
161
+ // git DOES report a worktree rename, the parser consumes its origin field.
162
+ const raw = await status(fixture.dir)
163
+ const kept = raw.find(entry => entry.path === 'src/kept.js')
164
+ assert.ok(kept !== undefined, 'the renamed path must appear in the status listing')
165
+ if (kept.xy[1] === 'R' || kept.xy[0] === 'R') {
166
+ assert.equal(kept.origPath, 'src/keep.js', 'a worktree rename must carry its origin')
167
+ } else {
168
+ assert.equal(kept.xy, '??', `unexpected status ${kept.xy} for a heuristic rename`)
169
+ }
170
+ const phantom = raw.filter(entry => entry.path.endsWith('.js') && !entry.path.startsWith('src/'))
171
+ assert.deepEqual(phantom, [])
172
+ })
173
+
174
+ await check('parseStatusZ handles the raw bytes git actually emits', () => {
175
+ const staged = 'R src/new.js\u0000src/old.js\u0000 M src/zh.js\u0000'
176
+ assert.deepEqual(parseStatusZ(staged), [
177
+ { xy: 'R ', path: 'src/new.js', origPath: 'src/old.js' },
178
+ { xy: ' M', path: 'src/zh.js' },
179
+ ])
180
+ const worktreeRename = ' R src/kept.js\u0000src/keep.js\u0000'
181
+ assert.deepEqual(parseStatusZ(worktreeRename), [
182
+ { xy: ' R', path: 'src/kept.js', origPath: 'src/keep.js' },
183
+ ])
184
+ })
185
+
186
+ console.log('\nreal git: diff --numstat -z rename keying')
187
+
188
+ await check('a renamed file is keyed by its CURRENT path', async () => {
189
+ // Dedicated fixture: a rename with a small edit, STAGED so git must report it
190
+ // as a rename (`R` in the index). This is the scenario that exercises the
191
+ // `add TAB del TAB NUL preimage NUL postimage NUL` framing.
192
+ const renameFixture = createFixtureRepo('git-commit-rename-')
193
+ try {
194
+ renameFixture.write('src/alpha.js', 'export const alpha = 1\nexport const beta = 2\nexport const gamma = 3\n')
195
+ renameFixture.commitAll('feat: alpha')
196
+ renameFixture.rename('src/alpha.js', 'src/renamed-alpha.js')
197
+ renameFixture.write('src/renamed-alpha.js', 'export const alpha = 1\nexport const beta = 99\nexport const gamma = 3\n')
198
+ renameFixture.git(['add', '-A'])
199
+
200
+ const entries = await status(renameFixture.dir)
201
+ const renamed = entries.find(entry => entry.path === 'src/renamed-alpha.js')
202
+ assert.equal(renamed?.xy[0], 'R', `fixture must produce a staged rename, got ${JSON.stringify(entries)}`)
203
+
204
+ const stats = await numstat(renameFixture.dir, true)
205
+ // The whole point: the map is keyed by the CURRENT path, because that is the
206
+ // path the status entries and the card use.
207
+ assert.ok(
208
+ stats.get('src/renamed-alpha.js') !== undefined,
209
+ `expected key "src/renamed-alpha.js"; got ${[...stats.keys()].join(', ')}`,
210
+ )
211
+ assert.equal(stats.get('src/alpha.js'), undefined, 'the map must not be keyed by the rename preimage')
212
+
213
+ const counts = stats.get('src/renamed-alpha.js')
214
+ assert.equal(counts.added >= 1 && counts.deleted >= 1, true, `expected +n/-n, got ${JSON.stringify(counts)}`)
215
+ } finally {
216
+ renameFixture.cleanup()
217
+ }
218
+ })
219
+
220
+ await check('a modified tracked file is counted, and untracked files appear in the status', async () => {
221
+ const stats = await numstat(fixture.dir, false)
222
+ const modified = stats.get('src/中文.js')
223
+ assert.ok(modified !== undefined)
224
+ assert.equal(modified.added >= 1 && modified.deleted >= 1, true, `expected +n/-n, got ${JSON.stringify(modified)}`)
225
+
226
+ const raw = await status(fixture.dir)
227
+ assert.ok(raw.some(entry => entry.path === 'src/brand-new.js' && entry.xy === '??'))
228
+ })
229
+
230
+ console.log('\nreal git: version-bump detection (package.json style)')
231
+
232
+ const versionFixture = createFixtureRepo('git-commit-version-')
233
+ versionFixture.write('package.json', JSON.stringify({ name: 'demo', version: '1.0.0' }, null, 2) + '\n')
234
+ versionFixture.commitAll('build: init package.json')
235
+ versionFixture.write('package.json', JSON.stringify({ name: 'demo', version: '2.0.5' }, null, 2) + '\n')
236
+
237
+ await check('an unstaged "version": JSON bump is detected', async () => {
238
+ const result = await survey({ cwd: versionFixture.dir })
239
+ assert.equal(result.ok, true, 'survey should succeed')
240
+ assert.ok(result.version !== undefined, 'a package.json version bump must be detected')
241
+ assert.equal(result.version.to, '2.0.5')
242
+ assert.equal(result.version.from, '1.0.0')
243
+ })
244
+
245
+ await check('a bump that is already staged is detected', async () => {
246
+ versionFixture.git(['add', 'package.json'])
247
+ const result = await survey({ cwd: versionFixture.dir })
248
+ assert.equal(result.ok, true)
249
+ assert.equal(result.version?.to, '2.0.5')
250
+ })
251
+
252
+ console.log('\nreal git: survey over a mixed changeset')
253
+
254
+ await check('survey reports entries, stats, draft and tag evidence', async () => {
255
+ const result = await survey({ cwd: fixture.dir })
256
+ assert.equal(result.ok, true, 'survey should succeed')
257
+ assert.equal(result.branch, 'main')
258
+ assert.ok(result.entries.length > 0)
259
+ assert.equal(typeof result.draft.message, 'string')
260
+ assert.match(result.draft.message.split('\n')[0], /^[a-z]+(\([^)]+\))?: /, 'draft must be Conventional Commits')
261
+ assert.equal(Array.isArray(result.recentSubjects), true)
262
+ assert.equal(result.recentSubjects[0], 'feat: base')
263
+ assert.equal(result.hasUpstream, false, 'a fixture has no upstream')
264
+ })
265
+
266
+ await check('a clean repository reports "nothing to commit"', async () => {
267
+ const clean = createFixtureRepo('git-commit-clean-')
268
+ try {
269
+ const result = await survey({ cwd: clean.dir })
270
+ assert.equal(result.ok, false)
271
+ assert.equal(result.reason, 'clean')
272
+ } finally {
273
+ clean.cleanup()
274
+ }
275
+ })
276
+
277
+ await check('a non-repository directory is reported, not guessed', async () => {
278
+ const result = await survey({ cwd: process.env.SystemRoot ?? '/nonexistent-dir-xyz' })
279
+ assert.equal(result.ok, false)
280
+ assert.ok(result.notRepo === true || result.reason === 'git-unavailable')
281
+ })
282
+
283
+ console.log('\nreal git: tag names and rebase probing')
284
+
285
+ await check('tagNameError accepts a good name and rejects unsafe ones', async () => {
286
+ assert.equal(await tagNameError(fixture.dir, 'v1.2.3'), undefined)
287
+ assert.equal(await tagNameError(fixture.dir, 'release/1.2.3'), undefined)
288
+ assert.match(String(await tagNameError(fixture.dir, '-badtag')), /start/)
289
+ assert.match(String(await tagNameError(fixture.dir, 'has space')), /whitespace/)
290
+ assert.match(String(await tagNameError(fixture.dir, '')), /empty/)
291
+ assert.match(String(await tagNameError(fixture.dir, 'bad..name')), /not a valid/)
292
+ })
293
+
294
+ await check('rebaseInProgress is false in a quiet repository', async () => {
295
+ assert.equal(await rebaseInProgress(fixture.dir), false)
296
+ })
297
+
298
+ fixture.cleanup()
299
+ versionFixture.cleanup()
300
+
301
+ console.log('\nrunner boundaries')
302
+
303
+ await check('run(prepare) returns a schema-shaped value on a fixture repo', async () => {
304
+ const repo = createFixtureRepo('git-commit-run-')
305
+ try {
306
+ repo.write('src/a.js', 'export const a = 1\n')
307
+ const result = await run({ get: () => undefined }, { action: 'prepare', cwd: repo.dir }, undefined)
308
+ assert.equal(result.ok, true)
309
+ assert.equal(result.action, 'prepare')
310
+ assert.equal(typeof result.card, 'string')
311
+ assert.equal(typeof result.pushed, 'boolean')
312
+ assert.ok(Array.isArray(result.changes))
313
+ assert.deepEqual(Object.keys(result.changes[0]).sort(), ['path', 'status'])
314
+ } finally {
315
+ repo.cleanup()
316
+ }
317
+ })
318
+
319
+ await check('run(apply) commits, and --no-push keeps it local', async () => {
320
+ const repo = createFixtureRepo('git-commit-apply-')
321
+ try {
322
+ repo.write('src/a.js', 'export const a = 1\n')
323
+ const result = await run(
324
+ { get: () => undefined },
325
+ { action: 'apply', message: 'feat(a): 新增 a 模块', push: false, cwd: repo.dir },
326
+ undefined,
327
+ )
328
+ assert.equal(result.ok, true, `apply failed: ${result.card}`)
329
+ assert.match(String(result.hash), /^[0-9a-f]{7,}$/)
330
+ assert.equal(result.pushed, false)
331
+ assert.equal(repo.git(['log', '-1', '--pretty=format:%s']).trim(), 'feat(a): 新增 a 模块')
332
+ } finally {
333
+ repo.cleanup()
334
+ }
335
+ })
336
+
337
+ await check('run(auto) uses the rule-generated message', async () => {
338
+ const repo = createFixtureRepo('git-commit-auto-')
339
+ try {
340
+ repo.write('docs/guide.md', '# guide\n')
341
+ const result = await run({ get: () => undefined }, { action: 'auto', push: false, cwd: repo.dir }, undefined)
342
+ assert.equal(result.ok, true, `auto failed: ${result.card}`)
343
+ const subject = repo.git(['log', '-1', '--pretty=format:%s']).trim()
344
+ assert.match(subject, /^docs/, `expected a docs-typed subject, got: ${subject}`)
345
+ } finally {
346
+ repo.cleanup()
347
+ }
348
+ })
349
+
350
+ await check('the commit card reports the real line counts', async () => {
351
+ // The staged numstat used to race `git add`, so a commit that changed lines
352
+ // reported `+0 / -0` — a wrong number exactly where a person looks for
353
+ // confirmation that their work landed.
354
+ const repo = createFixtureRepo('git-commit-totals-')
355
+ try {
356
+ repo.write('src/a.js', 'export const a = 1\n')
357
+ repo.git(['add', '-A'])
358
+ repo.git(['-c', 'user.email=t@example.com', '-c', 'user.name=T', 'commit', '-qm', 'init'])
359
+ repo.write('src/a.js', 'export const a = 1\nexport const b = 2\nexport const c = 3\n')
360
+
361
+ const result = await run({ get: () => undefined }, { action: 'apply', message: 'feat(a): 加两个常量', push: false, cwd: repo.dir }, undefined)
362
+ assert.equal(result.ok, true, `apply failed: ${result.card}`)
363
+ assert.match(String(result.card), /提交 1 个文件 · \+2 \/ -0/, String(result.card))
364
+ assert.match(String(result.card), /^✅ \*\*Git 提交成功(未推送)\*\*/)
365
+ } finally {
366
+ repo.cleanup()
367
+ }
368
+ })
369
+
370
+ await check('a multi-file commit lands as ONE commit with one note per file', async () => {
371
+ const repo = createFixtureRepo('git-commit-notes-')
372
+ try {
373
+ repo.write('src/api/retry.js', 'export function decideRetry(n) { return n < 3 }\n')
374
+ repo.write('docs/guide.md', '# guide\n')
375
+ repo.git(['add', '-A'])
376
+ repo.git(['-c', 'user.email=t@example.com', '-c', 'user.name=T', 'commit', '-qm', 'init'])
377
+ // One modified file that declares something, one new documentation file.
378
+ repo.write('src/api/retry.js', 'export function decideRetry(n) { return n < 5 }\n')
379
+ repo.write('docs/retry.md', '# retry\n')
380
+ const before = Number(repo.git(['rev-list', '--count', 'HEAD']).trim())
381
+
382
+ const result = await run({ get: () => undefined }, { action: 'auto', push: false, cwd: repo.dir }, undefined)
383
+ assert.equal(result.ok, true, `auto failed: ${result.card}`)
384
+
385
+ const after = Number(repo.git(['rev-list', '--count', 'HEAD']).trim())
386
+ assert.equal(after, before + 1, 'the notes must not become separate commits')
387
+ const body = repo.git(['log', '-1', '--pretty=format:%B'])
388
+ const lines = body.split('\n')
389
+ assert.match(lines[0], /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)/, body)
390
+ assert.equal(lines[1], '', 'subject and body are separated by a blank line')
391
+ assert.match(body, /- [^\n]*decideRetry[^\n]* · src\/api\/retry\.js/, body)
392
+ assert.match(body, /- [^\n]* · docs\/retry\.md/, body)
393
+ assert.equal(lines.filter(line => line.startsWith('- ')).length, 2, body)
394
+ } finally {
395
+ repo.cleanup()
396
+ }
397
+ })
398
+
399
+ await check('a caller subject keeps its own body when it wrote one', async () => {
400
+ const repo = createFixtureRepo('git-commit-verbatim-')
401
+ try {
402
+ repo.write('src/a.js', 'export const a = 1\n')
403
+ repo.write('src/b.js', 'export const b = 1\n')
404
+ const result = await run(
405
+ { get: () => undefined },
406
+ { action: 'apply', message: 'feat(a): 两个文件一起改\n\nBREAKING CHANGE: 接口改了', push: false, cwd: repo.dir },
407
+ undefined,
408
+ )
409
+ assert.equal(result.ok, true, `apply failed: ${result.card}`)
410
+ const body = repo.git(['log', '-1', '--pretty=format:%B'])
411
+ assert.match(body, /^feat\(a\): 两个文件一起改\n\nBREAKING CHANGE: 接口改了\n?$/, body)
412
+ assert.equal(body.includes(' · src/'), false, 'an explicit body must not be decorated with generated notes')
413
+ } finally {
414
+ repo.cleanup()
415
+ }
416
+ })
417
+
418
+ await check('a caller subject with no body gets the per-file notes appended', async () => {
419
+ const repo = createFixtureRepo('git-commit-subject-only-')
420
+ try {
421
+ repo.write('src/a.js', 'export const a = 1\n')
422
+ repo.write('src/b.js', 'export const b = 1\n')
423
+ const result = await run(
424
+ { get: () => undefined },
425
+ { action: 'apply', message: 'feat(a): 两个文件', push: false, cwd: repo.dir },
426
+ undefined,
427
+ )
428
+ assert.equal(result.ok, true, `apply failed: ${result.card}`)
429
+ const body = repo.git(['log', '-1', '--pretty=format:%B'])
430
+ assert.match(body, /^feat\(a\): 两个文件\n\n- /, body)
431
+ assert.match(body, /· src\/a\.js/)
432
+ assert.match(body, /· src\/b\.js/)
433
+ } finally {
434
+ repo.cleanup()
435
+ }
436
+ })
437
+
438
+ console.log(`\n${passed} passed, ${failures.length} failed\n`)
439
+ if (failures.length > 0) {
440
+ for (const failure of failures) console.log(`FAILED: ${failure.label}\n${failure.error.stack}\n`)
441
+ process.exitCode = 1
442
+ }