@itcase/config 1.6.78 → 1.6.80

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.78",
3
+ "version": "1.6.80",
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
@@ -77,24 +85,28 @@ async function main(argv) {
77
85
  return
78
86
  }
79
87
 
80
- const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
81
- const raw = await withAgentProgress(
82
- {
83
- fileCount: changedFiles.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
- let plans = parsePlan(raw, changedFiles)
97
- plans = assignOmittedFiles(plans, changedFiles)
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
+ }
98
110
  await validateAndNormalizePlans(cwd, plans)
99
111
 
100
112
  process.stdout.write('\nCommits:\n')
@@ -130,14 +142,25 @@ async function main(argv) {
130
142
 
131
143
  ensureGitIndexReady(cwd)
132
144
 
145
+ const total = activePlans.length
146
+ process.stdout.write(`Creating ${total} commit(s)…\n`)
147
+
133
148
  let created = 0
134
- for (const plan of activePlans) {
149
+ for (let index = 0; index < total; index++) {
150
+ const plan = activePlans[index]
151
+ const step = `[${index + 1}/${total}]`
135
152
  const planFiles = plan.files.filter((file) => fileHasChanges(cwd, file))
136
153
  if (!planFiles.length) {
137
- process.stdout.write(`Skipping empty commit: ${plan.message}\n`)
154
+ process.stdout.write(`${step} skip empty: ${plan.message}\n`)
138
155
  continue
139
156
  }
140
157
 
158
+ const startedAt = Date.now()
159
+ const fileLabel =
160
+ planFiles.length === 1 ? '1 file' : `${planFiles.length} files`
161
+ process.stdout.write(`${step} ${plan.message}\n`)
162
+ process.stdout.write(` staging ${fileLabel}…\n`)
163
+
141
164
  // Drop leftover index entries so only this plan's files are committed.
142
165
  git(cwd, ['reset', '-q', 'HEAD'])
143
166
  stageFiles(cwd, planFiles)
@@ -150,6 +173,7 @@ async function main(argv) {
150
173
  )
151
174
  }
152
175
 
176
+ process.stdout.write(' git commit (husky)…\n')
153
177
  try {
154
178
  git(cwd, ['commit', '-m', plan.message], { inherit: true })
155
179
  } catch (error) {
@@ -158,6 +182,7 @@ async function main(argv) {
158
182
  })
159
183
  }
160
184
  created++
185
+ process.stdout.write(` done (${formatElapsed(startedAt)})\n`)
161
186
  }
162
187
  process.stdout.write(`Created ${created} commit(s).\n`)
163
188
 
@@ -332,6 +357,15 @@ function listFiles(cwd, option) {
332
357
  ]
333
358
  }
334
359
 
360
+ function isLockfile(file) {
361
+ return LOCKFILE_NAMES.has(path.basename(file))
362
+ }
363
+
364
+ function siblingManifest(file) {
365
+ const directory = path.posix.dirname(file)
366
+ return directory === '.' ? 'package.json' : `${directory}/package.json`
367
+ }
368
+
335
369
  function buildPrompt(scopes, types, files, cwd, type) {
336
370
  const statusByFile = new Map(
337
371
  lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
@@ -339,10 +373,9 @@ function buildPrompt(scopes, types, files, cwd, type) {
339
373
  ),
340
374
  )
341
375
 
342
- let diff = files
343
- .map((file) => buildFileDiff(cwd, file))
344
- .filter(Boolean)
345
- .join('\n\n')
376
+ const sourceFiles = files.filter((file) => !isLockfile(file))
377
+ const lockFiles = files.filter((file) => isLockfile(file))
378
+ let diff = buildSourceDiff(cwd, sourceFiles)
346
379
  if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
347
380
 
348
381
  return `Plan git commits. Do not edit files or run git commit.
@@ -353,46 +386,57 @@ Subject rules:
353
386
  ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
354
387
  Prefer one commit when changes belong to the same feature.
355
388
  Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
389
+ Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
356
390
  Do not add Co-authored-by or Made-with trailers.
357
- ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
358
- Files:
391
+ ${type ? `Prefer type "${type}".\n` : ''}Files:
359
392
  ${files
360
393
  .map((file) => {
361
394
  const status =
362
395
  statusByFile.get(file) || (isUntracked(cwd, file) ? '??' : ' M')
363
- return `- ${file} (${status})`
396
+ const lockNote = isLockfile(file) ? ', lockfile' : ''
397
+ return `- ${file} (${status}${lockNote})`
364
398
  })
365
399
  .join('\n')}
366
- status:
367
- ${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
368
- diff:
400
+ ${
401
+ lockFiles.length
402
+ ? `Lockfiles (no diff, keep with package.json):\n${lockFiles
403
+ .map((file) => `- ${file}`)
404
+ .join('\n')}\n`
405
+ : ''
406
+ }diff:
369
407
  ${diff || '(none)'}
370
408
  `
371
409
  }
