@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
|
-
|
|
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.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.
|
|
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"
|
|
@@ -18,8 +18,9 @@ const releaseNotesFromIssuesScript = join(
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
// Ссылки на задачи из PR.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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) {
|
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
|
)
|
|
@@ -443,13 +458,30 @@ function buildSourceDiff(cwd, files) {
|
|
|
443
458
|
}
|
|
444
459
|
|
|
445
460
|
const parts = []
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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
|
|
816
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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 (!
|
|
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
|
+
}
|