@itcase/config 1.7.10 → 1.7.12

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.
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs'
2
- import { join, relative } from 'node:path'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
3
4
 
4
5
  const FORBIDDEN_SPEC = /^(file:|link:)/i
5
6
 
@@ -94,37 +95,54 @@ function collectHits(pkg) {
94
95
  return hits
95
96
  }
96
97
 
97
- const cliFiles = process.argv.slice(2)
98
- const files = cliFiles.length ? cliFiles : findPackageJsonFiles(process.cwd())
98
+ function findForbiddenDependencyHits(files) {
99
+ const targets = files?.length ? files : findPackageJsonFiles(process.cwd())
100
+ const hitsByFile = []
99
101
 
100
- const hitsByFile = []
102
+ for (const file of targets) {
103
+ const pkg = readPackageJson(file)
104
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) continue
101
105
 
102
- for (const file of files) {
103
- const pkg = readPackageJson(file)
104
- if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) continue
105
-
106
- const hits = collectHits(pkg)
107
- if (hits.length) {
108
- hitsByFile.push({ file, hits })
106
+ const hits = collectHits(pkg)
107
+ if (hits.length) {
108
+ hitsByFile.push({ file, hits })
109
+ }
109
110
  }
111
+
112
+ return hitsByFile
110
113
  }
111
114
 