372
410
 
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
- )
411
+ function buildSourceDiff(cwd, files) {
412
+ const tracked = []
413
+ const untracked = []
414
+ for (const file of files) {
415
+ if (isUntracked(cwd, file)) untracked.push(file)
416
+ else tracked.push(file)
380
417
  }
381
418
 
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)`
419
+ const parts = []
420
+ if (tracked.length) {
421
+ const text = out(cwd, ['diff', 'HEAD', '--', ...tracked])
422
+ if (text) parts.push(text)
388
423
  }
424
+ for (const file of untracked) {
425
+ const piece = buildUntrackedDiff(cwd, file)
426
+ if (piece) parts.push(piece)
427
+ }
428
+ return parts.join('\n\n')
429
+ }
389
430
 
390
- return [
391
- out(cwd, ['diff', '--cached', '--', file]),
392
- out(cwd, ['diff', '--', file]),
393
- ]
394
- .filter(Boolean)
395
- .join('\n')
431
+ function buildUntrackedDiff(cwd, file) {
432
+ const filePath = path.join(cwd, file)
433
+ if (!fs.existsSync(filePath)) return ''
434
+
435
+ const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'
436
+ const untrackedDiff = out(cwd, ['diff', '--no-index', nullPath, file])
437
+ return untrackedDiff
438
+ ? `new file ${file}:\n${untrackedDiff}`
439
+ : `new file ${file}: (empty)`
396
440
  }
397
441
 
398
442
  function buildAgentProgressSteps(fileCount) {
@@ -604,6 +648,104 @@ function parsePlan(raw, allowedFiles) {
604
648
  return plans
605
649
  }
606
650
 
651
+ function tryFastPlan(files, cwd, scopes, types) {
652
+ const sourceFiles = files.filter((file) => !isLockfile(file))
653
+ if (sourceFiles.length !== 1) return null
654
+ if (path.basename(sourceFiles[0]) !== 'package.json') return null
655
+
656
+ const message = inferDepsMessage(cwd, sourceFiles[0], scopes, types)
657
+ if (!message) return null
658
+
659
+ return [
660
+ {
661
+ message: message,
662
+ files: [...sourceFiles, ...files.filter(isLockfile)],
663
+ },
664
+ ]
665
+ }
666
+
667
+ function inferDepsMessage(cwd, file, scopes, types) {
668
+ const type = types.includes('deps')
669
+ ? 'deps'
670
+ : types.includes('chore')
671
+ ? 'chore'
672
+ : ''
673
+ if (!type) return ''
674
+
675
+ const names = changedDependencyNames(cwd, file)
676
+ if (!names?.length) return ''
677
+
678
+ const scope = pickScope(scopes, ['packages', 'deps', 'other'])
679
+ const subject =
680
+ names.length === 1
681
+ ? `bump ${names[0]}`
682
+ : names.length <= 3
683
+ ? `bump ${names.join(', ')}`
684
+ : 'bump dependencies'
685
+ return `${type}(${scope}): ${subject}`
686
+ }
687
+
688
+ function changedDependencyNames(cwd, file) {
689
+ const current = readJsonFile(path.join(cwd, file))
690
+ if (!current) return null
691
+
692
+ let previous = {}
693
+ const shown = spawnSync('git', ['show', `HEAD:${file}`], {
694
+ cwd: cwd,
695
+ encoding: 'utf8',
696
+ })
697
+ if (!shown.status && shown.stdout) {
698
+ try {
699
+ previous = JSON.parse(shown.stdout)
700
+ } catch {
701
+ return null
702
+ }
703
+ } else if (isUntracked(cwd, file)) {
704
+ return null
705
+ }
706
+
707
+ const sections = [
708
+ 'dependencies',
709
+ 'devDependencies',
710
+ 'optionalDependencies',
711
+ 'peerDependencies',
712
+ ]
713
+ const names = []
714
+ for (const key of sections) {
715
+ const before = previous[key] || {}
716
+ const after = current[key] || {}
717
+ for (const name of new Set([
718
+ ...Object.keys(before),
719
+ ...Object.keys(after),
720
+ ])) {
721
+ if (before[name] !== after[name]) names.push(name)
722
+ }
723
+ }
724
+
725
+ const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
726
+ for (const key of keys) {
727
+ if (sections.includes(key)) continue
728
+ if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
729
+ return null
730
+ }
731
+ }
732
+
733
+ return names.length ? names : null
734
+ }
735
+
736
+ function readJsonFile(filePath) {
737
+ try {
738
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
739
+ } catch {
740
+ return null
741
+ }
742
+ }
743
+
744
+ function pickScope(scopes, preferred) {
745
+ const list = buildScopeEnum(scopes)
746
+ return preferred.find((scope) => list.includes(scope)) || list[0] || 'other'
747
+ }
748
+
607
749
  function assignOmittedFiles(plans, allowedFiles) {
608
750
  const assigned = new Set(plans.flatMap((plan) => plan.files))
609
751
  const missing = allowedFiles.filter((file) => !assigned.has(file))
@@ -619,6 +761,23 @@ function assignOmittedFiles(plans, allowedFiles) {
619
761
  return plans
620
762
  }
621
763
 
764
+ function attachLockfiles(plans, allowedFiles) {
765
+ const locks = allowedFiles.filter((file) => isLockfile(file))
766
+ if (!locks.length) return plans
767
+
768
+ for (const lock of locks) {
769
+ for (const plan of plans) {
770
+ plan.files = plan.files.filter((file) => file !== lock)
771
+ }
772
+ const manifest = siblingManifest(lock)
773
+ const withManifest = plans.find((plan) => plan.files.includes(manifest))
774
+ const target = withManifest || findBestCommitForFile(plans, lock)
775
+ target.files.push(lock)
776
+ }
777
+
778
+ return plans.filter((plan) => plan.files.length)
779
+ }
780
+
622
781
  function findBestCommitForFile(plans, file) {
623
782
  const fileDir = path.dirname(file)
624
783
 
@@ -741,6 +900,11 @@ function sleepSync(ms) {
741
900
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
742
901
  }
743
902
 
903
+ function formatElapsed(startedAt) {
904
+ const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
905
+ return `${elapsed}s`
906
+ }
907
+
744
908
  function indexLockPath(cwd) {
745
909
  const result = spawnSync('git', ['rev-parse', '--git-path', 'index.lock'], {
746
910
  cwd: cwd,
@@ -803,6 +967,8 @@ function git(cwd, args, option = {}) {
803
967
  stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
804
968
  })
805
969
  if (!result.status) return result
970
+ // git diff exits 1 when there are differences
971
+ if (result.status === 1 && args[0] === 'diff') return result
806
972
 
807
973
  const stderr = result.stderr || ''
808
974
  if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) {