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/lib/analyze.js ADDED
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Change classification and the deterministic Conventional-Commits generator.
3
+ *
4
+ * This module is the "not enough context" fallback: it runs when the caller
5
+ * asks for `auto` mode and is the ONLY path that produces a commit subject
6
+ * without a human or a model writing one. It is deliberately rule-based and
7
+ * never invents a fact it did not observe — when nothing specific can be
8
+ * named it produces an honest, generic subject rather than a plausible lie.
9
+ */
10
+
11
+ /** Which Conventional Commits type a path most likely belongs to. */
12
+ const TYPE_BY_EXTENSION = new Map(Object.entries({
13
+ md: 'docs', mdx: 'docs', rst: 'docs', adoc: 'docs', txt: 'docs',
14
+ css: 'style', scss: 'style', less: 'style', styl: 'style', sass: 'style',
15
+ png: 'chore', jpg: 'chore', jpeg: 'chore', gif: 'chore', svg: 'chore',
16
+ ico: 'chore', webp: 'chore', woff: 'chore', woff2: 'chore', ttf: 'chore',
17
+ }))
18
+
19
+ /** Directories whose name alone implies a type. */
20
+ const TYPE_BY_SEGMENT = [
21
+ [/^(tests?|__tests__|spec|e2e)$/i, 'test'],
22
+ [/^(docs?|documentation)$/i, 'docs'],
23
+ [/^(\.github|\.gitlab|\.circleci)$/i, 'ci'],
24
+ [/^\.vscode$/i, 'chore'],
25
+ ]
26
+
27
+ /** File names that are build/CI/dependency manifests rather than product code. */
28
+ const BUILD_FILE = /^(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|npm-shrinkwrap\.json|tsconfig[^/]*\.json|vite\.config\.[cm]?[jt]s|rollup\.config\.[cm]?[jt]s|webpack\.config\.[cm]?[jt]s|tsdown\.config\.[cm]?[jt]s|esbuild\.[cm]?[jt]s|bun\.lockb|Cargo\.(toml|lock)|go\.(mod|sum)|pyproject\.toml|requirements\.txt|Gemfile(\.lock)?|composer\.(json|lock)|Makefile|Dockerfile|docker-compose\.ya?ml)$/i
29
+
30
+ /** Test-ish file names. */
31
+ const TEST_FILE = /(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$/i
32
+
33
+ /** Documentation-ish file names. */
34
+ const DOC_FILE = /(^|\/)(README|CHANGELOG|CONTRIBUTING|LICENSE|AGENTS|CLAUDE)(\.[^/]*)?$|\.(md|mdx|rst)$/i
35
+
36
+ /** Style-ish file names. */
37
+ const STYLE_FILE = /\.(css|scss|less|styl|sass)$/i
38
+
39
+ /** Files that carry a version number worth tagging a release for. */
40
+ export const VERSION_FILE = /(^|\/)(package\.json|manifest\.json|Cargo\.toml|pyproject\.toml|setup\.py|setup\.cfg|composer\.json|pubspec\.yaml|pom\.xml|__init__\.py)$/
41
+
42
+ /**
43
+ * Coerce a diff-shaped input to text.
44
+ *
45
+ * These functions are exported and take whatever a caller passes. A total
46
+ * function is the right contract here for one blunt reason: a commit must never
47
+ * fail because an analysis helper was handed something unexpected. The cost of
48
+ * being wrong is "the generator had less evidence", not a crashed commit.
49
+ */
50
+ function asDiffText(value) {
51
+ if (typeof value === 'string') return value
52
+ if (Array.isArray(value)) return value.join('\n')
53
+ return ''
54
+ }
55
+
56
+ /** Split a repository-relative path into its segments. */
57
+ function segments(path) {
58
+ return path.split('/').filter(part => part !== '')
59
+ }
60
+
61
+ /** The last path segment. */
62
+ function basename(path) {
63
+ const parts = segments(path)
64
+ return parts[parts.length - 1] ?? path
65
+ }
66
+
67
+ /** The last path segment without its extension. */
68
+ function stem(path) {
69
+ const name = basename(path)
70
+ const dot = name.lastIndexOf('.')
71
+ return dot > 0 ? name.slice(0, dot) : name
72
+ }
73
+
74
+ /**
75
+ * Classify one changed path into a Conventional Commits type.
76
+ *
77
+ * Order matters: an explicit test/doc/build path beats an extension rule, and
78
+ * the extension rule beats the generic "product code" answer.
79
+ */
80
+ export function typeOfPath(path, status) {
81
+ const parts = segments(path)
82
+ for (const part of parts.slice(0, -1)) {
83
+ for (const [pattern, type] of TYPE_BY_SEGMENT) {
84
+ if (pattern.test(part)) return type
85
+ }
86
+ }
87
+ const name = basename(path)
88
+ if (parts.slice(0, -1).some(part => /^(\.github|\.gitlab|\.circleci)$/i.test(part))) return 'ci'
89
+ if (BUILD_FILE.test(name)) return 'build'
90
+ if (TEST_FILE.test(path)) return 'test'
91
+ if (DOC_FILE.test(name)) return 'docs'
92
+ if (STYLE_FILE.test(name)) return 'style'
93
+ // A dotfile (`.gitignore`, `.npmrc`) has no extension: `lastIndexOf('.')` is 0,
94
+ // so a naive slice would invent the extension "gitignore" and fall through to
95
+ // `feat`. Repository plumbing is housekeeping, never a feature.
96
+ if (name.startsWith('.')) return 'chore'
97
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
98
+ const byExt = TYPE_BY_EXTENSION.get(ext)
99
+ if (byExt !== undefined) return byExt
100
+ if (status === 'D') return 'refactor'
101
+ return 'feat'
102
+ }
103
+
104
+ /**
105
+ * The most common shared directory among the changed paths.
106
+ *
107
+ * A `src`/`lib`/`app`/`packages` wrapper is transparent: the interesting scope
108
+ * is the module INSIDE it, not the wrapper everyone has. Returns undefined when
109
+ * the changes share no directory.
110
+ */
111
+ export function scopeOf(paths) {
112
+ const TRANSPARENT = new Set(['src', 'lib', 'libs', 'app', 'apps', 'packages', 'source', 'sources', 'internal', 'pkg'])
113
+ const counts = new Map()
114
+ for (const path of paths) {
115
+ const parts = segments(path)
116
+ if (parts.length < 2) continue
117
+ let index = 0
118
+ while (index < parts.length - 1 && TRANSPARENT.has(parts[index])) index += 1
119
+ const candidate = parts[index]
120
+ if (candidate === undefined || index >= parts.length - 1) continue
121
+ counts.set(candidate, (counts.get(candidate) ?? 0) + 1)
122
+ }
123
+ let best
124
+ let bestCount = 0
125
+ for (const [name, count] of counts) {
126
+ if (count > bestCount || (count === bestCount && best !== undefined && name < best)) {
127
+ best = name
128
+ bestCount = count
129
+ }
130
+ }
131
+ // A scope must be shared: two files, or (for a single-file changeset) the one
132
+ // file's own directory. A directory holding one file out of many is noise.
133
+ if (best === undefined || bestCount < Math.min(2, paths.length)) return undefined
134
+ return best
135
+ }
136
+
137
+ /**
138
+ * Extract identifiers declared by the changed lines of a diff.
139
+ *
140
+ * This is how the generator names a real symbol. Only declaration-shaped
141
+ * added/removed lines are considered, and the first identifier wins so the
142
+ * result is stable across runs.
143
+ *
144
+ * @param {string} unifiedDiff `git diff --unified=0` output
145
+ * @returns {string[]} up to 5 distinct identifiers, in first-seen order
146
+ */
147
+ export function declaredSymbols(unifiedDiff) {
148
+ const text = asDiffText(unifiedDiff)
149
+ const found = []
150
+ const patterns = [
151
+ /^\+\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
152
+ /^\+\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
153
+ /^\+\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/,
154
+ /^\+\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/,
155
+ /^\+\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/,
156
+ /^\+\s*(?:export\s+)?def\s+([A-Za-z_]\w*)/,
157
+ /^\+\s*(?:export\s+)?func\s+([A-Za-z_]\w*)/,
158
+ /^\+\s*pub(?:lic)?\s+fn\s+([A-Za-z_]\w*)/,
159
+ ]
160
+ for (const line of text.split('\n')) {
161
+ for (const pattern of patterns) {
162
+ const match = pattern.exec(line)
163
+ if (match !== null) {
164
+ const symbol = match[1]
165
+ if (!found.includes(symbol)) found.push(symbol)
166
+ break
167
+ }
168
+ }
169
+ if (found.length >= 5) break
170
+ }
171
+ return found
172
+ }
173
+
174
+ /** Count only removed lines that look like a public declaration. */
175
+ export function removedDeclarationCount(unifiedDiff) {
176
+ const text = asDiffText(unifiedDiff)
177
+ let count = 0
178
+ for (const line of text.split('\n')) {
179
+ if (!line.startsWith('-') || line.startsWith('---')) continue
180
+ if (/^-\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var|def|func|fn)\b/.test(line)) count += 1
181
+ }
182
+ return count
183
+ }
184
+
185
+ /**
186
+ * Pick the commit type for a whole changeset.
187
+ *
188
+ * `fix` is never inferred: a rule-based generator cannot tell a bug fix from
189
+ * any other edit, and claiming one would put a lie in the history. New files
190
+ * make it a feature; an all-deletion changeset is a refactor; otherwise the
191
+ * type is decided by the dominant file kind.
192
+ *
193
+ * @param {{ status: string, path: string }[]} entries
194
+ * @returns {string} a Conventional Commits type
195
+ */
196
+ export function inferType(entries) {
197
+ const kinds = entries.map(entry => typeOfPath(entry.path, entry.status))
198
+ const added = entries.filter(entry => entry.status === 'A')
199
+
200
+ // New test/doc/style/build/CI files describe themselves; they must not be
201
+ // promoted to `feat` just because they are new.
202
+ const NEW_FILE_TYPE = { test: 'test', docs: 'docs', style: 'style', build: 'build', ci: 'ci', chore: 'chore' }
203
+ if (added.length > 0) {
204
+ const addKinds = added.map(entry => typeOfPath(entry.path, entry.status))
205
+ const nonFeat = addKinds.filter(kind => kind !== 'feat')
206
+ if (nonFeat.length === addKinds.length) {
207
+ const ranked = rank(nonFeat)
208
+ return NEW_FILE_TYPE[ranked] ?? 'feat'
209
+ }
210
+ return 'feat'
211
+ }
212
+
213
+ if (entries.every(entry => entry.status === 'D')) return 'refactor'
214
+ const ranked = rank(kinds)
215
+ return ranked === 'chore' ? 'chore' : ranked
216
+ }
217
+
218
+ /** The most frequent value, with a deterministic tie-break by fixed priority. */
219
+ function rank(values) {
220
+ const PRIORITY = ['feat', 'fix', 'refactor', 'perf', 'test', 'docs', 'style', 'build', 'ci', 'chore']
221
+ const counts = new Map()
222
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
223
+ let best
224
+ let bestCount = 0
225
+ for (const [value, count] of counts) {
226
+ if (count > bestCount) {
227
+ best = value
228
+ bestCount = count
229
+ continue
230
+ }
231
+ if (count === bestCount && best !== undefined) {
232
+ if (PRIORITY.indexOf(value) !== -1 && (PRIORITY.indexOf(best) === -1 || PRIORITY.indexOf(value) < PRIORITY.indexOf(best))) best = value
233
+ }
234
+ }
235
+ return best ?? 'chore'
236
+ }
237
+
238
+ /** Total added/deleted lines across a per-file stat map. */
239
+ export function totalsOf(stats) {
240
+ let added = 0
241
+ let deleted = 0
242
+ for (const stat of stats.values()) {
243
+ added += stat.added
244
+ deleted += stat.deleted
245
+ }
246
+ return { added, deleted }
247
+ }
248
+
249
+ /**
250
+ * Build a Conventional Commits message from observed facts only.
251
+ *
252
+ * @param {object} input
253
+ * @param {{ status: string, path: string }[]} input.entries normalized changes
254
+ * @param {Map<string, { added: number, deleted: number, binary: boolean }>} input.stats
255
+ * @param {string} input.unifiedDiff `git diff --unified=0` text (may be '')
256
+ * @param {string[]} [input.recentSubjects] existing subjects, for tone matching
257
+ * @param {'zh' | 'en'} [input.language]
258
+ * @returns {{ message: string, type: string, scope?: string, subject: string }}
259
+ */
260
+ export function buildMessage(input) {
261
+ const {
262
+ entries, stats, unifiedDiff, recentSubjects = [], language = 'zh', maxFiles = 12,
263
+ } = input
264
+ const type = inferType(entries)
265
+ const scope = scopeOf(entries.map(entry => entry.path))
266
+ const { added, deleted } = totalsOf(stats)
267
+ const symbols = declaredSymbols(unifiedDiff)
268
+ const subject = buildSubject({ type, scope, entries, symbols, added, deleted, language })
269
+
270
+ const subjectLine = `${scopePrefix(type, scope)}: ${subject}`
271
+ const body = []
272
+ let notes = []
273
+ // A single-file commit says everything in its subject. With more than one
274
+ // file the body carries one typed note PER FILE: a shared sentence cannot
275
+ // describe a documentation change and a bug fix at the same time.
276
+ if (entries.length > 1) {
277
+ notes = buildPerFileNotes({ entries, stats, unifiedDiff, language, maxFiles })
278
+ for (const { path, note } of notes) body.push(`- ${note} · ${path}`)
279
+ const hidden = entries.length - notes.length
280
+ if (hidden > 0) {
281
+ body.push(language === 'en' ? `- …and ${hidden} more file${hidden > 1 ? 's' : ''}` : `- …另有 ${hidden} 个文件`)
282
+ }
283
+ }
284
+
285
+ // Tone hint only: never copied into the message, just surfaced to the caller.
286
+ void recentSubjects
287
+ // Subject, blank line, body — the shape every git client and `git log`
288
+ // renderer expects, and the one Conventional Commits documents.
289
+ const message = body.length > 0 ? [subjectLine, '', ...body].join('\n') : subjectLine
290
+ return { message, type, scope, subject, notes }
291
+ }
292
+
293
+ /**
294
+ * Split a `git diff` into one section per changed path.
295
+ *
296
+ * `git diff` emits `diff --git a/<path> b/<path>` headers, so the whole sampled
297
+ * diff can be attributed to files without a second git call per file. Paths are
298
+ * matched by SUFFIX against the paths the caller already knows about (see
299
+ * `patchForEntry`) instead of being parsed out of the header: a filename may
300
+ * itself contain ` b/`, and the known path list is the only unambiguous key.
301
+ *
302
+ * @param {string} diffText
303
+ * @returns {{ header: string, text: string }[]}
304
+ */
305
+ export function splitDiffSections(diffText) {
306
+ const sections = []
307
+ for (const line of String(diffText ?? '').split('\n')) {
308
+ if (line.startsWith('diff --git ')) sections.push({ header: line, lines: [line] })
309
+ else if (sections.length > 0) sections[sections.length - 1].lines.push(line)
310
+ }
311
+ return sections.map(section => ({ header: section.header, text: section.lines.join('\n') }))
312
+ }
313
+
314
+ /**
315
+ * The diff section belonging to one changed entry.
316
+ *
317
+ * A rename's header names the OLD path on the left and the new one on the
318
+ * right, so the entry's `origPath` is tried first; everything else matches on
319
+ * the right-hand path alone.
320
+ *
321
+ * @param {{ header: string, text: string }[]} sections
322
+ * @param {{ path: string, origPath?: string }} entry
323
+ * @returns {string} the patch text, or '' when the diff did not cover this file
324
+ */
325
+ export function patchForEntry(sections, entry) {
326
+ const right = ` b/${entry.path}`
327
+ const exact = sections.find(section => section.header === `diff --git a/${entry.path}${right}`)
328
+ if (exact !== undefined) return exact.text
329
+ if (entry.origPath !== undefined) {
330
+ const renamed = sections.find(section => section.header.startsWith(`diff --git a/${entry.origPath} `) && section.header.endsWith(right))
331
+ if (renamed !== undefined) return renamed.text
332
+ }
333
+ return sections.find(section => section.header.endsWith(right))?.text ?? ''
334
+ }
335
+
336
+ /**
337
+ * One Conventional-Commits note per changed file.
338
+ *
339
+ * This is what makes a multi-file commit readable: the subject describes the
340
+ * changeset, and every file in it carries its own typed note in the body
341
+ * instead of one shared sentence pretending to cover all of them.
342
+ *
343
+ * The note is derived from THAT file's own status, path, line counts and diff
344
+ * sample, so a documentation file gets `docs:` while a test file gets `test:`.
345
+ *
346
+ * @param {object} input
347
+ * @param {{ status: string, path: string, origPath?: string }[]} input.entries
348
+ * @param {Map<string, { added: number, deleted: number, binary: boolean }>} input.stats
349
+ * @param {string} input.unifiedDiff the whole sampled diff (may be '')
350
+ * @param {'zh' | 'en'} [input.language]
351
+ * @param {number} [input.maxFiles] how many files the body may name
352
+ * @returns {{ path: string, note: string }[]}
353
+ */
354
+ export function buildPerFileNotes(input) {
355
+ const { entries, stats, unifiedDiff, language = 'zh', maxFiles = 12 } = input
356
+ const sections = splitDiffSections(unifiedDiff)
357
+ return entries.slice(0, Math.max(0, maxFiles)).map((entry) => {
358
+ const stat = stats.get(entry.path) ?? { added: 0, deleted: 0, binary: false }
359
+ const patch = patchForEntry(sections, entry)
360
+ const single = [{ status: entry.status, path: entry.path }]
361
+ const type = inferType(single)
362
+ const scope = scopeOf([entry.path])
363
+ const subject = buildSubject({
364
+ type,
365
+ scope,
366
+ entries: single,
367
+ symbols: declaredSymbols(patch),
368
+ added: stat.added,
369
+ deleted: stat.deleted,
370
+ language,
371
+ })
372
+ return {
373
+ path: entry.path,
374
+ note: `${scopePrefix(type, scope)}: ${subject}`,
375
+ }
376
+ })
377
+ }
378
+
379
+ /**
380
+ * Compose the subject line itself from the strongest available fact.
381
+ *
382
+ * @param {object} input
383
+ * @param {string} input.type
384
+ * @param {string | undefined} input.scope
385
+ * @param {{ status: string, path: string }[]} input.entries
386
+ * @param {string[]} input.symbols
387
+ * @param {number} input.added
388
+ * @param {number} input.deleted
389
+ * @param {'zh' | 'en'} [input.language]
390
+ * @returns {string}
391
+ */
392
+ /**
393
+ * `type(scope)`, with the scope dropped when it only repeats the type.
394
+ *
395
+ * A file under `docs/` infers type `docs` and scope `docs`; emitting
396
+ * `docs(docs): …` reads like a bug, and `docs: …` says the same thing.
397
+ *
398
+ * @param {string} type
399
+ * @param {string | undefined} scope
400
+ * @returns {string}
401
+ */
402
+ export function scopePrefix(type, scope) {
403
+ return scope === undefined || scope === type ? type : `${type}(${scope})`
404
+ }
405
+
406
+ export function buildSubject({ type, scope, entries, symbols, added, deleted, language }) {
407
+ // The label names the thing changed: the directory when it adds information,
408
+ // the file itself when the scope would only echo the type.
409
+ const label = scope === undefined || scope === type ? stem(entries[0]?.path ?? 'project') : scope
410
+
411
+ if (symbols.length > 0) {
412
+ const named = symbols.slice(0, 2).join('、')
413
+ if (language === 'en') return `update ${named}`
414
+ return type === 'test'
415
+ ? `补充 ${named} 相关测试`
416
+ : `更新 ${named}`
417
+ }
418
+
419
+ const files = entries.length
420
+ const removed = entries.filter(entry => entry.status === 'D').length
421
+ const addedFiles = entries.filter(entry => entry.status === 'A').length
422
+
423
+ if (addedFiles === files && addedFiles > 0) {
424
+ return language === 'en'
425
+ ? `add ${addedFiles} file${addedFiles > 1 ? 's' : ''}`
426
+ : `新增 ${describeFiles(entries)}`
427
+ }
428
+ if (removed === files && removed > 0) {
429
+ return language === 'en'
430
+ ? `remove ${removed} file${removed > 1 ? 's' : ''}`
431
+ : `移除 ${describeFiles(entries)}`
432
+ }
433
+
434
+ const verbs = {
435
+ feat: '实现', fix: '修复', refactor: '重构', perf: '优化', test: '补充测试',
436
+ docs: '更新文档', style: '调整样式', build: '调整构建配置', ci: '调整 CI 配置', chore: '维护',
437
+ }
438
+ const verb = verbs[type] ?? '更新'
439
+ if (language === 'en') return `update ${label} (${files} file${files > 1 ? 's' : ''}, +${added}/-${deleted})`
440
+ return files === 1 ? `${verb} ${label}` : `${verb} ${label} 等 ${files} 个文件`
441
+ }
442
+
443
+ /** A short, human-readable list of the changed file names. */
444
+ function describeFiles(entries) {
445
+ const names = entries.slice(0, 2).map(entry => basename(entry.path))
446
+ const suffix = entries.length > 2 ? ` 等 ${entries.length} 个文件` : ''
447
+ return names.join('、') + suffix
448
+ }
449
+
450
+ /**
451
+ * The compact, token-bounded report the model reads.
452
+ *
453
+ * This is the whole point of the plugin: the caller learns what changed, in
454
+ * what shape, and what the deterministic message would be, without ever seeing
455
+ * a diff body. Everything here is derived from one status call, one numstat
456
+ * call, one bounded diff sample, and one log call.
457
+ *
458
+ * @param {object} input
459
+ * @param {string} input.branch
460
+ * @param {{ status: string, path: string }[]} input.entries
461
+ * @param {Map<string, { added: number, deleted: number, binary: boolean }>} input.stats
462
+ * @param {string[]} input.recentSubjects
463
+ * @param {{ symbol: string, from?: string, to?: string, file: string } | undefined} input.version
464
+ * @param {boolean} input.breaking
465
+ * @param {{ type: string, scope?: string, subject: string, message: string }} input.draft
466
+ * @param {number} input.maxFiles
467
+ * @param {boolean} input.hasUpstream
468
+ * @returns {string} a markdown card, bounded by `maxFiles`
469
+ */
470
+ export function renderCard(input) {
471
+ const {
472
+ branch, entries, stats, recentSubjects, version, breaking, draft, maxFiles, hasUpstream,
473
+ } = input
474
+ const { added, deleted } = totalsOf(stats)
475
+ const removed = entries.filter(entry => entry.status === 'D').map(entry => entry.path)
476
+
477
+ const lines = []
478
+ // A leading, unmissable verdict: the first line is what a person reads when
479
+ // the card renders, so it says what state the repository is in.
480
+ lines.push(`🔎 **改动预览(未提交)** · \`${branch}\`${hasUpstream ? '' : '(无 upstream)'} · ${entries.length} 个文件 · +${added} / -${deleted}`)
481
+
482
+ const counts = new Map()
483
+ for (const entry of entries) counts.set(entry.status, (counts.get(entry.status) ?? 0) + 1)
484
+ const label = { A: '新增', M: '修改', D: '删除', R: '重命名', C: '复制', U: '冲突', '?': '未跟踪' }
485
+ lines.push('状态:' + [...counts].map(([status, count]) => `${label[status] ?? status} ${count}`).join(' / '))
486
+
487
+ // One line per file, each ending in the Conventional-Commits note THAT file
488
+ // would get in the commit body (`draft.notes`); a file whose note the body
489
+ // dropped (the cap) simply shows its stat.
490
+ const notes = new Map((draft.notes ?? []).map(item => [item.path, item.note]))
491
+ const shown = entries.slice(0, maxFiles)
492
+ for (const entry of shown) {
493
+ const stat = stats.get(entry.path)
494
+ const delta = stat === undefined ? '' : stat.binary ? ' (binary)' : ` +${stat.added}/-${stat.deleted}`
495
+ const note = notes.get(entry.path)
496
+ lines.push(` ${label[entry.status] ?? entry.status} ${entry.path}${delta}${note === undefined ? '' : ` → ${note}`}`)
497
+ }
498
+ if (entries.length > shown.length) lines.push(` …另有 ${entries.length - shown.length} 个文件`)
499
+
500
+ const hints = []
501
+ if (version !== undefined && (version.from !== version.to)) {
502
+ hints.push(`版本号 ${version.from ?? '?'} → ${version.to ?? '?'}(${version.file})`)
503
+ }
504
+ if (breaking) hints.push('检测到可能的破坏性变更(公共声明被删除)')
505
+ if (entries.length >= 10) hints.push(`文件数 ${entries.length} ≥ 10`)
506
+ if (hints.length > 0) lines.push('标签依据:' + hints.join(';'))
507
+
508
+ if (recentSubjects.length > 0) {
509
+ lines.push(`最近提交风格:${recentSubjects.slice(0, 3).map(subject => `"${subject}"`).join(' ')}`)
510
+ }
511
+
512
+ lines.push(`拟定标题:\`${draft.message.split('\n')[0]}\``)
513
+
514
+ if (removed.length > 0) lines.push(` (删除项 ${removed.length})`)
515
+ lines.push('(仅预览,未提交未推送)')
516
+ return lines.join('\n')
517
+ }