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/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
|
+
}
|