@itcase/config 1.7.11 → 1.7.13
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/lint-staged/checkNoFileDependencies.js +42 -24
- package/lint-staged/common.js +48 -16
- package/package.json +12 -12
- package/smart-commit/cli.js +502 -78
|
@@ -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
|
-
|
|
98
|
-
const
|
|
98
|
+
function findForbiddenDependencyHits(files) {
|
|
99
|
+
const targets = files?.length ? files : findPackageJsonFiles(process.cwd())
|
|
100
|
+
const hitsByFile = []
|
|
99
101
|
|
|
100
|
-
const
|
|
102
|
+
for (const file of targets) {
|
|
103
|
+
const pkg = readPackageJson(file)
|
|
104
|
+
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) continue
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
process.exit(1)
|
|
148
|
+
export { findForbiddenDependencyHits, formatFileDependencyReport }
|
package/lint-staged/common.js
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
+
/* eslint-disable perfectionist/sort-objects */
|
|
2
|
+
|
|
1
3
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
22
|
+
return commandLine(['eslint', '--fix', '--color', ...files])
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
function stylelintCommand(files) {
|
|
19
|
-
return
|
|
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
|
|
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
|
|
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: —
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
93
|
+
return commands
|
|
62
94
|
},
|
|
63
95
|
'*.css': (files) => {
|
|
64
96
|
const existing = existingFiles(files)
|
|
65
|
-
if (!existing.length) return
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.7.13",
|
|
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.
|
|
47
|
-
"@lerna-lite/exec": "^5.6.
|
|
48
|
-
"@lerna-lite/list": "^5.6.
|
|
49
|
-
"@lerna-lite/run": "^5.6.
|
|
50
|
-
"@lerna-lite/watch": "^5.6.
|
|
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
|
|
72
|
-
"cssnano-preset-default": "^9.0
|
|
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.
|
|
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.
|
|
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.
|
|
122
|
-
"prettier": "^3.9.
|
|
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"
|
package/smart-commit/cli.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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)
|
|
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(
|
|
174
|
-
process.stdout.write(` staging ${fileLabel}
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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(
|
|
211
|
+
process.stdout.write(
|
|
212
|
+
`${paint('1;32', ` done ${formatElapsed(startedAt)}`)}\n`,
|
|
213
|
+
)
|
|
206
214
|
}
|
|
207
|
-
process.stdout.write(`Created ${created} commit(s)
|
|
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
|
-
|
|
411
|
+
linesFromPaths(cwd, ['status', '--short', '--porcelain'], files).map(
|
|
397
412
|
(line) => [line.slice(3), line.slice(0, 2).trim()],
|
|
398
413
|
),
|
|
399
414
|
)
|
|
@@ -405,6 +420,7 @@ function buildPrompt(scopes, types, files, cwd, type) {
|
|
|
405
420
|
|
|
406
421
|
return `Plan git commits. Do not edit files or run git commit.
|
|
407
422
|
Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
|
|
423
|
+
Every message must be type(scope): subject. Put a scope from the list in parentheses, including when the type is "other".
|
|
408
424
|
Types: ${types.join(', ')}
|
|
409
425
|
Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
|
|
410
426
|
Subject rules:
|
|
@@ -443,13 +459,30 @@ function buildSourceDiff(cwd, files) {
|
|
|
443
459
|
}
|
|
444
460
|
|
|
445
461
|
const parts = []
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (
|
|
462
|
+
let used = 0
|
|
463
|
+
const pushLimited = (text) => {
|
|
464
|
+
if (used >= MAX_DIFF) return false
|
|
465
|
+
// Empty batch (no changes / ENOBUFS skip) — keep scanning remaining files.
|
|
466
|
+
if (!text) return true
|
|
467
|
+
const room = MAX_DIFF - used
|
|
468
|
+
if (text.length > room) {
|
|
469
|
+
parts.push(`${text.slice(0, room)}\n…truncated`)
|
|
470
|
+
used = MAX_DIFF
|
|
471
|
+
return false
|
|
472
|
+
}
|
|
473
|
+
parts.push(text)
|
|
474
|
+
used += text.length
|
|
475
|
+
return true
|
|
449
476
|
}
|
|
477
|
+
|
|
478
|
+
forEachBatch(tracked, (batch) => {
|
|
479
|
+
if (used >= MAX_DIFF) return false
|
|
480
|
+
return pushLimited(tryOut(cwd, ['diff', 'HEAD', '--', ...batch]))
|
|
481
|
+
})
|
|
450
482
|
for (const file of untracked) {
|
|
483
|
+
if (used >= MAX_DIFF) break
|
|
451
484
|
const piece = buildUntrackedDiff(cwd, file)
|
|
452
|
-
if (piece)
|
|
485
|
+
if (!pushLimited(piece)) break
|
|
453
486
|
}
|
|
454
487
|
return parts.join('\n\n')
|
|
455
488
|
}
|
|
@@ -634,7 +667,22 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
|
634
667
|
if (model) args.push('--model', model)
|
|
635
668
|
if (process.env.CURSOR_API_KEY)
|
|
636
669
|
args.push('--api-key', process.env.CURSOR_API_KEY)
|
|
637
|
-
|
|
670
|
+
|
|
671
|
+
let promptFile = ''
|
|
672
|
+
let agentPrompt = prompt
|
|
673
|
+
if (prompt.length > ARG_SAFE) {
|
|
674
|
+
const candidate = path.join(cwd, '.git', 'smart-commit-prompt.txt')
|
|
675
|
+
try {
|
|
676
|
+
fs.writeFileSync(candidate, prompt, 'utf8')
|
|
677
|
+
promptFile = candidate
|
|
678
|
+
agentPrompt =
|
|
679
|
+
`Read the file ${candidate} and follow it exactly. ` +
|
|
680
|
+
'Return ONLY the JSON it requests. Do not edit files or run git commit.'
|
|
681
|
+
} catch {
|
|
682
|
+
// If .git cannot be written, keep the original prompt.
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
args.push(agentPrompt)
|
|
638
686
|
|
|
639
687
|
if (verbose) process.stderr.write(`Using: ${cmd}\n`)
|
|
640
688
|
if (onAgentActivity) {
|
|
@@ -667,6 +715,13 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
|
667
715
|
settled = true
|
|
668
716
|
process.removeListener('SIGINT', onSignal)
|
|
669
717
|
process.removeListener('SIGTERM', onSignal)
|
|
718
|
+
if (promptFile) {
|
|
719
|
+
try {
|
|
720
|
+
fs.unlinkSync(promptFile)
|
|
721
|
+
} catch {
|
|
722
|
+
// Ignore cleanup errors.
|
|
723
|
+
}
|
|
724
|
+
}
|
|
670
725
|
if (error) reject(error)
|
|
671
726
|
else resolve(value)
|
|
672
727
|
}
|
|
@@ -754,20 +809,12 @@ function applyAgentLine(stream, line) {
|
|
|
754
809
|
const text = extractThinkingText(event)
|
|
755
810
|
if (event.subtype === 'completed') {
|
|
756
811
|
return stream.thinking
|
|
757
|
-
?
|
|
758
|
-
log: `Thinking: ${stream.thinking}`,
|
|
759
|
-
stream: true,
|
|
760
|
-
streamKey: 'thinking',
|
|
761
|
-
}
|
|
812
|
+
? thinkingLog(stream)
|
|
762
813
|
: { log: 'Finished thinking' }
|
|
763
814
|
}
|
|
764
815
|
if (text) {
|
|
765
816
|
stream.thinking += text
|
|
766
|
-
return
|
|
767
|
-
log: `Thinking: ${stream.thinking}`,
|
|
768
|
-
stream: true,
|
|
769
|
-
streamKey: 'thinking',
|
|
770
|
-
}
|
|
817
|
+
return thinkingLog(stream)
|
|
771
818
|
}
|
|
772
819
|
return { log: 'Thinking' }
|
|
773
820
|
}
|
|
@@ -782,13 +829,9 @@ function applyAgentLine(stream, line) {
|
|
|
782
829
|
const thinking = extractContentText(event.message?.content, 'thinking')
|
|
783
830
|
if (thinking) {
|
|
784
831
|
stream.thinking += thinking
|
|
785
|
-
return
|
|
786
|
-
log: `Thinking: ${stream.thinking}`,
|
|
787
|
-
stream: true,
|
|
788
|
-
streamKey: 'thinking',
|
|
789
|
-
}
|
|
832
|
+
return thinkingLog(stream)
|
|
790
833
|
}
|
|
791
|
-
const text =
|
|
834
|
+
const text = extractContentText(event.message?.content, 'text')
|
|
792
835
|
if (!text) return null
|
|
793
836
|
if (typeof event.timestamp_ms === 'number') stream.lastAssistant += text
|
|
794
837
|
else stream.lastAssistant = text
|
|
@@ -812,8 +855,12 @@ function applyAgentLine(stream, line) {
|
|
|
812
855
|
return null
|
|
813
856
|
}
|
|
814
857
|
|
|
815
|
-
function
|
|
816
|
-
return
|
|
858
|
+
function thinkingLog(stream) {
|
|
859
|
+
return {
|
|
860
|
+
log: `Thinking: ${stream.thinking}`,
|
|
861
|
+
stream: true,
|
|
862
|
+
streamKey: 'thinking',
|
|
863
|
+
}
|
|
817
864
|
}
|
|
818
865
|
|
|
819
866
|
function extractThinkingText(event) {
|
|
@@ -940,6 +987,131 @@ function parsePlan(raw, allowedFiles) {
|
|
|
940
987
|
return plans
|
|
941
988
|
}
|
|
942
989
|
|
|
990
|
+
async function planWithAgent(context) {
|
|
991
|
+
const batches = splitPlanBatches(context.files)
|
|
992
|
+
const plans = []
|
|
993
|
+
for (let index = 0; index < batches.length; index++) {
|
|
994
|
+
const batch = batches[index]
|
|
995
|
+
if (batches.length > 1) {
|
|
996
|
+
process.stdout.write(
|
|
997
|
+
`Planning ${index + 1}/${batches.length} (${batch.length} files)…\n`,
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
const batchPlans = await planBatch({ ...context, files: batch })
|
|
1001
|
+
plans.push(...batchPlans)
|
|
1002
|
+
}
|
|
1003
|
+
return plans
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function planBatch(context) {
|
|
1007
|
+
try {
|
|
1008
|
+
return await requestPlan(context)
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (context.files.length < 2 || !isRetryablePlanError(error)) throw error
|
|
1011
|
+
const mid = Math.ceil(context.files.length / 2)
|
|
1012
|
+
process.stderr.write(
|
|
1013
|
+
`Commit plan failed for ${context.files.length} files, splitting…\n`,
|
|
1014
|
+
)
|
|
1015
|
+
const left = await planBatch({
|
|
1016
|
+
...context,
|
|
1017
|
+
files: context.files.slice(0, mid),
|
|
1018
|
+
})
|
|
1019
|
+
const right = await planBatch({
|
|
1020
|
+
...context,
|
|
1021
|
+
files: context.files.slice(mid),
|
|
1022
|
+
})
|
|
1023
|
+
return [...left, ...right]
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function isRetryablePlanError(error) {
|
|
1028
|
+
return /Bad JSON|Empty commit plan|No JSON/.test(String(error?.message || ''))
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function requestPlan(context) {
|
|
1032
|
+
const prompt = buildPrompt(
|
|
1033
|
+
context.scopes,
|
|
1034
|
+
context.types,
|
|
1035
|
+
context.files,
|
|
1036
|
+
context.cwd,
|
|
1037
|
+
context.option.type,
|
|
1038
|
+
)
|
|
1039
|
+
const raw = await withAgentProgress(
|
|
1040
|
+
{
|
|
1041
|
+
fileCount: context.files.length,
|
|
1042
|
+
sprite: pickSprite(context.option.sprite),
|
|
1043
|
+
verbose: context.option.verbose,
|
|
1044
|
+
},
|
|
1045
|
+
(progress) =>
|
|
1046
|
+
askAgent(
|
|
1047
|
+
context.cwd,
|
|
1048
|
+
context.model,
|
|
1049
|
+
prompt,
|
|
1050
|
+
context.option.verbose,
|
|
1051
|
+
progress.onAgentActivity,
|
|
1052
|
+
),
|
|
1053
|
+
)
|
|
1054
|
+
return parsePlan(raw, context.files)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function splitPlanBatches(files) {
|
|
1058
|
+
if (files.length <= PLAN_FILE_BATCH) return [files]
|
|
1059
|
+
|
|
1060
|
+
const groups = new Map()
|
|
1061
|
+
for (const file of files) {
|
|
1062
|
+
if (isLockfile(file)) continue
|
|
1063
|
+
const key = planGroupKey(file)
|
|
1064
|
+
const group = groups.get(key) || []
|
|
1065
|
+
group.push(file)
|
|
1066
|
+
groups.set(key, group)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const file of files) {
|
|
1070
|
+
if (!isLockfile(file)) continue
|
|
1071
|
+
const manifest = siblingManifest(file)
|
|
1072
|
+
let placed = false
|
|
1073
|
+
for (const group of groups.values()) {
|
|
1074
|
+
if (!group.includes(manifest)) continue
|
|
1075
|
+
group.push(file)
|
|
1076
|
+
placed = true
|
|
1077
|
+
break
|
|
1078
|
+
}
|
|
1079
|
+
if (placed) continue
|
|
1080
|
+
const key = planGroupKey(file)
|
|
1081
|
+
const group = groups.get(key) || []
|
|
1082
|
+
group.push(file)
|
|
1083
|
+
groups.set(key, group)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const batches = []
|
|
1087
|
+
let current = []
|
|
1088
|
+
for (const group of groups.values()) {
|
|
1089
|
+
if (group.length > PLAN_FILE_BATCH) {
|
|
1090
|
+
if (current.length) {
|
|
1091
|
+
batches.push(current)
|
|
1092
|
+
current = []
|
|
1093
|
+
}
|
|
1094
|
+
for (let index = 0; index < group.length; index += PLAN_FILE_BATCH) {
|
|
1095
|
+
batches.push(group.slice(index, index + PLAN_FILE_BATCH))
|
|
1096
|
+
}
|
|
1097
|
+
continue
|
|
1098
|
+
}
|
|
1099
|
+
if (current.length && current.length + group.length > PLAN_FILE_BATCH) {
|
|
1100
|
+
batches.push(current)
|
|
1101
|
+
current = []
|
|
1102
|
+
}
|
|
1103
|
+
current.push(...group)
|
|
1104
|
+
}
|
|
1105
|
+
if (current.length) batches.push(current)
|
|
1106
|
+
return batches.length ? batches : [files]
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function planGroupKey(file) {
|
|
1110
|
+
const parts = file.split('/')
|
|
1111
|
+
if (parts.length <= 2) return parts[0] || '.'
|
|
1112
|
+
return `${parts[0]}/${parts[1]}`
|
|
1113
|
+
}
|
|
1114
|
+
|
|
943
1115
|
function tryFastPlan(files, cwd, scopes, types) {
|
|
944
1116
|
const sourceFiles = files.filter((file) => !isLockfile(file))
|
|
945
1117
|
if (sourceFiles.length !== 1) return null
|
|
@@ -1118,7 +1290,7 @@ function normalizeCommitMessage(message) {
|
|
|
1118
1290
|
return normalizeCommitSubject(subject)
|
|
1119
1291
|
}
|
|
1120
1292
|
|
|
1121
|
-
async function validateAndNormalizePlans(cwd, plans) {
|
|
1293
|
+
async function validateAndNormalizePlans(cwd, plans, scopes, types) {
|
|
1122
1294
|
const loaded = await load({}, cwd)
|
|
1123
1295
|
const lintOptions = {
|
|
1124
1296
|
defaultIgnores: loaded.defaultIgnores,
|
|
@@ -1128,38 +1300,71 @@ async function validateAndNormalizePlans(cwd, plans) {
|
|
|
1128
1300
|
|
|
1129
1301
|
for (const plan of plans) {
|
|
1130
1302
|
plan.message = normalizeCommitSubject(plan.message)
|
|
1131
|
-
|
|
1303
|
+
let report = await lint(plan.message, loaded.rules, lintOptions)
|
|
1132
1304
|
if (report.valid) continue
|
|
1133
1305
|
|
|
1306
|
+
const oldMessage = plan.message
|
|
1307
|
+
const repaired = repairCommitHeader(plan.message, scopes, types)
|
|
1308
|
+
if (repaired !== oldMessage) {
|
|
1309
|
+
report = await lint(repaired, loaded.rules, lintOptions)
|
|
1310
|
+
if (report.valid) {
|
|
1311
|
+
process.stderr.write(
|
|
1312
|
+
`Adjusted commit message for commitlint:\n ${oldMessage}\n ${repaired}\n`,
|
|
1313
|
+
)
|
|
1314
|
+
plan.message = repaired
|
|
1315
|
+
continue
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1134
1319
|
const errors =
|
|
1135
1320
|
report.errors?.map((error) => error.message).join('; ') || 'invalid'
|
|
1136
1321
|
throw new Error(
|
|
1137
1322
|
`Commit message failed commitlint:\n ${plan.message}\n ${errors}\n` +
|
|
1138
|
-
'Use lower-case
|
|
1323
|
+
'Use type(scope): subject. Scope in parentheses is required. Subject is lower-case without camelCase (e.g. "public dir" not "publicDir").',
|
|
1139
1324
|
)
|
|
1140
1325
|
}
|
|
1141
1326
|
}
|
|
1142
1327
|
|
|
1328
|
+
function repairCommitHeader(message, scopes, types) {
|
|
1329
|
+
const match = String(message || '')
|
|
1330
|
+
.trim()
|
|
1331
|
+
.match(/^(\w+)(?:\(([^)]*)\))?:\s*(.+)$/)
|
|
1332
|
+
if (!match) return message
|
|
1333
|
+
|
|
1334
|
+
let [, type, rawScope, subject] = match
|
|
1335
|
+
const scopeEnum = buildScopeEnum(scopes)
|
|
1336
|
+
if (types.length && !types.includes(type)) {
|
|
1337
|
+
if (types.includes('chore')) type = 'chore'
|
|
1338
|
+
else if (types.includes('other')) type = 'other'
|
|
1339
|
+
else type = types[0]
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const matchedScope = matchScope((rawScope || '').trim(), scopeEnum)
|
|
1343
|
+
const scope = matchedScope || pickScope(scopes, ['other'])
|
|
1344
|
+
|
|
1345
|
+
return normalizeCommitSubject(`${type}(${scope}): ${subject}`)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function matchScope(scope, scopeEnum) {
|
|
1349
|
+
if (scopeEnum.includes(scope)) return scope
|
|
1350
|
+
const normalized = scope
|
|
1351
|
+
.trim()
|
|
1352
|
+
.replace(/\s*\/\s*/g, ' / ')
|
|
1353
|
+
.replace(/\s+/g, ' ')
|
|
1354
|
+
if (scopeEnum.includes(normalized)) return normalized
|
|
1355
|
+
return ''
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1143
1358
|
function isOnDisk(cwd, file) {
|
|
1144
1359
|
return fs.existsSync(path.join(cwd, file))
|
|
1145
1360
|
}
|
|
1146
1361
|
|
|
1147
1362
|
function isTracked(cwd, file) {
|
|
1148
|
-
return (
|
|
1149
|
-
spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
|
|
1150
|
-
cwd: cwd,
|
|
1151
|
-
encoding: 'utf8',
|
|
1152
|
-
}).status === 0
|
|
1153
|
-
)
|
|
1363
|
+
return gitOk(cwd, ['ls-files', '--error-unmatch', '--', file])
|
|
1154
1364
|
}
|
|
1155
1365
|
|
|
1156
1366
|
function isInHead(cwd, file) {
|
|
1157
|
-
return (
|
|
1158
|
-
spawnSync('git', ['cat-file', '-e', `HEAD:${file}`], {
|
|
1159
|
-
cwd: cwd,
|
|
1160
|
-
encoding: 'utf8',
|
|
1161
|
-
}).status === 0
|
|
1162
|
-
)
|
|
1367
|
+
return gitOk(cwd, ['cat-file', '-e', `HEAD:${file}`])
|
|
1163
1368
|
}
|
|
1164
1369
|
|
|
1165
1370
|
function isUntracked(cwd, file) {
|
|
@@ -1181,6 +1386,44 @@ function fileHasChanges(cwd, file) {
|
|
|
1181
1386
|
)
|
|
1182
1387
|
}
|
|
1183
1388
|
|
|
1389
|
+
function siblingLocksForFiles(files) {
|
|
1390
|
+
const locks = []
|
|
1391
|
+
for (const file of files) {
|
|
1392
|
+
if (path.basename(file) !== 'package.json') continue
|
|
1393
|
+
locks.push(...siblingLockPaths(file))
|
|
1394
|
+
}
|
|
1395
|
+
return [...new Set(locks)]
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// lint-staged runs `npm install --package-lock-only` for package.json, but
|
|
1399
|
+
// older lint-staged only re-stages the matched file. The lockfile stays dirty
|
|
1400
|
+
// after the commit. Fold that hook output into the commit we just created.
|
|
1401
|
+
function includeHookLockfiles(cwd, planFiles, dirtyBefore) {
|
|
1402
|
+
const locks = []
|
|
1403
|
+
for (const file of planFiles) {
|
|
1404
|
+
if (path.basename(file) !== 'package.json') continue
|
|
1405
|
+
for (const lock of siblingLockPaths(file)) {
|
|
1406
|
+
if (!fileHasChanges(cwd, lock)) continue
|
|
1407
|
+
const wasDirty = dirtyBefore.has(lock)
|
|
1408
|
+
const wasInCommit = planFiles.includes(lock)
|
|
1409
|
+
if (!wasDirty || wasInCommit) locks.push(lock)
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const unique = [...new Set(locks)]
|
|
1414
|
+
if (!unique.length) return []
|
|
1415
|
+
|
|
1416
|
+
stageFiles(cwd, unique)
|
|
1417
|
+
const staged = new Set(lines(cwd, ['diff', '--cached', '--name-only']))
|
|
1418
|
+
const stagedLocks = unique.filter((file) => staged.has(file))
|
|
1419
|
+
if (!stagedLocks.length) return []
|
|
1420
|
+
|
|
1421
|
+
// --no-verify: the hook already ran and produced this lockfile.
|
|
1422
|
+
// Running it again would call npm install and dirty the lockfile once more.
|
|
1423
|
+
git(cwd, ['commit', '--amend', '--no-edit', '--no-verify'])
|
|
1424
|
+
return stagedLocks
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1184
1427
|
function stageFiles(cwd, files) {
|
|
1185
1428
|
if (!files.length) return
|
|
1186
1429
|
|
|
@@ -1193,10 +1436,12 @@ function stageFiles(cwd, files) {
|
|
|
1193
1436
|
|
|
1194
1437
|
// `git add -A -- deleted/path` warns or misses the deletion when the
|
|
1195
1438
|
// parent directory is already gone. `git rm` stages the index removal.
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1439
|
+
forEachBatch(existing, (batch) => {
|
|
1440
|
+
git(cwd, ['add', '-A', '--', ...batch])
|
|
1441
|
+
})
|
|
1442
|
+
forEachBatch(removed, (batch) => {
|
|
1443
|
+
git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...batch])
|
|
1444
|
+
})
|
|
1200
1445
|
}
|
|
1201
1446
|
|
|
1202
1447
|
async function confirm(count) {
|
|
@@ -1277,31 +1522,191 @@ function ensureGitIndexReady(cwd) {
|
|
|
1277
1522
|
)
|
|
1278
1523
|
}
|
|
1279
1524
|
|
|
1525
|
+
async function gitCommitWithProgress(cwd, args, label) {
|
|
1526
|
+
for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
|
|
1527
|
+
const result = await spawnGitWithSpinner(cwd, args, label)
|
|
1528
|
+
if (result.error) throw result.error
|
|
1529
|
+
if (!result.status) {
|
|
1530
|
+
if (process.stderr.isTTY) writeGitSummary(result.stdout)
|
|
1531
|
+
else writeHookOutput(result.stdout, result.stderr)
|
|
1532
|
+
return
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const stderr = result.stderr || ''
|
|
1536
|
+
if (!retryIndexLock(cwd, attempt, stderr)) {
|
|
1537
|
+
throw new Error(
|
|
1538
|
+
(stderr || result.stdout || '').trim() || `git ${args.join(' ')}`,
|
|
1539
|
+
)
|
|
1540
|
+
}
|
|
1541
|
+
await sleep(INDEX_LOCK_WAIT_MS * attempt)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function spawnGitWithSpinner(cwd, args, label) {
|
|
1546
|
+
return new Promise((resolve) => {
|
|
1547
|
+
const child = spawn('git', args, {
|
|
1548
|
+
cwd: cwd,
|
|
1549
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1550
|
+
})
|
|
1551
|
+
let stdout = ''
|
|
1552
|
+
let stderr = ''
|
|
1553
|
+
const logLines = []
|
|
1554
|
+
let frame = 0
|
|
1555
|
+
const startedAt = Date.now()
|
|
1556
|
+
const tty = Boolean(process.stderr.isTTY)
|
|
1557
|
+
|
|
1558
|
+
const pushLog = (chunk) => {
|
|
1559
|
+
for (const line of String(chunk).split('\n')) {
|
|
1560
|
+
const trimmed = line.trim()
|
|
1561
|
+
if (!trimmed) continue
|
|
1562
|
+
logLines.push(trimmed)
|
|
1563
|
+
}
|
|
1564
|
+
if (logLines.length > 40) logLines.splice(0, logLines.length - 40)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
child.stdout.on('data', (chunk) => {
|
|
1568
|
+
stdout += chunk
|
|
1569
|
+
pushLog(chunk)
|
|
1570
|
+
})
|
|
1571
|
+
child.stderr.on('data', (chunk) => {
|
|
1572
|
+
stderr += chunk
|
|
1573
|
+
pushLog(chunk)
|
|
1574
|
+
})
|
|
1575
|
+
|
|
1576
|
+
let timer
|
|
1577
|
+
if (tty) {
|
|
1578
|
+
const render = () => {
|
|
1579
|
+
const columns = process.stderr.columns || 80
|
|
1580
|
+
const spin = SPIN_FRAMES[frame % SPIN_FRAMES.length]
|
|
1581
|
+
frame += 1
|
|
1582
|
+
const hookLines = logLines
|
|
1583
|
+
.slice(-HOOK_LOG_ROWS)
|
|
1584
|
+
.map((line) =>
|
|
1585
|
+
dim(` │ ${clipLog(line, Math.max(20, columns - 10))}`),
|
|
1586
|
+
)
|
|
1587
|
+
const status = paint(
|
|
1588
|
+
'1;36',
|
|
1589
|
+
` ${spin} ${label} ${formatElapsed(startedAt)}`,
|
|
1590
|
+
)
|
|
1591
|
+
const block = [paint('1;33', ' husky'), ...hookLines, status]
|
|
1592
|
+
logUpdateStderr(block.join('\n'))
|
|
1593
|
+
}
|
|
1594
|
+
timer = setInterval(render, SPIN_MS)
|
|
1595
|
+
render()
|
|
1596
|
+
} else {
|
|
1597
|
+
timer = setInterval(() => {
|
|
1598
|
+
process.stderr.write(` ${label}… ${formatElapsed(startedAt)}\n`)
|
|
1599
|
+
}, AGENT_STEP_MS)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const stop = () => {
|
|
1603
|
+
clearInterval(timer)
|
|
1604
|
+
if (tty) logUpdateStderr.clear()
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const onSignal = () => {
|
|
1608
|
+
if (child.exitCode === null && !child.killed) child.kill('SIGTERM')
|
|
1609
|
+
}
|
|
1610
|
+
process.once('SIGINT', onSignal)
|
|
1611
|
+
process.once('SIGTERM', onSignal)
|
|
1612
|
+
|
|
1613
|
+
const finish = (result) => {
|
|
1614
|
+
stop()
|
|
1615
|
+
process.removeListener('SIGINT', onSignal)
|
|
1616
|
+
process.removeListener('SIGTERM', onSignal)
|
|
1617
|
+
resolve(result)
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
child.on('error', (error) => {
|
|
1621
|
+
finish({ status: 1, error: error, stderr: stderr, stdout: stdout })
|
|
1622
|
+
})
|
|
1623
|
+
child.on('close', (code) => {
|
|
1624
|
+
finish({
|
|
1625
|
+
status: code ?? 1,
|
|
1626
|
+
stderr: stderr,
|
|
1627
|
+
stdout: stdout,
|
|
1628
|
+
})
|
|
1629
|
+
})
|
|
1630
|
+
})
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function writeGitSummary(stdout) {
|
|
1634
|
+
const lines = String(stdout || '')
|
|
1635
|
+
.split('\n')
|
|
1636
|
+
.map((line) => line.trimEnd())
|
|
1637
|
+
.filter(
|
|
1638
|
+
(line) =>
|
|
1639
|
+
/^\s*\[[^\]]+\]/.test(line) || /^\s*\d+ files? changed/.test(line),
|
|
1640
|
+
)
|
|
1641
|
+
if (!lines.length) return
|
|
1642
|
+
process.stdout.write(`${dim(lines.join('\n'))}\n`)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function paint(code, text) {
|
|
1646
|
+
if (!process.stdout.isTTY || process.env.NO_COLOR) return text
|
|
1647
|
+
return `\x1b[${code}m${text}\x1b[0m`
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function dim(text) {
|
|
1651
|
+
return paint('2', text)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function writeHookOutput(stdout, stderr) {
|
|
1655
|
+
if (stdout) {
|
|
1656
|
+
process.stdout.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`)
|
|
1657
|
+
}
|
|
1658
|
+
if (stderr) {
|
|
1659
|
+
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`)
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function sleep(ms) {
|
|
1664
|
+
return new Promise((resolve) => {
|
|
1665
|
+
setTimeout(resolve, ms)
|
|
1666
|
+
})
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1280
1669
|
function git(cwd, args, option = {}) {
|
|
1281
1670
|
for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
|
|
1282
1671
|
const result = spawnSync('git', args, {
|
|
1672
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
1283
1673
|
cwd: cwd,
|
|
1284
1674
|
encoding: 'utf8',
|
|
1285
1675
|
stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
|
|
1286
1676
|
})
|
|
1677
|
+
if (result.error) throw result.error
|
|
1287
1678
|
if (!result.status) return result
|
|
1288
1679
|
// git diff exits 1 when there are differences
|
|
1289
1680
|
if (result.status === 1 && args[0] === 'diff') return result
|
|
1290
1681
|
|
|
1291
1682
|
const stderr = result.stderr || ''
|
|
1292
|
-
if (!
|
|
1683
|
+
if (!retryIndexLock(cwd, attempt, stderr)) {
|
|
1293
1684
|
if (option.inherit && stderr) process.stderr.write(stderr)
|
|
1294
1685
|
throw new Error(stderr.trim() || `git ${args.join(' ')}`)
|
|
1295
1686
|
}
|
|
1296
|
-
|
|
1297
|
-
if (attempt === 1) {
|
|
1298
|
-
process.stderr.write('Waiting for git index.lock to clear…\n')
|
|
1299
|
-
}
|
|
1300
|
-
clearStaleIndexLock(cwd)
|
|
1301
1687
|
sleepSync(INDEX_LOCK_WAIT_MS * attempt)
|
|
1302
1688
|
}
|
|
1303
1689
|
}
|
|
1304
1690
|
|
|
1691
|
+
function retryIndexLock(cwd, attempt, stderr) {
|
|
1692
|
+
if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) return false
|
|
1693
|
+
if (attempt === 1) {
|
|
1694
|
+
process.stderr.write('Waiting for git index.lock to clear…\n')
|
|
1695
|
+
}
|
|
1696
|
+
clearStaleIndexLock(cwd)
|
|
1697
|
+
return true
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function gitOk(cwd, args) {
|
|
1701
|
+
return spawnSync('git', args, { cwd: cwd, encoding: 'utf8' }).status === 0
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function forEachBatch(items, visit) {
|
|
1705
|
+
for (let index = 0; index < items.length; index += GIT_PATH_BATCH) {
|
|
1706
|
+
if (visit(items.slice(index, index + GIT_PATH_BATCH)) === false) return
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1305
1710
|
function out(cwd, args) {
|
|
1306
1711
|
return (git(cwd, args).stdout || '').trim()
|
|
1307
1712
|
}
|
|
@@ -1309,3 +1714,22 @@ function out(cwd, args) {
|
|
|
1309
1714
|
function lines(cwd, args) {
|
|
1310
1715
|
return out(cwd, args).split('\n').filter(Boolean)
|
|
1311
1716
|
}
|
|
1717
|
+
|
|
1718
|
+
function linesFromPaths(cwd, prefix, files) {
|
|
1719
|
+
if (!files.length) return []
|
|
1720
|
+
const chunks = []
|
|
1721
|
+
forEachBatch(files, (batch) => {
|
|
1722
|
+
const text = out(cwd, [...prefix, '--', ...batch])
|
|
1723
|
+
if (text) chunks.push(text)
|
|
1724
|
+
})
|
|
1725
|
+
return chunks.join('\n').split('\n').filter(Boolean)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function tryOut(cwd, args) {
|
|
1729
|
+
try {
|
|
1730
|
+
return out(cwd, args)
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
if (/ENOBUFS|maxBuffer/i.test(String(error?.message || ''))) return ''
|
|
1733
|
+
throw error
|
|
1734
|
+
}
|
|
1735
|
+
}
|