@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 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.76",
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.1",
71
- "cssnano-preset-default": "^9.0.1",
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.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",
@@ -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 files = listFiles(cwd, option)
70
- if (!files.length) {
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, files, cwd, option.type)
80
+ const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
76
81
  const raw = await withAgentProgress(
77
- { fileCount: files.length, verbose: option.verbose },
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
- const plans = parsePlan(raw, files)
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 files = plan.files.filter((file) => fileHasChanges(cwd, file))
126
- if (!files.length) {
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
- git(cwd, ['add', '--', ...files])
143
+ stageFiles(cwd, planFiles)
134
144
  const staged = lines(cwd, ['diff', '--cached', '--name-only'])
135
- const stagedForPlan = files.filter((file) => staged.includes(file))
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: ${files.join(', ')}`,
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
- const staged = lines(cwd, [
293
- 'diff',
294
- '--cached',
295
- '--name-only',
296
- '--diff-filter=ACDMR',
297
- ])
298
- if (option.staged || staged.length) return staged
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
- let diff = [
309
- out(cwd, ['diff', '--cached', '--', ...files]),
310
- out(cwd, ['diff', '--', ...files]),
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.map((file) => `- ${file}`).join('\n')}
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(`${getBananaFrame(frameIndex)}\n${formatText()}`)
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 fileHasChanges(cwd, file) {
572
- const filePath = path.join(cwd, file)
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
- if (!tracked) return true
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 })