112
- if (!hitsByFile.length) {
113
- process.exit(0)
115
+ function formatFileDependencyReport(hitsByFile) {
116
+ const lines = [
117
+ '❌ Локальные зависимости file: / link: нельзя коммитить.',
118
+ ' Замените их на версию из npm registry.',
119
+ '',
120
+ ]
121
+
122
+ for (const { file, hits } of hitsByFile) {
123
+ lines.push(` ${relative(process.cwd(), file)}`)
124
+ for (const { locator, spec } of hits) {
125
+ lines.push(` ${locator}: ${spec}`)
126
+ }
127
+ }
128
+
129
+ return lines.join('\n')
114
130
  }
115
131
 
116
- const lines = [
117
- '❌ Локальные зависимости file: / link: нельзя коммитить.',
118
- ' Замените их на версию из npm registry.',
119
- '',
120
- ]
132
+ function isDirectCli() {
133
+ const entry = process.argv[1]
134
+ if (!entry) return false
135
+ return import.meta.url === pathToFileURL(resolve(entry)).href
136
+ }
121
137
 
122
- for (const { file, hits } of hitsByFile) {
123
- lines.push(` ${relative(process.cwd(), file)}`)
124
- for (const { locator, spec } of hits) {
125
- lines.push(` ${locator}: ${spec}`)
138
+ if (isDirectCli()) {
139
+ const hitsByFile = findForbiddenDependencyHits(process.argv.slice(2))
140
+ if (!hitsByFile.length) {
141
+ process.exit(0)
126
142
  }
143
+
144
+ process.stderr.write(`${formatFileDependencyReport(hitsByFile)}\n`)
145
+ process.exit(1)
127
146
  }
128
147
 
129
- process.stderr.write(`${lines.join('\n')}\n`)
130
- process.exit(1)
148
+ export { findForbiddenDependencyHits, formatFileDependencyReport }
@@ -1,22 +1,36 @@
1
+ /* eslint-disable perfectionist/sort-objects */
2
+
1
3
  import { existsSync } from 'node:fs'
2
- import { dirname, join, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
+ import { resolve } from 'node:path'
4
5
 
5
- const checkNoFileDependencies = join(
6
- dirname(fileURLToPath(import.meta.url)),
7
- 'checkNoFileDependencies.js',
8
- )
6
+ import {
7
+ findForbiddenDependencyHits,
8
+ formatFileDependencyReport,
9
+ } from './checkNoFileDependencies.js'
9
10
 
10
11
  function existingFiles(files) {
11
12
  return files.filter((file) => existsSync(file))
12
13
  }
13
14
 
15
+ // Команды запускаются без shell (string-argv): каждый аргумент в кавычках,
16
+ // иначе путь с пробелом разрежется. npx не нужен — lint-staged берёт бинарь из node_modules/.bin.
17
+ function commandLine(parts) {
18
+ return parts.map((part) => JSON.stringify(part)).join(' ')
19
+ }
20
+
14
21
  function eslintCommand(files) {
15
- return `npx eslint --fix --color ${files.join(' ')}`
22
+ return commandLine(['eslint', '--fix', '--color', ...files])
16
23
  }
17
24
 
18
25
  function stylelintCommand(files) {
19
- return `npx stylelint --fix --color --formatter verbose ${files.join(' ')}`
26
+ return commandLine([
27
+ 'stylelint',
28
+ '--fix',
29
+ '--color',
30
+ '--formatter',
31
+ 'verbose',
32
+ ...files,
33
+ ])
20
34
  }
21
35
 
22
36
  function packageJsonCommand(files) {
@@ -24,10 +38,12 @@ function packageJsonCommand(files) {
24
38
  const rootPackageJson = resolve(process.cwd(), 'package.json')
25
39
  const isRoot = files.some((file) => resolve(file) === rootPackageJson)
26
40
  if (existsSync(rootPackageJson) && isRoot) {
27
- // lint-staged автоматически добавляет изменения задач в index
41
+ // С lint-staged 17.6 задача стейджит все tracked-файлы, которые она изменила,
42
+ // не только совпавшие с glob. package-lock.json попадёт в коммит без git add
43
+ // (git add в команде lint-staged просит убрать).
28
44
  return 'npm install --package-lock-only'
29
45
  }
30
- return 'true' // Пропускаем, если не найден
46
+ return []
31
47
  }
32
48
 
33
49
  /**
@@ -43,9 +59,25 @@ function createLintStagedConfig({
43
59
  typecheckCommand = 'tsc --noEmit --pretty',
44
60
  } = {}) {
45
61
  return {
46
- // На любой staged-файл: если в package.json есть file:/link: — exit 1, коммит не пройдёт
47
- '*': () => `node ${JSON.stringify(checkNoFileDependencies)}`,
48
- '*.(js|jsx|ts|tsx)': (files) => {
62
+ // На любой staged-файл: если в package.json есть file:/link: — задача падает, коммит не пройдёт.
63
+ // Function task (lint-staged 16+, log() с 17.6): без отдельного node-процесса.
64
+ // До 17.6 второй аргумент не передаётся — деструктуризация { log } роняла задачу
65
+ // и lint-staged убивал параллельный eslint (Task killed).
66
+ '*': {
67
+ title: 'check file: dependencies',
68
+ task: async (_filepaths, context) => {
69
+ const hitsByFile = findForbiddenDependencyHits()
70
+ if (!hitsByFile.length) return
71
+
72
+ const report = formatFileDependencyReport(hitsByFile)
73
+ if (typeof context?.log === 'function') context.log('%s', report)
74
+ else process.stderr.write(`${report}\n`)
75
+ throw new Error('Локальные зависимости file: / link: нельзя коммитить.')
76
+ },
77
+ },
78
+ // picomatch (lint-staged 16.4+) раскрывает brace-glob.
79
+ // tsc только для .ts/.tsx; .js/.jsx/.mjs/.cjs идут в eslint.
80
+ '*.{js,jsx,mjs,cjs,ts,tsx}': (files) => {
49
81
  const existing = existingFiles(files)
50
82
  const tsFiles = existing.filter((file) => /\.(ts|tsx)$/.test(file))
51
83
  const commands = []
@@ -58,16 +90,16 @@ function createLintStagedConfig({
58
90
  commands.push(eslintCommand(existing))
59
91
  }
60
92
 
61
- return commands.length ? commands : 'true'
93
+ return commands
62
94
  },
63
95
  '*.css': (files) => {
64
96
  const existing = existingFiles(files)
65
- if (!existing.length) return 'true'
97
+ if (!existing.length) return []
66
98
  return stylelintCommand(existing)
67
99
  },
68
100
  '*.mdx': (files) => {
69
101
  const existing = existingFiles(files)
70
- if (!existing.length) return 'true'
102
+ if (!existing.length) return []
71
103
  return eslintCommand(existing)
72
104
  },
73
105
  'package.json': packageJsonCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.7.10",
3
+ "version": "1.7.12",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -43,11 +43,11 @@
43
43
  "@commitlint/config-conventional": "^21.2.3",
44
44
  "@commitlint/cz-commitlint": "^21.2.3",
45
45
  "@lehoczky/postcss-fluid": "^1.0.3",
46
- "@lerna-lite/cli": "^5.6.1",
47
- "@lerna-lite/exec": "^5.6.1",
48
- "@lerna-lite/list": "^5.6.1",
49
- "@lerna-lite/run": "^5.6.1",
50
- "@lerna-lite/watch": "^5.6.1",
46
+ "@lerna-lite/cli": "^5.6.2",
47
+ "@lerna-lite/exec": "^5.6.2",
48
+ "@lerna-lite/list": "^5.6.2",
49
+ "@lerna-lite/run": "^5.6.2",
50
+ "@lerna-lite/watch": "^5.6.2",
51
51
  "@rollup/plugin-alias": "^6.0.0",
52
52
  "@rollup/plugin-babel": "^7.1.0",
53
53
  "@rollup/plugin-commonjs": "^29.0.3",
@@ -68,8 +68,8 @@
68
68
  "commitizen": "^4.3.2",
69
69
  "conventional-changelog-conventionalcommits": "9.3.1",
70
70
  "conventional-changelog-writer": "9.2.1",
71
- "cssnano": "^9.0.5",
72
- "cssnano-preset-default": "^9.0.5",
71
+ "cssnano": "^9.1.0",
72
+ "cssnano-preset-default": "^9.1.0",
73
73
  "inquirer": "^14.2.2",
74
74
  "log-update": "^8.0.0",
75
75
  "postcss": "^8.5.28",
@@ -80,7 +80,7 @@
80
80
  "postcss-combine-duplicated-selectors": "^10.0.3",
81
81
  "postcss-convert-unit": "^0.1.3",
82
82
  "postcss-dark-theme-class": "^2.0.0",
83
- "postcss-discard-duplicates": "^9.0.3",
83
+ "postcss-discard-duplicates": "^9.0.4",
84
84
  "postcss-each": "^1.1.0",
85
85
  "postcss-easings": "^4.0.0",
86
86
  "postcss-extend-rule": "^4.0.0",
@@ -96,7 +96,7 @@
96
96
  "postcss-nested-ancestors": "^3.0.0",
97
97
  "postcss-normalize": "^13.0.1",
98
98
  "postcss-prepend-imports": "^1.0.1",
99
- "postcss-preset-env": "^11.5.3",
99
+ "postcss-preset-env": "^11.5.4",
100
100
  "postcss-pxtorem": "^6.1.0",
101
101
  "postcss-sort-media-queries": "^6.7.1",
102
102
  "postcss-unit": "^0.0.3",
@@ -118,8 +118,8 @@
118
118
  "eslint": "9.39.2",
119
119
  "husky": "^9.1.7",
120
120
  "inquirer": "^14.2.2",
121
- "lint-staged": "^17.5.1",
122
- "prettier": "^3.9.8",
121
+ "lint-staged": "^17.6.0",
122
+ "prettier": "^3.9.9",
123
123
  "storybook": "^10.6.0",
124
124
  "stylelint": "^17.15.0",
125
125
  "typescript": "^6.0.3"
@@ -18,8 +18,9 @@ const releaseNotesFromIssuesScript = join(
18
18
  )
19
19
 
20
20
  // Ссылки на задачи из PR.
21
- // Pull request'ы текущего репозитория читает GITHUB_TOKEN.
22
- // Задачи другого репозитория читает RELEASE_NOTES_GH_TOKEN.
21
+ // RELEASE_NOTES_PULLS_REPO — репозиторий pull request'ов, если это не репозиторий Actions.
22
+ // Его и задачи читает RELEASE_NOTES_GH_TOKEN.
23
+ // Без RELEASE_NOTES_PULLS_REPO pull request'ы читает GITHUB_TOKEN в текущем репозитории.
23
24
  // RELEASE_NOTES_ISSUES_REPO — репозиторий задач.
24
25
  const releaseNotesFromIssuesPlugin = [
25
26
  '@semantic-release/exec',
@@ -14,15 +14,22 @@ import { join } from 'node:path'
14
14
  */
15
15
  const [lastTagArg, nextTagArg] = process.argv.slice(2)
16
16
 
17
- // Pull requests of this repository. In GitHub Actions this is the workflow token.
17
+ // Pull requests of the repository where Actions runs.
18
+ // Used only when RELEASE_NOTES_PULLS_REPO is not set.
18
19
  const repositoryToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN
19
20
 
20
- // Issues live in another repository. The workflow token cannot read them.
21
+ // Issues and, when configured, pull requests of another repository.
22
+ // The workflow token cannot read them.
21
23
  const issuesToken = process.env.RELEASE_NOTES_GH_TOKEN
22
24
 
23
25
  const currentProjectOwner = process.env.GITHUB_REPOSITORY?.split('/')[0]
24
26
  const currentProjectRepo = process.env.GITHUB_REPOSITORY?.split('/')[1]
25
27
 
28
+ // Repository with pull requests, when it is not the Actions repository.
29
+ const releaseNotesPullsRepo = process.env.RELEASE_NOTES_PULLS_REPO
30
+ const pullsProjectOwner = releaseNotesPullsRepo?.split('/')[0]
31
+ const pullsProjectRepo = releaseNotesPullsRepo?.split('/')[1]
32
+
26
33
  // Repository with all issues. Not this project repository.
27
34
  // Used when PR text contains "Issue: #123".
28
35
  const releaseNotesIssuesRepo = process.env.RELEASE_NOTES_ISSUES_REPO
@@ -30,8 +37,24 @@ const releaseNotesIssuesRepo = process.env.RELEASE_NOTES_ISSUES_REPO
30
37
  const issuesProjectOwner = releaseNotesIssuesRepo?.split('/')[0]
31
38
  const issuesProjectRepo = releaseNotesIssuesRepo?.split('/')[1]
32
39
 
33
- if (!currentProjectOwner || !currentProjectRepo || !repositoryToken) {
34
- throw new Error('Missing "GITHUB_REPOSITORY" or "GITHUB_TOKEN"')
40
+ const pullRequestOwner = pullsProjectOwner || currentProjectOwner
41
+ const pullRequestRepo = pullsProjectRepo || currentProjectRepo
42
+ const pullRequestToken = releaseNotesPullsRepo ? issuesToken : repositoryToken
43
+
44
+ if (!currentProjectOwner || !currentProjectRepo) {
45
+ throw new Error('Missing "GITHUB_REPOSITORY"')
46
+ }
47
+
48
+ if (releaseNotesPullsRepo && (!pullsProjectOwner || !pullsProjectRepo)) {
49
+ throw new Error('Invalid "RELEASE_NOTES_PULLS_REPO". Expected "owner/repo"')
50
+ }
51
+
52
+ if (!pullRequestToken) {
53
+ throw new Error(
54
+ releaseNotesPullsRepo
55
+ ? 'Missing "RELEASE_NOTES_GH_TOKEN"'
56
+ : 'Missing "GITHUB_TOKEN"',
57
+ )
35
58
  }
36
59
 
37
60
  if (!issuesToken) {
@@ -433,11 +456,24 @@ function getGitCommitsInRange(fromRef, toRef) {
433
456
  async function getAssociatedPullRequestsForCommit(commitSha) {
434
457
  // GitHub endpoint that returns PRs linked to a commit SHA.
435
458
  // Works for both merge-commit and regular commits associated with PRs.
436
- const response = await fetchGithubApi(
437
- `/repos/${currentProjectOwner}/${currentProjectRepo}/commits/${commitSha}/pulls`,
438
- repositoryToken,
439
- )
440
- return Array.isArray(response) ? response : []
459
+ // The commit has to exist in pullRequestOwner/pullRequestRepo.
460
+ // A SHA from another git history is not a pull request there.
461
+ try {
462
+ const response = await fetchGithubApi(
463
+ `/repos/${pullRequestOwner}/${pullRequestRepo}/commits/${commitSha}/pulls`,
464
+ pullRequestToken,
465
+ )
466
+ return Array.isArray(response) ? response : []
467
+ } catch (error) {
468
+ if (String(error?.message).includes('(422)')) {
469
+ console.error(
470
+ `[release-notes] Commit ${commitSha} is not in ${pullRequestOwner}/${pullRequestRepo}`,
471
+ )
472
+ return []
473
+ }
474
+
475
+ throw error
476
+ }
441
477
  }
442
478
 
443
479
  async function loadIssuesInBatches(refs) {
@@ -25,6 +25,10 @@ const CLI_RELATIVE = 'smart-commit/cli.js'
25
25
  const DEFAULT_MODEL = 'composer-2.5'
26
26
 
27
27
  const MAX_DIFF = 80_000
28
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024
29
+ const GIT_PATH_BATCH = 40
30
+ const PLAN_FILE_BATCH = 40
31
+ const ARG_SAFE = 100_000
28
32
  const LOCKFILE_NAMES = new Set([
29
33
  'package-lock.json',
30
34
  'npm-shrinkwrap.json',
@@ -37,6 +41,9 @@ const AGENT_STEP_MS = 2_500
37
41
  const AGENT_STILL_WORKING_S = 120
38
42
  const AGENT_LOG_ROWS = 8
39
43
  const AGENT_LOG_MAX = 160
44
+ const HOOK_LOG_ROWS = 6
45
+ const SPIN_MS = 80
46
+ const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
40
47
  const NYAN_FRAME_MS = 90
41
48
  const BANANA_FRAME_MS = 100
42
49
  const UPYACHKA_FRAME_MS = 100
@@ -99,27 +106,18 @@ async function main(argv) {
99
106
 
100
107
  let plans = tryFastPlan(changedFiles, cwd, scopes, types)
101
108
  if (!plans) {
102
- const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
103
- const raw = await withAgentProgress(
104
- {
105
- fileCount: changedFiles.length,
106
- sprite: pickSprite(option.sprite),
107
- verbose: option.verbose,
108
- },
109
- (progress) =>
110
- askAgent(
111
- cwd,
112
- option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
113
- prompt,
114
- option.verbose,
115
- progress.onAgentActivity,
116
- ),
117
- )
118
- plans = parsePlan(raw, changedFiles)
109
+ plans = await planWithAgent({
110
+ cwd: cwd,
111
+ files: changedFiles,
112
+ model: option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
113
+ option: option,
114
+ scopes: scopes,
115
+ types: types,
116
+ })
119
117
  plans = assignOmittedFiles(plans, changedFiles)
120
118
  plans = attachLockfiles(plans, changedFiles)
121
119
  }
122
- await validateAndNormalizePlans(cwd, plans)
120
+ await validateAndNormalizePlans(cwd, plans, scopes, types)
123
121
 
124
122
  process.stdout.write('\nCommits:\n')
125
123
  for (const plan of plans) process.stdout.write(` • ${plan.message}\n`)
@@ -155,7 +153,7 @@ async function main(argv) {
155
153
  ensureGitIndexReady(cwd)
156
154
 
157
155
  const total = activePlans.length
158
- process.stdout.write(`Creating ${total} commit(s)…\n`)
156
+ process.stdout.write(`\n${paint('1', `Creating ${total} commit(s)…`)}\n`)
159
157
 
160
158
  let created = 0
161
159
  for (let index = 0; index < total; index++) {
@@ -163,15 +161,15 @@ async function main(argv) {
163
161
  const step = `[${index + 1}/${total}]`
164
162
  const planFiles = plan.files.filter((file) => fileHasChanges(cwd, file))
165
163
  if (!planFiles.length) {
166
- process.stdout.write(`${step} skip empty: ${plan.message}\n`)
164
+ process.stdout.write(`${dim(step)} skip empty: ${plan.message}\n`)
167
165
  continue
168
166
  }
169
167
 
170
168
  const startedAt = Date.now()
171
169
  const fileLabel =
172
170
  planFiles.length === 1 ? '1 file' : `${planFiles.length} files`
173
- process.stdout.write(`${step} ${plan.message}\n`)
174
- process.stdout.write(` staging ${fileLabel}…\n`)
171
+ process.stdout.write(`\n${paint('1;36', step)} ${plan.message}\n`)
172
+ process.stdout.write(`${dim(` staging ${fileLabel}…`)}\n`)
175
173
 
176
174
  // Drop leftover index entries so only this plan's files are committed.
177
175
  git(cwd, ['reset', '-q', 'HEAD'])
@@ -185,26 +183,36 @@ async function main(argv) {
185
183
  )
186
184
  }
187
185
 
188
- process.stdout.write(
189
- option.noVerify
190
- ? ' git commit --no-verify (hooks skipped)…\n'
191
- : ' git commit (husky)…\n',
186
+ const commitLabel = option.noVerify
187
+ ? 'git commit --no-verify'
188
+ : 'git commit'
189
+ const hookLocks = siblingLocksForFiles(planFiles)
190
+ const dirtyLocksBefore = new Set(
191
+ hookLocks.filter((file) => fileHasChanges(cwd, file)),
192
192
  )
193
193
  try {
194
194
  const commitArgs = ['commit', '-m', plan.message]
195
195
  // Skips pre-commit (lint-staged) and commit-msg. lint-staged has no
196
196
  // equivalent flag — the hook simply does not run.
197
197
  if (option.noVerify) commitArgs.push('--no-verify')
198
- git(cwd, commitArgs, { inherit: true })
198
+ await gitCommitWithProgress(cwd, commitArgs, commitLabel)
199
+ const included = includeHookLockfiles(cwd, planFiles, dirtyLocksBefore)
200
+ if (included.length) {
201
+ process.stdout.write(
202
+ `${dim(` hook updated ${included.join(', ')}, added to commit`)}\n`,
203
+ )
204
+ }
199
205
  } catch (error) {
200
206
  throw new Error(`git commit failed: ${plan.message}\n${error.message}`, {
201
207
  cause: error,
202
208
  })
203
209
  }
204
210
  created++
205
- process.stdout.write(` done (${formatElapsed(startedAt)})\n`)
211
+ process.stdout.write(
212
+ `${paint('1;32', ` done ${formatElapsed(startedAt)}`)}\n`,
213
+ )
206
214
  }
207
- process.stdout.write(`Created ${created} commit(s).\n`)
215
+ process.stdout.write(`\n${paint('1;32', `Created ${created} commit(s).`)}\n`)
208
216
 
209
217
  const remaining = changedFiles.filter((file) => fileHasChanges(cwd, file))
210
218
  if (remaining.length) {
@@ -391,9 +399,16 @@ function siblingManifest(file) {
391
399
  return directory === '.' ? 'package.json' : `${directory}/package.json`
392
400
  }
393
401
 
402
+ function siblingLockPaths(manifest) {
403
+ const directory = path.posix.dirname(manifest)
404
+ return [...LOCKFILE_NAMES].map((name) =>
405
+ directory === '.' ? name : `${directory}/${name}`,
406
+ )
407
+ }
408
+
394
409
  function buildPrompt(scopes, types, files, cwd, type) {
395
410
  const statusByFile = new Map(
396
- lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
411
+ linesFromPaths(cwd, ['status', '--short', '--porcelain'], files).map(
397
412
  (line) => [line.slice(3), line.slice(0, 2).trim()],
398
413
  ),
399
414
  )
@@ -443,13 +458,30 @@ function buildSourceDiff(cwd, files) {
443
458
  }
444
459
 
445
460
  const parts = []
446
- if (tracked.length) {
447
- const text = out(cwd, ['diff', 'HEAD', '--', ...tracked])
448
- if (text) parts.push(text)
461
+ let used = 0
462
+ const pushLimited = (text) => {
463
+ if (used >= MAX_DIFF) return false
464
+ // Empty batch (no changes / ENOBUFS skip) — keep scanning remaining files.
465
+ if (!text) return true
466
+ const room = MAX_DIFF - used
467
+ if (text.length > room) {
468
+ parts.push(`${text.slice(0, room)}\n…truncated`)
469
+ used = MAX_DIFF
470
+ return false
471
+ }
472
+ parts.push(text)
473
+ used += text.length
474
+ return true
449
475
  }
476
+
477
+ forEachBatch(tracked, (batch) => {
478
+ if (used >= MAX_DIFF) return false
479
+ return pushLimited(tryOut(cwd, ['diff', 'HEAD', '--', ...batch]))
480
+ })
450
481
  for (const file of untracked) {
482
+ if (used >= MAX_DIFF) break
451
483
  const piece = buildUntrackedDiff(cwd, file)
452
- if (piece) parts.push(piece)
484
+ if (!pushLimited(piece)) break
453
485
  }
454
486
  return parts.join('\n\n')
455
487
  }
@@ -634,7 +666,22 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
634
666
  if (model) args.push('--model', model)
635
667
  if (process.env.CURSOR_API_KEY)
636
668
  args.push('--api-key', process.env.CURSOR_API_KEY)
637
- args.push(prompt)
669
+
670
+ let promptFile = ''
671
+ let agentPrompt = prompt
672
+ if (prompt.length > ARG_SAFE) {
673
+ const candidate = path.join(cwd, '.git', 'smart-commit-prompt.txt')
674
+ try {
675
+ fs.writeFileSync(candidate, prompt, 'utf8')
676
+ promptFile = candidate
677
+ agentPrompt =
678
+ `Read the file ${candidate} and follow it exactly. ` +
679
+ 'Return ONLY the JSON it requests. Do not edit files or run git commit.'
680
+ } catch {
681
+ // If .git cannot be written, keep the original prompt.
682
+ }
683
+ }
684
+ args.push(agentPrompt)
638
685
 
639
686
  if (verbose) process.stderr.write(`Using: ${cmd}\n`)
640
687
  if (onAgentActivity) {
@@ -667,6 +714,13 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
667
714
  settled = true
668
715
  process.removeListener('SIGINT', onSignal)
669
716
  process.removeListener('SIGTERM', onSignal)
717
+ if (promptFile) {
718
+ try {
719
+ fs.unlinkSync(promptFile)
720
+ } catch {
721
+ // Ignore cleanup errors.
722
+ }
723
+ }
670
724
  if (error) reject(error)
671
725
  else resolve(value)
672
726
  }
@@ -754,20 +808,12 @@ function applyAgentLine(stream, line) {
754
808
  const text = extractThinkingText(event)
755
809
  if (event.subtype === 'completed') {
756
810
  return stream.thinking
757
- ? {
758
- log: `Thinking: ${stream.thinking}`,
759
- stream: true,
760
- streamKey: 'thinking',
761
- }
811
+ ? thinkingLog(stream)
762
812
  : { log: 'Finished thinking' }
763
813
  }
764
814
  if (text) {
765
815
  stream.thinking += text
766
- return {
767
- log: `Thinking: ${stream.thinking}`,
768
- stream: true,
769
- streamKey: 'thinking',
770
- }
816
+ return thinkingLog(stream)
771
817
  }
772
818
  return { log: 'Thinking' }
773
819
  }
@@ -782,13 +828,9 @@ function applyAgentLine(stream, line) {
782
828
  const thinking = extractContentText(event.message?.content, 'thinking')
783
829
  if (thinking) {
784
830
  stream.thinking += thinking
785
- return {
786
- log: `Thinking: ${stream.thinking}`,
787
- stream: true,
788
- streamKey: 'thinking',
789
- }
831
+ return thinkingLog(stream)
790
832
  }
791
- const text = extractAssistantText(event)
833
+ const text = extractContentText(event.message?.content, 'text')
792
834
  if (!text) return null
793
835
  if (typeof event.timestamp_ms === 'number') stream.lastAssistant += text
794
836
  else stream.lastAssistant = text
@@ -812,8 +854,12 @@ function applyAgentLine(stream, line) {
812
854
  return null
813
855
  }
814
856
 
815
- function extractAssistantText(event) {
816
- return extractContentText(event.message?.content, 'text')
857
+ function thinkingLog(stream) {
858
+ return {
859
+ log: `Thinking: ${stream.thinking}`,
860
+ stream: true,
861
+ streamKey: 'thinking',
862
+ }
817
863
  }
818
864
 
819
865
  function extractThinkingText(event) {
@@ -940,6 +986,131 @@ function parsePlan(raw, allowedFiles) {
940
986
  return plans
941
987
  }
942
988
 
989
+ async function planWithAgent(context) {
990
+ const batches = splitPlanBatches(context.files)
991
+ const plans = []
992
+ for (let index = 0; index < batches.length; index++) {
993
+ const batch = batches[index]
994
+ if (batches.length > 1) {
995
+ process.stdout.write(
996
+ `Planning ${index + 1}/${batches.length} (${batch.length} files)…\n`,
997
+ )
998
+ }
999
+ const batchPlans = await planBatch({ ...context, files: batch })
1000
+ plans.push(...batchPlans)
1001
+ }
1002
+ return plans
1003
+ }
1004
+
1005
+ async function planBatch(context) {
1006
+ try {
1007
+ return await requestPlan(context)
1008
+ } catch (error) {
1009
+ if (context.files.length < 2 || !isRetryablePlanError(error)) throw error
1010
+ const mid = Math.ceil(context.files.length / 2)
1011
+ process.stderr.write(
1012
+ `Commit plan failed for ${context.files.length} files, splitting…\n`,
1013
+ )
1014
+ const left = await planBatch({
1015
+ ...context,
1016
+ files: context.files.slice(0, mid),
1017
+ })
1018
+ const right = await planBatch({
1019
+ ...context,
1020
+ files: context.files.slice(mid),
1021
+ })
1022
+ return [...left, ...right]
1023
+ }
1024
+ }
1025
+
1026
+ function isRetryablePlanError(error) {
1027
+ return /Bad JSON|Empty commit plan|No JSON/.test(String(error?.message || ''))
1028
+ }
1029
+
1030
+ async function requestPlan(context) {
1031
+ const prompt = buildPrompt(
1032
+ context.scopes,
1033
+ context.types,
1034
+ context.files,
1035
+ context.cwd,
1036
+ context.option.type,
1037
+ )
1038
+ const raw = await withAgentProgress(
1039
+ {
1040
+ fileCount: context.files.length,
1041
+ sprite: pickSprite(context.option.sprite),
1042
+ verbose: context.option.verbose,
1043
+ },
1044
+ (progress) =>
1045
+ askAgent(
1046
+ context.cwd,
1047
+ context.model,
1048
+ prompt,
1049
+ context.option.verbose,
1050
+ progress.onAgentActivity,
1051
+ ),
1052
+ )
1053
+ return parsePlan(raw, context.files)
1054
+ }
1055
+
1056
+ function splitPlanBatches(files) {
1057
+ if (files.length <= PLAN_FILE_BATCH) return [files]
1058
+
1059
+ const groups = new Map()
1060
+ for (const file of files) {
1061
+ if (isLockfile(file)) continue
1062
+ const key = planGroupKey(file)
1063
+ const group = groups.get(key) || []
1064
+ group.push(file)
1065
+ groups.set(key, group)
1066
+ }
1067
+
1068
+ for (const file of files) {
1069
+ if (!isLockfile(file)) continue
1070
+ const manifest = siblingManifest(file)
1071
+ let placed = false
1072
+ for (const group of groups.values()) {
1073
+ if (!group.includes(manifest)) continue
1074
+ group.push(file)
1075
+ placed = true
1076
+ break
1077
+ }
1078
+ if (placed) continue
1079
+ const key = planGroupKey(file)
1080
+ const group = groups.get(key) || []
1081
+ group.push(file)
1082
+ groups.set(key, group)
1083
+ }
1084
+
1085
+ const batches = []
1086
+ let current = []
1087
+ for (const group of groups.values()) {
1088
+ if (group.length > PLAN_FILE_BATCH) {
1089
+ if (current.length) {
1090
+ batches.push(current)
1091
+ current = []
1092
+ }
1093
+ for (let index = 0; index < group.length; index += PLAN_FILE_BATCH) {
1094
+ batches.push(group.slice(index, index + PLAN_FILE_BATCH))
1095
+ }
1096
+ continue
1097
+ }
1098
+ if (current.length && current.length + group.length > PLAN_FILE_BATCH) {
1099
+ batches.push(current)
1100
+ current = []
1101
+ }
1102
+ current.push(...group)
1103
+ }
1104
+ if (current.length) batches.push(current)
1105
+ return batches.length ? batches : [files]
1106
+ }
1107
+
1108
+ function planGroupKey(file) {
1109
+ const parts = file.split('/')
1110
+ if (parts.length <= 2) return parts[0] || '.'
1111
+ return `${parts[0]}/${parts[1]}`
1112
+ }
1113
+
943
1114
  function tryFastPlan(files, cwd, scopes, types) {
944
1115
  const sourceFiles = files.filter((file) => !isLockfile(file))
945
1116
  if (sourceFiles.length !== 1) return null
@@ -1118,7 +1289,7 @@ function normalizeCommitMessage(message) {
1118
1289
  return normalizeCommitSubject(subject)
1119
1290
  }
1120
1291
 
1121
- async function validateAndNormalizePlans(cwd, plans) {
1292
+ async function validateAndNormalizePlans(cwd, plans, scopes, types) {
1122
1293
  const loaded = await load({}, cwd)
1123
1294
  const lintOptions = {
1124
1295
  defaultIgnores: loaded.defaultIgnores,
@@ -1128,9 +1299,22 @@ async function validateAndNormalizePlans(cwd, plans) {
1128
1299
 
1129
1300
  for (const plan of plans) {
1130
1301
  plan.message = normalizeCommitSubject(plan.message)
1131
- const report = await lint(plan.message, loaded.rules, lintOptions)
1302
+ let report = await lint(plan.message, loaded.rules, lintOptions)
1132
1303
  if (report.valid) continue
1133
1304
 
1305
+ const oldMessage = plan.message
1306
+ const repaired = repairCommitHeader(plan.message, scopes, types)
1307
+ if (repaired !== oldMessage) {
1308
+ report = await lint(repaired, loaded.rules, lintOptions)
1309
+ if (report.valid) {
1310
+ process.stderr.write(
1311
+ `Adjusted commit message for commitlint:\n ${oldMessage}\n ${repaired}\n`,
1312
+ )
1313
+ plan.message = repaired
1314
+ continue
1315
+ }
1316
+ }
1317
+
1134
1318
  const errors =
1135
1319
  report.errors?.map((error) => error.message).join('; ') || 'invalid'
1136
1320
  throw new Error(
@@ -1140,26 +1324,45 @@ async function validateAndNormalizePlans(cwd, plans) {
1140
1324
  }
1141
1325
  }
1142
1326
 
1327
+ function repairCommitHeader(message, scopes, types) {
1328
+ const match = String(message || '').match(/^(\w+)\(([^)]+)\):\s*(.+)$/)
1329
+ if (!match) return message
1330
+
1331
+ let [, type, scope, subject] = match
1332
+ const scopeEnum = buildScopeEnum(scopes)
1333
+ if (types.length && !types.includes(type)) {
1334
+ if (types.includes('chore')) type = 'chore'
1335
+ else if (types.includes('other')) type = 'other'
1336
+ else type = types[0]
1337
+ }
1338
+
1339
+ const matchedScope = matchScope(scope, scopeEnum)
1340
+ if (matchedScope) scope = matchedScope
1341
+ else scope = pickScope(scopes, ['other'])
1342
+
1343
+ return normalizeCommitSubject(`${type}(${scope}): ${subject}`)
1344
+ }
1345
+
1346
+ function matchScope(scope, scopeEnum) {
1347
+ if (scopeEnum.includes(scope)) return scope
1348
+ const normalized = scope
1349
+ .trim()
1350
+ .replace(/\s*\/\s*/g, ' / ')
1351
+ .replace(/\s+/g, ' ')
1352
+ if (scopeEnum.includes(normalized)) return normalized
1353
+ return ''
1354
+ }
1355
+
1143
1356
  function isOnDisk(cwd, file) {
1144
1357
  return fs.existsSync(path.join(cwd, file))
1145
1358
  }
1146
1359
 
1147
1360
  function isTracked(cwd, file) {
1148
- return (
1149
- spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
1150
- cwd: cwd,
1151
- encoding: 'utf8',
1152
- }).status === 0
1153
- )
1361
+ return gitOk(cwd, ['ls-files', '--error-unmatch', '--', file])
1154
1362
  }
1155
1363
 
1156
1364
  function isInHead(cwd, file) {
1157
- return (
1158
- spawnSync('git', ['cat-file', '-e', `HEAD:${file}`], {
1159
- cwd: cwd,
1160
- encoding: 'utf8',
1161
- }).status === 0
1162
- )
1365
+ return gitOk(cwd, ['cat-file', '-e', `HEAD:${file}`])
1163
1366
  }
1164
1367
 
1165
1368
  function isUntracked(cwd, file) {
@@ -1181,6 +1384,44 @@ function fileHasChanges(cwd, file) {
1181
1384
  )
1182
1385
  }
1183
1386
 
1387
+ function siblingLocksForFiles(files) {
1388
+ const locks = []
1389
+ for (const file of files) {
1390
+ if (path.basename(file) !== 'package.json') continue
1391
+ locks.push(...siblingLockPaths(file))
1392
+ }
1393
+ return [...new Set(locks)]
1394
+ }
1395
+
1396
+ // lint-staged runs `npm install --package-lock-only` for package.json, but
1397
+ // older lint-staged only re-stages the matched file. The lockfile stays dirty
1398
+ // after the commit. Fold that hook output into the commit we just created.
1399
+ function includeHookLockfiles(cwd, planFiles, dirtyBefore) {
1400
+ const locks = []
1401
+ for (const file of planFiles) {
1402
+ if (path.basename(file) !== 'package.json') continue
1403
+ for (const lock of siblingLockPaths(file)) {
1404
+ if (!fileHasChanges(cwd, lock)) continue
1405
+ const wasDirty = dirtyBefore.has(lock)
1406
+ const wasInCommit = planFiles.includes(lock)
1407
+ if (!wasDirty || wasInCommit) locks.push(lock)
1408
+ }
1409
+ }
1410
+
1411
+ const unique = [...new Set(locks)]
1412
+ if (!unique.length) return []
1413
+
1414
+ stageFiles(cwd, unique)
1415
+ const staged = new Set(lines(cwd, ['diff', '--cached', '--name-only']))
1416
+ const stagedLocks = unique.filter((file) => staged.has(file))
1417
+ if (!stagedLocks.length) return []
1418
+
1419
+ // --no-verify: the hook already ran and produced this lockfile.
1420
+ // Running it again would call npm install and dirty the lockfile once more.
1421
+ git(cwd, ['commit', '--amend', '--no-edit', '--no-verify'])
1422
+ return stagedLocks
1423
+ }
1424
+
1184
1425
  function stageFiles(cwd, files) {
1185
1426
  if (!files.length) return
1186
1427
 
@@ -1193,10 +1434,12 @@ function stageFiles(cwd, files) {
1193
1434
 
1194
1435
  // `git add -A -- deleted/path` warns or misses the deletion when the
1195
1436
  // parent directory is already gone. `git rm` stages the index removal.
1196
- if (existing.length) git(cwd, ['add', '-A', '--', ...existing])
1197
- if (removed.length) {
1198
- git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...removed])
1199
- }
1437
+ forEachBatch(existing, (batch) => {
1438
+ git(cwd, ['add', '-A', '--', ...batch])
1439
+ })
1440
+ forEachBatch(removed, (batch) => {
1441
+ git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...batch])
1442
+ })
1200
1443
  }
1201
1444
 
1202
1445
  async function confirm(count) {
@@ -1277,31 +1520,191 @@ function ensureGitIndexReady(cwd) {
1277
1520
  )
1278
1521
  }
1279
1522
 
1523
+ async function gitCommitWithProgress(cwd, args, label) {
1524
+ for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
1525
+ const result = await spawnGitWithSpinner(cwd, args, label)
1526
+ if (result.error) throw result.error
1527
+ if (!result.status) {
1528
+ if (process.stderr.isTTY) writeGitSummary(result.stdout)
1529
+ else writeHookOutput(result.stdout, result.stderr)
1530
+ return
1531
+ }
1532
+
1533
+ const stderr = result.stderr || ''
1534
+ if (!retryIndexLock(cwd, attempt, stderr)) {
1535
+ throw new Error(
1536
+ (stderr || result.stdout || '').trim() || `git ${args.join(' ')}`,
1537
+ )
1538
+ }
1539
+ await sleep(INDEX_LOCK_WAIT_MS * attempt)
1540
+ }
1541
+ }
1542
+
1543
+ function spawnGitWithSpinner(cwd, args, label) {
1544
+ return new Promise((resolve) => {
1545
+ const child = spawn('git', args, {
1546
+ cwd: cwd,
1547
+ stdio: ['ignore', 'pipe', 'pipe'],
1548
+ })
1549
+ let stdout = ''
1550
+ let stderr = ''
1551
+ const logLines = []
1552
+ let frame = 0
1553
+ const startedAt = Date.now()
1554
+ const tty = Boolean(process.stderr.isTTY)
1555
+
1556
+ const pushLog = (chunk) => {
1557
+ for (const line of String(chunk).split('\n')) {
1558
+ const trimmed = line.trim()
1559
+ if (!trimmed) continue
1560
+ logLines.push(trimmed)
1561
+ }
1562
+ if (logLines.length > 40) logLines.splice(0, logLines.length - 40)
1563
+ }
1564
+
1565
+ child.stdout.on('data', (chunk) => {
1566
+ stdout += chunk
1567
+ pushLog(chunk)
1568
+ })
1569
+ child.stderr.on('data', (chunk) => {
1570
+ stderr += chunk
1571
+ pushLog(chunk)
1572
+ })
1573
+
1574
+ let timer
1575
+ if (tty) {
1576
+ const render = () => {
1577
+ const columns = process.stderr.columns || 80
1578
+ const spin = SPIN_FRAMES[frame % SPIN_FRAMES.length]
1579
+ frame += 1
1580
+ const hookLines = logLines
1581
+ .slice(-HOOK_LOG_ROWS)
1582
+ .map((line) =>
1583
+ dim(` │ ${clipLog(line, Math.max(20, columns - 10))}`),
1584
+ )
1585
+ const status = paint(
1586
+ '1;36',
1587
+ ` ${spin} ${label} ${formatElapsed(startedAt)}`,
1588
+ )
1589
+ const block = [paint('1;33', ' husky'), ...hookLines, status]
1590
+ logUpdateStderr(block.join('\n'))
1591
+ }
1592
+ timer = setInterval(render, SPIN_MS)
1593
+ render()
1594
+ } else {
1595
+ timer = setInterval(() => {
1596
+ process.stderr.write(` ${label}… ${formatElapsed(startedAt)}\n`)
1597
+ }, AGENT_STEP_MS)
1598
+ }
1599
+
1600
+ const stop = () => {
1601
+ clearInterval(timer)
1602
+ if (tty) logUpdateStderr.clear()
1603
+ }
1604
+
1605
+ const onSignal = () => {
1606
+ if (child.exitCode === null && !child.killed) child.kill('SIGTERM')
1607
+ }
1608
+ process.once('SIGINT', onSignal)
1609
+ process.once('SIGTERM', onSignal)
1610
+
1611
+ const finish = (result) => {
1612
+ stop()
1613
+ process.removeListener('SIGINT', onSignal)
1614
+ process.removeListener('SIGTERM', onSignal)
1615
+ resolve(result)
1616
+ }
1617
+
1618
+ child.on('error', (error) => {
1619
+ finish({ status: 1, error: error, stderr: stderr, stdout: stdout })
1620
+ })
1621
+ child.on('close', (code) => {
1622
+ finish({
1623
+ status: code ?? 1,
1624
+ stderr: stderr,
1625
+ stdout: stdout,
1626
+ })
1627
+ })
1628
+ })
1629
+ }
1630
+
1631
+ function writeGitSummary(stdout) {
1632
+ const lines = String(stdout || '')
1633
+ .split('\n')
1634
+ .map((line) => line.trimEnd())
1635
+ .filter(
1636
+ (line) =>
1637
+ /^\s*\[[^\]]+\]/.test(line) || /^\s*\d+ files? changed/.test(line),
1638
+ )
1639
+ if (!lines.length) return
1640
+ process.stdout.write(`${dim(lines.join('\n'))}\n`)
1641
+ }
1642
+
1643
+ function paint(code, text) {
1644
+ if (!process.stdout.isTTY || process.env.NO_COLOR) return text
1645
+ return `\x1b[${code}m${text}\x1b[0m`
1646
+ }
1647
+
1648
+ function dim(text) {
1649
+ return paint('2', text)
1650
+ }
1651
+
1652
+ function writeHookOutput(stdout, stderr) {
1653
+ if (stdout) {
1654
+ process.stdout.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`)
1655
+ }
1656
+ if (stderr) {
1657
+ process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`)
1658
+ }
1659
+ }
1660
+
1661
+ function sleep(ms) {
1662
+ return new Promise((resolve) => {
1663
+ setTimeout(resolve, ms)
1664
+ })
1665
+ }
1666
+
1280
1667
  function git(cwd, args, option = {}) {
1281
1668
  for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
1282
1669
  const result = spawnSync('git', args, {
1670
+ maxBuffer: GIT_MAX_BUFFER,
1283
1671
  cwd: cwd,
1284
1672
  encoding: 'utf8',
1285
1673
  stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
1286
1674
  })
1675
+ if (result.error) throw result.error
1287
1676
  if (!result.status) return result
1288
1677
  // git diff exits 1 when there are differences
1289
1678
  if (result.status === 1 && args[0] === 'diff') return result
1290
1679
 
1291
1680
  const stderr = result.stderr || ''
1292
- if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) {
1681
+ if (!retryIndexLock(cwd, attempt, stderr)) {
1293
1682
  if (option.inherit && stderr) process.stderr.write(stderr)
1294
1683
  throw new Error(stderr.trim() || `git ${args.join(' ')}`)
1295
1684
  }
1296
-
1297
- if (attempt === 1) {
1298
- process.stderr.write('Waiting for git index.lock to clear…\n')
1299
- }
1300
- clearStaleIndexLock(cwd)
1301
1685
  sleepSync(INDEX_LOCK_WAIT_MS * attempt)
1302
1686
  }
1303
1687
  }
1304
1688
 
1689
+ function retryIndexLock(cwd, attempt, stderr) {
1690
+ if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) return false
1691
+ if (attempt === 1) {
1692
+ process.stderr.write('Waiting for git index.lock to clear…\n')
1693
+ }
1694
+ clearStaleIndexLock(cwd)
1695
+ return true
1696
+ }
1697
+
1698
+ function gitOk(cwd, args) {
1699
+ return spawnSync('git', args, { cwd: cwd, encoding: 'utf8' }).status === 0
1700
+ }
1701
+
1702
+ function forEachBatch(items, visit) {
1703
+ for (let index = 0; index < items.length; index += GIT_PATH_BATCH) {
1704
+ if (visit(items.slice(index, index + GIT_PATH_BATCH)) === false) return
1705
+ }
1706
+ }
1707
+
1305
1708
  function out(cwd, args) {
1306
1709
  return (git(cwd, args).stdout || '').trim()
1307
1710
  }
@@ -1309,3 +1712,22 @@ function out(cwd, args) {
1309
1712
  function lines(cwd, args) {
1310
1713
  return out(cwd, args).split('\n').filter(Boolean)
1311
1714
  }
1715
+
1716
+ function linesFromPaths(cwd, prefix, files) {
1717
+ if (!files.length) return []
1718
+ const chunks = []
1719
+ forEachBatch(files, (batch) => {
1720
+ const text = out(cwd, [...prefix, '--', ...batch])
1721
+ if (text) chunks.push(text)
1722
+ })
1723
+ return chunks.join('\n').split('\n').filter(Boolean)
1724
+ }
1725
+
1726
+ function tryOut(cwd, args) {
1727
+ try {
1728
+ return out(cwd, args)
1729
+ } catch (error) {
1730
+ if (/ENOBUFS|maxBuffer/i.test(String(error?.message || ''))) return ''
1731
+ throw error
1732
+ }
1733
+ }