@itcase/config 1.6.77 → 1.6.79

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]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит.
90
+ `smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит. Lockfile (`package-lock.json` и аналоги) всегда кладётся в тот же коммит, что и соседний `package.json`; при одном только bump deps агент не вызывается.
91
91
 
92
92
  ```text
93
93
  [yellow pixel banana animation]
@@ -9,8 +9,11 @@ export default {
9
9
  'package.json': (files) => {
10
10
  // Проверяем, есть ли package.json в корне репозитория
11
11
  const rootPackageJson = resolve(process.cwd(), 'package.json')
12
- if (existsSync(rootPackageJson) && files.includes(rootPackageJson)) {
13
- return 'npm install --package-lock-only'
12
+ const isRoot = files.some((file) => resolve(file) === rootPackageJson)
13
+ if (existsSync(rootPackageJson) && isRoot) {
14
+ // lock пишется командой, но glob только package.json — без git add
15
+ // он останется вне коммита (типичный leftover после bump deps)
16
+ return ['npm install --package-lock-only', 'git add -- package-lock.json']
14
17
  }
15
18
  return 'true' // Пропускаем, если не найден
16
19
  },
@@ -17,8 +17,11 @@ export default {
17
17
  'package.json': (files) => {
18
18
  // Проверяем, есть ли package.json в корне репозитория
19
19
  const rootPackageJson = resolve(process.cwd(), 'package.json')
20
- if (existsSync(rootPackageJson) && files.includes(rootPackageJson)) {
21
- return 'npm install --package-lock-only'
20
+ const isRoot = files.some((file) => resolve(file) === rootPackageJson)
21
+ if (existsSync(rootPackageJson) && isRoot) {
22
+ // lock пишется командой, но glob только package.json — без git add
23
+ // он останется вне коммита (типичный leftover после bump deps)
24
+ return ['npm install --package-lock-only', 'git add -- package-lock.json']
22
25
  }
23
26
  return 'true' // Пропускаем, если не найден
24
27
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.77",
3
+ "version": "1.6.79",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -22,6 +22,14 @@ const PACKAGE_NAME = '@itcase/config'
22
22
  const CLI_RELATIVE = 'smart-commit/cli.js'
23
23
 
24
24
  const MAX_DIFF = 80_000
25
+ const LOCKFILE_NAMES = new Set([
26
+ 'package-lock.json',
27
+ 'npm-shrinkwrap.json',
28
+ 'yarn.lock',
29
+ 'pnpm-lock.yaml',
30
+ 'bun.lock',
31
+ 'bun.lockb',
32
+ ])
25
33
  const AGENT_TIMEOUT_MS = 120_000
26
34
  const AGENT_STEP_MS = 2_500
27
35
  const NYAN_FRAME_MS = 90
@@ -71,29 +79,34 @@ async function main(argv) {
71
79
  const cwd = process.cwd()
72
80
  assertItcaseConfigPackage(cwd)
73
81
  const { scopes, types } = await loadCommitlintConfig(cwd)
74
- const files = listFiles(cwd, option)
75
- if (!files.length) {
82
+ const changedFiles = listFiles(cwd, option)
83
+ if (!changedFiles.length) {
76
84
  process.stdout.write('No changes to commit.\n')
77
85
  return
78
86
  }
79
87
 
80
- const prompt = buildPrompt(scopes, types, files, cwd, option.type)
81
- const raw = await withAgentProgress(
82
- {
83
- fileCount: files.length,
84
- sprite: pickSprite(option.sprite),
85
- verbose: option.verbose,
86
- },
87
- (progress) =>
88
- askAgent(
89
- cwd,
90
- option.model,
91
- prompt,
92
- option.verbose,
93
- progress.onAgentActivity,
94
- ),
95
- )
96
- const plans = parsePlan(raw, files)
88
+ let plans = tryFastPlan(changedFiles, cwd, scopes, types)
89
+ if (!plans) {
90
+ const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
91
+ const raw = await withAgentProgress(
92
+ {
93
+ fileCount: changedFiles.length,
94
+ sprite: pickSprite(option.sprite),
95
+ verbose: option.verbose,
96
+ },
97
+ (progress) =>
98
+ askAgent(
99
+ cwd,
100
+ option.model || process.env.SMART_COMMIT_MODEL || '',
101
+ prompt,
102
+ option.verbose,
103
+ progress.onAgentActivity,
104
+ ),
105
+ )
106
+ plans = parsePlan(raw, changedFiles)
107
+ plans = assignOmittedFiles(plans, changedFiles)
108
+ plans = attachLockfiles(plans, changedFiles)
109
+ }
97
110
  await validateAndNormalizePlans(cwd, plans)
98
111
 
99
112
  process.stdout.write('\nCommits:\n')
@@ -131,21 +144,21 @@ async function main(argv) {
131
144
 
132
145
  let created = 0
133
146
  for (const plan of activePlans) {
134
- const files = plan.files.filter((file) => fileHasChanges(cwd, file))
135
- if (!files.length) {
147
+ const planFiles = plan.files.filter((file) => fileHasChanges(cwd, file))
148
+ if (!planFiles.length) {
136
149
  process.stdout.write(`Skipping empty commit: ${plan.message}\n`)
137
150
  continue
138
151
  }
139
152
 
140
153
  // Drop leftover index entries so only this plan's files are committed.
141
154
  git(cwd, ['reset', '-q', 'HEAD'])
142
- git(cwd, ['add', '--', ...files])
155
+ stageFiles(cwd, planFiles)
143
156
  const staged = lines(cwd, ['diff', '--cached', '--name-only'])
144
- const stagedForPlan = files.filter((file) => staged.includes(file))
157
+ const stagedForPlan = planFiles.filter((file) => staged.includes(file))
145
158
  if (!stagedForPlan.length) {
146
159
  throw new Error(
147
160
  `Nothing staged for commit: ${plan.message}\n` +
148
- `Files: ${files.join(', ')}`,
161
+ `Files: ${planFiles.join(', ')}`,
149
162
  )
150
163
  }
151
164
 
@@ -159,6 +172,14 @@ async function main(argv) {
159
172
  created++
160
173
  }
161
174
  process.stdout.write(`Created ${created} commit(s).\n`)
175
+
176
+ const remaining = changedFiles.filter((file) => fileHasChanges(cwd, file))
177
+ if (remaining.length) {
178
+ process.stdout.write(
179
+ `\nUncommitted changes (${remaining.length}):\n` +
180
+ `${remaining.map((file) => ` ${file}`).join('\n')}\n`,
181
+ )
182
+ }
162
183
  }
163
184
 
164
185
  function parseArgs(argv) {
@@ -306,28 +327,42 @@ function readReleaseBranches(cwd) {
306
327
  }
307
328
 
308
329
  function listFiles(cwd, option) {
309
- const staged = lines(cwd, [
310
- 'diff',
311
- '--cached',
312
- '--name-only',
313
- '--diff-filter=ACDMR',
314
- ])
315
- if (option.staged || staged.length) return staged
330
+ if (option.staged) {
331
+ return lines(cwd, [
332
+ 'diff',
333
+ '--cached',
334
+ '--name-only',
335
+ '--diff-filter=ACDMR',
336
+ ])
337
+ }
316
338
  return [
317
339
  ...new Set([
340
+ ...lines(cwd, ['diff', '--cached', '--name-only', '--diff-filter=ACDMR']),
318
341
  ...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
319
342
  ...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
320
343
  ]),
321
344
  ]
322
345
  }
323
346
 
347
+ function isLockfile(file) {
348
+ return LOCKFILE_NAMES.has(path.basename(file))
349
+ }
350
+
351
+ function siblingManifest(file) {
352
+ const directory = path.posix.dirname(file)
353
+ return directory === '.' ? 'package.json' : `${directory}/package.json`
354
+ }
355
+
324
356
  function buildPrompt(scopes, types, files, cwd, type) {
325
- let diff = [
326
- out(cwd, ['diff', '--cached', '--', ...files]),
327
- out(cwd, ['diff', '--', ...files]),
328
- ]
329
- .filter(Boolean)
330
- .join('\n\n')
357
+ const statusByFile = new Map(
358
+ lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
359
+ (line) => [line.slice(3), line.slice(0, 2).trim()],
360
+ ),
361
+ )
362
+
363
+ const sourceFiles = files.filter((file) => !isLockfile(file))
364
+ const lockFiles = files.filter((file) => isLockfile(file))
365
+ let diff = buildSourceDiff(cwd, sourceFiles)
331
366
  if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
332
367
 
333
368
  return `Plan git commits. Do not edit files or run git commit.
@@ -337,18 +372,60 @@ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
337
372
  Subject rules:
338
373
  ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
339
374
  Prefer one commit when changes belong to the same feature.
340
- Split by scope. Each listed file in exactly one commit.
375
+ Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
376
+ Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
341
377
  Do not add Co-authored-by or Made-with trailers.
342
- ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
343
- Files:
344
- ${files.map((file) => `- ${file}`).join('\n')}
345
- status:
346
- ${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
347
- diff:
378
+ ${type ? `Prefer type "${type}".\n` : ''}Files:
379
+ ${files
380
+ .map((file) => {
381
+ const status =
382
+ statusByFile.get(file) || (isUntracked(cwd, file) ? '??' : ' M')
383
+ const lockNote = isLockfile(file) ? ', lockfile' : ''
384
+ return `- ${file} (${status}${lockNote})`
385
+ })
386
+ .join('\n')}
387
+ ${
388
+ lockFiles.length
389
+ ? `Lockfiles (no diff, keep with package.json):\n${lockFiles
390
+ .map((file) => `- ${file}`)
391
+ .join('\n')}\n`
392
+ : ''
393
+ }diff:
348
394
  ${diff || '(none)'}
349
395
  `
350
396
  }
351
397
 
398
+ function buildSourceDiff(cwd, files) {
399
+ const tracked = []
400
+ const untracked = []
401
+ for (const file of files) {
402
+ if (isUntracked(cwd, file)) untracked.push(file)
403
+ else tracked.push(file)
404
+ }
405
+
406
+ const parts = []
407
+ if (tracked.length) {
408
+ const text = out(cwd, ['diff', 'HEAD', '--', ...tracked])
409
+ if (text) parts.push(text)
410
+ }
411
+ for (const file of untracked) {
412
+ const piece = buildUntrackedDiff(cwd, file)
413
+ if (piece) parts.push(piece)
414
+ }
415
+ return parts.join('\n\n')
416
+ }
417
+
418
+ function buildUntrackedDiff(cwd, file) {
419
+ const filePath = path.join(cwd, file)
420
+ if (!fs.existsSync(filePath)) return ''
421
+
422
+ const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'
423
+ const untrackedDiff = out(cwd, ['diff', '--no-index', nullPath, file])
424
+ return untrackedDiff
425
+ ? `new file ${file}:\n${untrackedDiff}`
426
+ : `new file ${file}: (empty)`
427
+ }
428
+
352
429
  function buildAgentProgressSteps(fileCount) {
353
430
  const fileLabel =
354
431
  fileCount === 1 ? '1 changed file' : `${fileCount} changed files`
@@ -555,12 +632,175 @@ function parsePlan(raw, allowedFiles) {
555
632
  }
556
633
 
557
634
  if (!plans.length) throw new Error(`Empty commit plan:\n${raw}`)
558
- const missing = allowedFiles.filter((file) => !seen.has(file))
559
- if (missing.length)
560
- throw new Error(`Cursor omitted files:\n${missing.join('\n')}`)
561
635
  return plans
562
636
  }
563
637
 
638
+ function tryFastPlan(files, cwd, scopes, types) {
639
+ const sourceFiles = files.filter((file) => !isLockfile(file))
640
+ if (sourceFiles.length !== 1) return null
641
+ if (path.basename(sourceFiles[0]) !== 'package.json') return null
642
+
643
+ const message = inferDepsMessage(cwd, sourceFiles[0], scopes, types)
644
+ if (!message) return null
645
+
646
+ return [
647
+ {
648
+ message: message,
649
+ files: [...sourceFiles, ...files.filter(isLockfile)],
650
+ },
651
+ ]
652
+ }
653
+
654
+ function inferDepsMessage(cwd, file, scopes, types) {
655
+ const type = types.includes('deps')
656
+ ? 'deps'
657
+ : types.includes('chore')
658
+ ? 'chore'
659
+ : ''
660
+ if (!type) return ''
661
+
662
+ const names = changedDependencyNames(cwd, file)
663
+ if (!names?.length) return ''
664
+
665
+ const scope = pickScope(scopes, ['packages', 'deps', 'other'])
666
+ const subject =
667
+ names.length === 1
668
+ ? `bump ${names[0]}`
669
+ : names.length <= 3
670
+ ? `bump ${names.join(', ')}`
671
+ : 'bump dependencies'
672
+ return `${type}(${scope}): ${subject}`
673
+ }
674
+
675
+ function changedDependencyNames(cwd, file) {
676
+ const current = readJsonFile(path.join(cwd, file))
677
+ if (!current) return null
678
+
679
+ let previous = {}
680
+ const shown = spawnSync('git', ['show', `HEAD:${file}`], {
681
+ cwd: cwd,
682
+ encoding: 'utf8',
683
+ })
684
+ if (!shown.status && shown.stdout) {
685
+ try {
686
+ previous = JSON.parse(shown.stdout)
687
+ } catch {
688
+ return null
689
+ }
690
+ } else if (isUntracked(cwd, file)) {
691
+ return null
692
+ }
693
+
694
+ const sections = [
695
+ 'dependencies',
696
+ 'devDependencies',
697
+ 'optionalDependencies',
698
+ 'peerDependencies',
699
+ ]
700
+ const names = []
701
+ for (const key of sections) {
702
+ const before = previous[key] || {}
703
+ const after = current[key] || {}
704
+ for (const name of new Set([
705
+ ...Object.keys(before),
706
+ ...Object.keys(after),
707
+ ])) {
708
+ if (before[name] !== after[name]) names.push(name)
709
+ }
710
+ }
711
+
712
+ const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
713
+ for (const key of keys) {
714
+ if (sections.includes(key)) continue
715
+ if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
716
+ return null
717
+ }
718
+ }
719
+
720
+ return names.length ? names : null
721
+ }
722
+
723
+ function readJsonFile(filePath) {
724
+ try {
725
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
726
+ } catch {
727
+ return null
728
+ }
729
+ }
730
+
731
+ function pickScope(scopes, preferred) {
732
+ const list = buildScopeEnum(scopes)
733
+ return preferred.find((scope) => list.includes(scope)) || list[0] || 'other'
734
+ }
735
+
736
+ function assignOmittedFiles(plans, allowedFiles) {
737
+ const assigned = new Set(plans.flatMap((plan) => plan.files))
738
+ const missing = allowedFiles.filter((file) => !assigned.has(file))
739
+ if (!missing.length) return plans
740
+
741
+ for (const file of missing) {
742
+ const target = findBestCommitForFile(plans, file)
743
+ target.files.push(file)
744
+ process.stderr.write(
745
+ `Auto-assigned omitted file to commit: ${file} → ${target.message}\n`,
746
+ )
747
+ }
748
+ return plans
749
+ }
750
+
751
+ function attachLockfiles(plans, allowedFiles) {
752
+ const locks = allowedFiles.filter((file) => isLockfile(file))
753
+ if (!locks.length) return plans
754
+
755
+ for (const lock of locks) {
756
+ for (const plan of plans) {
757
+ plan.files = plan.files.filter((file) => file !== lock)
758
+ }
759
+ const manifest = siblingManifest(lock)
760
+ const withManifest = plans.find((plan) => plan.files.includes(manifest))
761
+ const target = withManifest || findBestCommitForFile(plans, lock)
762
+ target.files.push(lock)
763
+ }
764
+
765
+ return plans.filter((plan) => plan.files.length)
766
+ }
767
+
768
+ function findBestCommitForFile(plans, file) {
769
+ const fileDir = path.dirname(file)
770
+
771
+ const sameDir = plans.find((plan) =>
772
+ plan.files.some((candidate) => path.dirname(candidate) === fileDir),
773
+ )
774
+ if (sameDir) return sameDir
775
+
776
+ let best = plans[0]
777
+ let bestScore = -1
778
+ for (const plan of plans) {
779
+ for (const candidate of plan.files) {
780
+ const score = commonPathPrefixLength(candidate, file)
781
+ if (score > bestScore) {
782
+ bestScore = score
783
+ best = plan
784
+ }
785
+ }
786
+ }
787
+ return best
788
+ }
789
+
790
+ function commonPathPrefixLength(left, right) {
791
+ const leftParts = left.split('/')
792
+ const rightParts = right.split('/')
793
+ let index = 0
794
+ while (
795
+ index < leftParts.length &&
796
+ index < rightParts.length &&
797
+ leftParts[index] === rightParts[index]
798
+ ) {
799
+ index++
800
+ }
801
+ return index
802
+ }
803
+
564
804
  function normalizeCommitMessage(message) {
565
805
  const lines = String(message || '')
566
806
  .split('\n')
@@ -595,17 +835,25 @@ async function validateAndNormalizePlans(cwd, plans) {
595
835
  }
596
836
  }
597
837
 
598
- function fileHasChanges(cwd, file) {
599
- const filePath = path.join(cwd, file)
600
- if (!fs.existsSync(filePath)) return false
601
-
602
- const tracked =
838
+ function isTracked(cwd, file) {
839
+ return (
603
840
  spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
604
841
  cwd: cwd,
605
842
  encoding: 'utf8',
606
843
  }).status === 0
844
+ )
845
+ }
846
+
847
+ function isUntracked(cwd, file) {
848
+ return !isTracked(cwd, file) && fs.existsSync(path.join(cwd, file))
849
+ }
850
+
851
+ function fileHasChanges(cwd, file) {
852
+ const filePath = path.join(cwd, file)
853
+ const tracked = isTracked(cwd, file)
607
854
 
608
- if (!tracked) return true
855
+ if (!tracked) return fs.existsSync(filePath)
856
+ if (!fs.existsSync(filePath)) return true
609
857
 
610
858
  return (
611
859
  spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
@@ -615,6 +863,11 @@ function fileHasChanges(cwd, file) {
615
863
  )
616
864
  }
617
865
 
866
+ function stageFiles(cwd, files) {
867
+ if (!files.length) return
868
+ git(cwd, ['add', '-A', '--', ...files])
869
+ }
870
+
618
871
  async function confirm(count) {
619
872
  if (!input.isTTY) throw new Error('No TTY — use --yes or --dry-run')
620
873
  const rl = readline.createInterface({ input, output })
@@ -696,6 +949,8 @@ function git(cwd, args, option = {}) {
696
949
  stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
697
950
  })
698
951
  if (!result.status) return result
952
+ // git diff exits 1 when there are differences
953
+ if (result.status === 1 && args[0] === 'diff') return result
699
954
 
700
955
  const stderr = result.stderr || ''
701
956
  if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) {