@itcase/config 1.6.76 → 1.6.78
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/README.md +2 -2
- package/package.json +4 -4
- package/smart-commit/cli.js +169 -35
- package/smart-commit/dancing-banana.js +339 -224
- package/smart-commit/nyan-cat.js +327 -0
- package/smart-commit/scripts/extract-banana.py +0 -255
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ npm install --save-dev commitizen @commitlint/cz-commitlint
|
|
|
87
87
|
|
|
88
88
|
## Smart commit
|
|
89
89
|
|
|
90
|
-
`smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`.
|
|
90
|
+
`smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит.
|
|
91
91
|
|
|
92
92
|
```text
|
|
93
93
|
[yellow pixel banana animation]
|
|
@@ -146,7 +146,7 @@ CLI ставится в `~/.local/bin`. Логин в Cursor IDE этот CLI н
|
|
|
146
146
|
|
|
147
147
|
Обновление CLI: `agent update`.
|
|
148
148
|
|
|
149
|
-
Пока Agent думает, в терминале крутится pixel-анимация dancing banana + статус `(Ns)` на одной области экрана (`log-update`). Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
|
|
149
|
+
Пока Agent думает, в терминале крутится pixel-анимация (случайно nyan cat или dancing banana) + статус `(Ns)` на одной области экрана (`log-update`). Принудительно: `--cat` / `--banana`. Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
|
|
150
150
|
|
|
151
151
|
> **Важно:** в npm есть одноимённый пакет `smart-commit`. Используйте `npm run smart-commit` из проекта с `@itcase/config` — bin берётся из `node_modules`. `npx smart-commit` без локальной установки может подтянуть чужой пакет; CLI перед запуском проверяет, что выполняется именно `@itcase/config`.
|
|
152
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itcase/config",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.78",
|
|
4
4
|
"author": "ITCase",
|
|
5
5
|
"description": "ITCase Config",
|
|
6
6
|
"engines": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"chokidar-cli": "^3.0.0",
|
|
68
68
|
"commitizen": "^4.3.2",
|
|
69
69
|
"conventional-changelog-conventionalcommits": "9.3.1",
|
|
70
|
-
"cssnano": "^9.0.
|
|
71
|
-
"cssnano-preset-default": "^9.0.
|
|
70
|
+
"cssnano": "^9.0.2",
|
|
71
|
+
"cssnano-preset-default": "^9.0.2",
|
|
72
72
|
"inquirer": "^14.2.0",
|
|
73
73
|
"log-update": "^8.0.0",
|
|
74
74
|
"postcss": "^8.5.26",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"postcss-combine-duplicated-selectors": "^10.0.3",
|
|
80
80
|
"postcss-convert-unit": "^0.1.3",
|
|
81
81
|
"postcss-dark-theme-class": "^2.0.0",
|
|
82
|
-
"postcss-discard-duplicates": "^9.0.
|
|
82
|
+
"postcss-discard-duplicates": "^9.0.1",
|
|
83
83
|
"postcss-each": "^1.1.0",
|
|
84
84
|
"postcss-easings": "^4.0.0",
|
|
85
85
|
"postcss-extend-rule": "^4.0.0",
|
package/smart-commit/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
promptSubjectRules,
|
|
17
17
|
} from '../commitlint/commitlintCommon.js'
|
|
18
18
|
import { getBananaFrame } from './dancing-banana.js'
|
|
19
|
+
import { getNyanFrame } from './nyan-cat.js'
|
|
19
20
|
|
|
20
21
|
const PACKAGE_NAME = '@itcase/config'
|
|
21
22
|
const CLI_RELATIVE = 'smart-commit/cli.js'
|
|
@@ -23,6 +24,7 @@ const CLI_RELATIVE = 'smart-commit/cli.js'
|
|
|
23
24
|
const MAX_DIFF = 80_000
|
|
24
25
|
const AGENT_TIMEOUT_MS = 120_000
|
|
25
26
|
const AGENT_STEP_MS = 2_500
|
|
27
|
+
const NYAN_FRAME_MS = 90
|
|
26
28
|
const BANANA_FRAME_MS = 100
|
|
27
29
|
const INDEX_LOCK_RETRIES = 8
|
|
28
30
|
const INDEX_LOCK_WAIT_MS = 250
|
|
@@ -33,10 +35,13 @@ const AGENT_WAIT_STEPS = [
|
|
|
33
35
|
'Waiting for response',
|
|
34
36
|
]
|
|
35
37
|
const HELP = `smart-commit [--dry-run] [--yes|-y] [--type t] [--model id] [--verbose]
|
|
38
|
+
[--cat|--nyan] [--banana]
|
|
36
39
|
|
|
37
40
|
Cursor Agent → commit messages by SCOPES → [y/N] → git commit
|
|
38
41
|
Run via: npm exec smart-commit (from project with @itcase/config)
|
|
39
42
|
Needs: agent login (or CURSOR_API_KEY) + commitlint.config.* with SCOPES
|
|
43
|
+
|
|
44
|
+
Progress sprite is random (nyan cat or banana). Force with --cat or --banana.
|
|
40
45
|
`
|
|
41
46
|
const AGENT_MISSING = `Cursor Agent CLI not found — smart-commit cannot run.
|
|
42
47
|
|
|
@@ -66,15 +71,19 @@ async function main(argv) {
|
|
|
66
71
|
const cwd = process.cwd()
|
|
67
72
|
assertItcaseConfigPackage(cwd)
|
|
68
73
|
const { scopes, types } = await loadCommitlintConfig(cwd)
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
74
|
+
const changedFiles = listFiles(cwd, option)
|
|
75
|
+
if (!changedFiles.length) {
|
|
71
76
|
process.stdout.write('No changes to commit.\n')
|
|
72
77
|
return
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
const prompt = buildPrompt(scopes, types,
|
|
80
|
+
const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
|
|
76
81
|
const raw = await withAgentProgress(
|
|
77
|
-
{
|
|
82
|
+
{
|
|
83
|
+
fileCount: changedFiles.length,
|
|
84
|
+
sprite: pickSprite(option.sprite),
|
|
85
|
+
verbose: option.verbose,
|
|
86
|
+
},
|
|
78
87
|
(progress) =>
|
|
79
88
|
askAgent(
|
|
80
89
|
cwd,
|
|
@@ -84,7 +93,8 @@ async function main(argv) {
|
|
|
84
93
|
progress.onAgentActivity,
|
|
85
94
|
),
|
|
86
95
|
)
|
|
87
|
-
|
|
96
|
+
let plans = parsePlan(raw, changedFiles)
|
|
97
|
+
plans = assignOmittedFiles(plans, changedFiles)
|
|
88
98
|
await validateAndNormalizePlans(cwd, plans)
|
|
89
99
|
|
|
90
100
|
process.stdout.write('\nCommits:\n')
|
|
@@ -122,21 +132,21 @@ async function main(argv) {
|
|
|
122
132
|
|
|
123
133
|
let created = 0
|
|
124
134
|
for (const plan of activePlans) {
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
135
|
+
const planFiles = plan.files.filter((file) => fileHasChanges(cwd, file))
|
|
136
|
+
if (!planFiles.length) {
|
|
127
137
|
process.stdout.write(`Skipping empty commit: ${plan.message}\n`)
|
|
128
138
|
continue
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
// Drop leftover index entries so only this plan's files are committed.
|
|
132
142
|
git(cwd, ['reset', '-q', 'HEAD'])
|
|
133
|
-
|
|
143
|
+
stageFiles(cwd, planFiles)
|
|
134
144
|
const staged = lines(cwd, ['diff', '--cached', '--name-only'])
|
|
135
|
-
const stagedForPlan =
|
|
145
|
+
const stagedForPlan = planFiles.filter((file) => staged.includes(file))
|
|
136
146
|
if (!stagedForPlan.length) {
|
|
137
147
|
throw new Error(
|
|
138
148
|
`Nothing staged for commit: ${plan.message}\n` +
|
|
139
|
-
`Files: ${
|
|
149
|
+
`Files: ${planFiles.join(', ')}`,
|
|
140
150
|
)
|
|
141
151
|
}
|
|
142
152
|
|
|
@@ -150,6 +160,14 @@ async function main(argv) {
|
|
|
150
160
|
created++
|
|
151
161
|
}
|
|
152
162
|
process.stdout.write(`Created ${created} commit(s).\n`)
|
|
163
|
+
|
|
164
|
+
const remaining = changedFiles.filter((file) => fileHasChanges(cwd, file))
|
|
165
|
+
if (remaining.length) {
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
`\nUncommitted changes (${remaining.length}):\n` +
|
|
168
|
+
`${remaining.map((file) => ` ${file}`).join('\n')}\n`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
153
171
|
}
|
|
154
172
|
|
|
155
173
|
function parseArgs(argv) {
|
|
@@ -157,6 +175,7 @@ function parseArgs(argv) {
|
|
|
157
175
|
type: '',
|
|
158
176
|
dryRun: false,
|
|
159
177
|
model: '',
|
|
178
|
+
sprite: '',
|
|
160
179
|
staged: false,
|
|
161
180
|
verbose: false,
|
|
162
181
|
yes: false,
|
|
@@ -167,6 +186,8 @@ function parseArgs(argv) {
|
|
|
167
186
|
else if (arg === '--yes' || arg === '-y') option.yes = true
|
|
168
187
|
else if (arg === '--verbose') option.verbose = true
|
|
169
188
|
else if (arg === '--staged') option.staged = true
|
|
189
|
+
else if (arg === '--cat' || arg === '--nyan') option.sprite = 'cat'
|
|
190
|
+
else if (arg === '--banana') option.sprite = 'banana'
|
|
170
191
|
else if (arg === '--type') option.type = argv[++i] || ''
|
|
171
192
|
else if (arg === '--model') option.model = argv[++i] || ''
|
|
172
193
|
else if (arg.startsWith('--type=')) option.type = arg.slice(7)
|
|
@@ -176,6 +197,11 @@ function parseArgs(argv) {
|
|
|
176
197
|
return option
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
function pickSprite(forced) {
|
|
201
|
+
if (forced === 'cat' || forced === 'banana') return forced
|
|
202
|
+
return Math.random() < 0.5 ? 'cat' : 'banana'
|
|
203
|
+
}
|
|
204
|
+
|
|
179
205
|
function assertItcaseConfigPackage(cwd) {
|
|
180
206
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
181
207
|
|
|
@@ -289,15 +315,17 @@ function readReleaseBranches(cwd) {
|
|
|
289
315
|
}
|
|
290
316
|
|
|
291
317
|
function listFiles(cwd, option) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
318
|
+
if (option.staged) {
|
|
319
|
+
return lines(cwd, [
|
|
320
|
+
'diff',
|
|
321
|
+
'--cached',
|
|
322
|
+
'--name-only',
|
|
323
|
+
'--diff-filter=ACDMR',
|
|
324
|
+
])
|
|
325
|
+
}
|
|
299
326
|
return [
|
|
300
327
|
...new Set([
|
|
328
|
+
...lines(cwd, ['diff', '--cached', '--name-only', '--diff-filter=ACDMR']),
|
|
301
329
|
...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
|
|
302
330
|
...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
|
|
303
331
|
]),
|
|
@@ -305,10 +333,14 @@ function listFiles(cwd, option) {
|
|
|
305
333
|
}
|
|
306
334
|
|
|
307
335
|
function buildPrompt(scopes, types, files, cwd, type) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
336
|
+
const statusByFile = new Map(
|
|
337
|
+
lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
|
|
338
|
+
(line) => [line.slice(3), line.slice(0, 2).trim()],
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
let diff = files
|
|
343
|
+
.map((file) => buildFileDiff(cwd, file))
|
|
312
344
|
.filter(Boolean)
|
|
313
345
|
.join('\n\n')
|
|
314
346
|
if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
|
|
@@ -320,11 +352,17 @@ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
|
|
|
320
352
|
Subject rules:
|
|
321
353
|
${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
|
|
322
354
|
Prefer one commit when changes belong to the same feature.
|
|
323
|
-
Split by scope. Each listed file in exactly one commit.
|
|
355
|
+
Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
|
|
324
356
|
Do not add Co-authored-by or Made-with trailers.
|
|
325
357
|
${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
|
|
326
358
|
Files:
|
|
327
|
-
${files
|
|
359
|
+
${files
|
|
360
|
+
.map((file) => {
|
|
361
|
+
const status =
|
|
362
|
+
statusByFile.get(file) || (isUntracked(cwd, file) ? '??' : ' M')
|
|
363
|
+
return `- ${file} (${status})`
|
|
364
|
+
})
|
|
365
|
+
.join('\n')}
|
|
328
366
|
status:
|
|
329
367
|
${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
|
|
330
368
|
diff:
|
|
@@ -332,6 +370,31 @@ ${diff || '(none)'}
|
|
|
332
370
|
`
|
|
333
371
|
}
|
|
334
372
|
|
|
373
|
+
function buildFileDiff(cwd, file) {
|
|
374
|
+
const filePath = path.join(cwd, file)
|
|
375
|
+
if (!fs.existsSync(filePath)) {
|
|
376
|
+
return (
|
|
377
|
+
out(cwd, ['diff', '--cached', '--', file]) ||
|
|
378
|
+
out(cwd, ['diff', '--', file])
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isUntracked(cwd, file)) {
|
|
383
|
+
const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
384
|
+
const untrackedDiff = out(cwd, ['diff', '--no-index', nullPath, file])
|
|
385
|
+
return untrackedDiff
|
|
386
|
+
? `new file ${file}:\n${untrackedDiff}`
|
|
387
|
+
: `new file ${file}: (empty)`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return [
|
|
391
|
+
out(cwd, ['diff', '--cached', '--', file]),
|
|
392
|
+
out(cwd, ['diff', '--', file]),
|
|
393
|
+
]
|
|
394
|
+
.filter(Boolean)
|
|
395
|
+
.join('\n')
|
|
396
|
+
}
|
|
397
|
+
|
|
335
398
|
function buildAgentProgressSteps(fileCount) {
|
|
336
399
|
const fileLabel =
|
|
337
400
|
fileCount === 1 ? '1 changed file' : `${fileCount} changed files`
|
|
@@ -381,13 +444,21 @@ async function withAgentProgress(context, callback) {
|
|
|
381
444
|
return callback({ onAgentActivity })
|
|
382
445
|
}
|
|
383
446
|
|
|
447
|
+
const columns = process.stderr.columns || process.stdout.columns || 80
|
|
448
|
+
const rows = process.stderr.rows || process.stdout.rows || 24
|
|
449
|
+
const getFrame =
|
|
450
|
+
context.sprite === 'cat'
|
|
451
|
+
? (index) => getNyanFrame(index, columns)
|
|
452
|
+
: (index) => getBananaFrame(index, columns, rows)
|
|
453
|
+
const frameMs = context.sprite === 'cat' ? NYAN_FRAME_MS : BANANA_FRAME_MS
|
|
454
|
+
process.stderr.write('\x1b[?7l')
|
|
455
|
+
|
|
384
456
|
const render = () => {
|
|
385
|
-
logUpdateStderr(`${
|
|
457
|
+
logUpdateStderr(`${getFrame(frameIndex)}\n${formatText()}`)
|
|
386
458
|
frameIndex++
|
|
387
459
|
}
|
|
388
460
|
|
|
389
|
-
render
|
|
390
|
-
const frameTimer = setInterval(render, BANANA_FRAME_MS)
|
|
461
|
+
const frameTimer = setInterval(render, frameMs)
|
|
391
462
|
const stepTimer = setInterval(() => {
|
|
392
463
|
if (phase === 'early') {
|
|
393
464
|
if (earlyStepIndex < earlySteps.length - 1) {
|
|
@@ -401,11 +472,13 @@ async function withAgentProgress(context, callback) {
|
|
|
401
472
|
}, AGENT_STEP_MS)
|
|
402
473
|
|
|
403
474
|
try {
|
|
475
|
+
render()
|
|
404
476
|
return await callback({ onAgentActivity })
|
|
405
477
|
} finally {
|
|
406
478
|
clearInterval(frameTimer)
|
|
407
479
|
clearInterval(stepTimer)
|
|
408
480
|
logUpdateStderr.clear()
|
|
481
|
+
process.stderr.write('\x1b[?7h')
|
|
409
482
|
}
|
|
410
483
|
}
|
|
411
484
|
|
|
@@ -528,12 +601,60 @@ function parsePlan(raw, allowedFiles) {
|
|
|
528
601
|
}
|
|
529
602
|
|
|
530
603
|
if (!plans.length) throw new Error(`Empty commit plan:\n${raw}`)
|
|
531
|
-
const missing = allowedFiles.filter((file) => !seen.has(file))
|
|
532
|
-
if (missing.length)
|
|
533
|
-
throw new Error(`Cursor omitted files:\n${missing.join('\n')}`)
|
|
534
604
|
return plans
|
|
535
605
|
}
|
|
536
606
|
|
|
607
|
+
function assignOmittedFiles(plans, allowedFiles) {
|
|
608
|
+
const assigned = new Set(plans.flatMap((plan) => plan.files))
|
|
609
|
+
const missing = allowedFiles.filter((file) => !assigned.has(file))
|
|
610
|
+
if (!missing.length) return plans
|
|
611
|
+
|
|
612
|
+
for (const file of missing) {
|
|
613
|
+
const target = findBestCommitForFile(plans, file)
|
|
614
|
+
target.files.push(file)
|
|
615
|
+
process.stderr.write(
|
|
616
|
+
`Auto-assigned omitted file to commit: ${file} → ${target.message}\n`,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
return plans
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function findBestCommitForFile(plans, file) {
|
|
623
|
+
const fileDir = path.dirname(file)
|
|
624
|
+
|
|
625
|
+
const sameDir = plans.find((plan) =>
|
|
626
|
+
plan.files.some((candidate) => path.dirname(candidate) === fileDir),
|
|
627
|
+
)
|
|
628
|
+
if (sameDir) return sameDir
|
|
629
|
+
|
|
630
|
+
let best = plans[0]
|
|
631
|
+
let bestScore = -1
|
|
632
|
+
for (const plan of plans) {
|
|
633
|
+
for (const candidate of plan.files) {
|
|
634
|
+
const score = commonPathPrefixLength(candidate, file)
|
|
635
|
+
if (score > bestScore) {
|
|
636
|
+
bestScore = score
|
|
637
|
+
best = plan
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return best
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function commonPathPrefixLength(left, right) {
|
|
645
|
+
const leftParts = left.split('/')
|
|
646
|
+
const rightParts = right.split('/')
|
|
647
|
+
let index = 0
|
|
648
|
+
while (
|
|
649
|
+
index < leftParts.length &&
|
|
650
|
+
index < rightParts.length &&
|
|
651
|
+
leftParts[index] === rightParts[index]
|
|
652
|
+
) {
|
|
653
|
+
index++
|
|
654
|
+
}
|
|
655
|
+
return index
|
|
656
|
+
}
|
|
657
|
+
|
|
537
658
|
function normalizeCommitMessage(message) {
|
|
538
659
|
const lines = String(message || '')
|
|
539
660
|
.split('\n')
|
|
@@ -568,17 +689,25 @@ async function validateAndNormalizePlans(cwd, plans) {
|
|
|
568
689
|
}
|
|
569
690
|
}
|
|
570
691
|
|
|
571
|
-
function
|
|
572
|
-
|
|
573
|
-
if (!fs.existsSync(filePath)) return false
|
|
574
|
-
|
|
575
|
-
const tracked =
|
|
692
|
+
function isTracked(cwd, file) {
|
|
693
|
+
return (
|
|
576
694
|
spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
|
|
577
695
|
cwd: cwd,
|
|
578
696
|
encoding: 'utf8',
|
|
579
697
|
}).status === 0
|
|
698
|
+
)
|
|
699
|
+
}
|
|
580
700
|
|
|
581
|
-
|
|
701
|
+
function isUntracked(cwd, file) {
|
|
702
|
+
return !isTracked(cwd, file) && fs.existsSync(path.join(cwd, file))
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function fileHasChanges(cwd, file) {
|
|
706
|
+
const filePath = path.join(cwd, file)
|
|
707
|
+
const tracked = isTracked(cwd, file)
|
|
708
|
+
|
|
709
|
+
if (!tracked) return fs.existsSync(filePath)
|
|
710
|
+
if (!fs.existsSync(filePath)) return true
|
|
582
711
|
|
|
583
712
|
return (
|
|
584
713
|
spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
|
|
@@ -588,6 +717,11 @@ function fileHasChanges(cwd, file) {
|
|
|
588
717
|
)
|
|
589
718
|
}
|
|
590
719
|
|
|
720
|
+
function stageFiles(cwd, files) {
|
|
721
|
+
if (!files.length) return
|
|
722
|
+
git(cwd, ['add', '-A', '--', ...files])
|
|
723
|
+
}
|
|
724
|
+
|
|
591
725
|
async function confirm(count) {
|
|
592
726
|
if (!input.isTTY) throw new Error('No TTY — use --yes or --dry-run')
|
|
593
727
|
const rl = readline.createInterface({ input, output })
|