@hifullmoon/aicommit 2.1.0 → 2.2.1
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/CHANGELOG.md +20 -1
- package/README.md +28 -3
- package/README.zh-CN.md +28 -3
- package/docs/distribution.md +1 -1
- package/package.json +1 -1
- package/presets/provider-presets.json +61 -1
- package/src/completion.js +7 -3
- package/src/context.js +103 -15
- package/src/git.js +1 -1
- package/src/policy-command.js +13 -2
- package/src/policy.js +76 -2
- package/src/split-plan.js +16 -3
- package/src/split.js +40 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.2.1] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Combined repository commit policies with commitlint constraints without allowing either source to relax the other, including correct `never` and complete-header length handling.
|
|
12
|
+
- Hardened split transactions against changes during snapshot capture, restricted plan exports to safe locations, and prevented existing output files from being overwritten.
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- Redacted credential assignments with quoted JSON or YAML keys before diffs and untracked previews are sent to a model.
|
|
17
|
+
|
|
18
|
+
## [2.2.0] - 2026-08-28
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Expanded bundled model presets for OpenAI, DeepSeek, OpenRouter, and Ollama, including current GPT, Claude, Gemini, Qwen, GLM, Kimi, Grok, DeepSeek, and local open-model choices.
|
|
23
|
+
|
|
7
24
|
## [2.1.0] - 2026-08-27
|
|
8
25
|
|
|
9
26
|
### Changed
|
|
@@ -147,7 +164,9 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
147
164
|
- Added file-level split planning and execution with Git-state concurrency checks.
|
|
148
165
|
- Added provider presets and user/project configuration boundaries.
|
|
149
166
|
|
|
150
|
-
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.1
|
|
167
|
+
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.2.1...HEAD
|
|
168
|
+
[2.2.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.1
|
|
169
|
+
[2.2.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.0
|
|
151
170
|
[2.1.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.1.0
|
|
152
171
|
[2.0.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.0.1
|
|
153
172
|
[2.0.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.0.0
|
package/README.md
CHANGED
|
@@ -217,7 +217,7 @@ Requests retry only transient failures: HTTP 429, recoverable 5xx responses, net
|
|
|
217
217
|
|
|
218
218
|
### Built-in provider defaults
|
|
219
219
|
|
|
220
|
-
Setup uses validated provider defaults shipped with AICommit. For another OpenAI-compatible service, add a named provider directly to the user config; no separate manifest installation or third-party executable code is required.
|
|
220
|
+
Setup uses validated provider defaults shipped with AICommit. OpenAI includes GPT-4o and GPT-5.6 profiles; DeepSeek includes V4 Flash and Pro; OpenRouter includes Auto plus current GPT, Claude, Gemini, DeepSeek, Qwen, GLM, Kimi, and Grok choices; Ollama includes Qwen 3, DeepSeek R1, and GPT-OSS profiles. These are starting points: setup keeps the model IDs editable and existing user-defined profiles take precedence. For another OpenAI-compatible service, add a named provider directly to the user config; no separate manifest installation or third-party executable code is required.
|
|
221
221
|
|
|
222
222
|
### Saving tokens
|
|
223
223
|
|
|
@@ -306,13 +306,38 @@ Completion scripts are generated from the installed CLI and contain no configura
|
|
|
306
306
|
# Bash
|
|
307
307
|
aicommit completion bash > ~/.local/share/bash-completion/completions/aicommit
|
|
308
308
|
|
|
309
|
-
# Zsh
|
|
309
|
+
# Zsh
|
|
310
|
+
mkdir -p ~/.zfunc
|
|
310
311
|
aicommit completion zsh > ~/.zfunc/_aicommit
|
|
311
312
|
|
|
312
313
|
# Fish
|
|
313
314
|
aicommit completion fish > ~/.config/fish/completions/aicommit.fish
|
|
314
315
|
```
|
|
315
316
|
|
|
317
|
+
For Zsh, add the completion directory to `fpath` before the line that initializes Oh My Zsh or another completion framework. For Oh My Zsh, place this in `~/.zshrc` before `source "$ZSH/oh-my-zsh.sh"`:
|
|
318
|
+
|
|
319
|
+
```zsh
|
|
320
|
+
fpath=("$HOME/.zfunc" $fpath)
|
|
321
|
+
source "$ZSH/oh-my-zsh.sh"
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
If no Zsh framework initializes completion, use this instead:
|
|
325
|
+
|
|
326
|
+
```zsh
|
|
327
|
+
fpath=("$HOME/.zfunc" $fpath)
|
|
328
|
+
autoload -Uz compinit
|
|
329
|
+
compinit
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Restart Zsh after editing the file. If a previous completion cache prevents discovery, remove only that cache before restarting:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
rm -f "$HOME"/.zcompdump*
|
|
336
|
+
exec zsh
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Verify the registration with `whence -w _aicommit`; it should print `_aicommit: function`. Then type `aicommit`, add a space, and press `Tab`.
|
|
340
|
+
|
|
316
341
|
### Machine-readable output
|
|
317
342
|
|
|
318
343
|
Use `--output=json` for scripts and CI. Commit and split flows also require `--yes`, preventing a machine consumer from hanging on an interactive prompt. stdout contains exactly one JSON object; progress, debug details, and diagnostics go to stderr. `doctor --output=json` does not require `--yes`.
|
|
@@ -390,7 +415,7 @@ When reasoning mode is `on` (including via `--reasoning=<level>`), aicommit requ
|
|
|
390
415
|
|
|
391
416
|
`aicommit split` (also available as the explicit `aicommit split run`) asks whether to group the staged index snapshot or all staged, unstaged, and untracked changes into file-level logical commits. Use `--scope=staged` or `--scope=all` when the boundary must be explicit, including every non-interactive run. You can review the plan, regenerate messages for selected groups, or edit the plan as JSON before committing. Sensitive-content detection fails closed before a non-interactive provider request or automatic staging.
|
|
392
417
|
|
|
393
|
-
For an auditable two-step flow, `aicommit split plan --scope=staged|all --file=<path>` exports a versioned JSON artifact, and `aicommit split apply --file=<path>` rechecks its base commit, change set, and content fingerprint before touching the index. Keep plan files outside the worktree or under `.git
|
|
418
|
+
For an auditable two-step flow, `aicommit split plan --scope=staged|all --file=<path>` exports a versioned JSON artifact, and `aicommit split apply --file=<path>` rechecks its base commit, change set, and content fingerprint before touching the index. Keep plan files outside the worktree or under the dedicated `.git/aicommit/` directory so they cannot become part of their own plan. Export never overwrites an existing destination.
|
|
394
419
|
|
|
395
420
|
Execution uses temporary indexes and a code-free checkpoint under `.git/aicommit`. A hook, Git error, interruption, or crash leaves completed commits in history and preserves the pending snapshot; the failure report shows checkpointed, in-flight, pending, and current worktree/index state. Resolve the cause and run `aicommit split resume`. Resume reconciles the possible post-commit crash window before creating anything else, so a completed group is neither duplicated nor omitted. If you intentionally finished or replaced the interrupted work through another Git workflow, run `aicommit split abort`; it removes only the stale checkpoint and never rewrites HEAD, the index, or the worktree. New committing split runs detect a checkpoint before contacting the provider. If planning or preflight fails before the first group, no split commit is created and the real index remains unchanged.
|
|
396
421
|
|
package/README.zh-CN.md
CHANGED
|
@@ -219,7 +219,7 @@ aicommit policy check --range=origin/main..HEAD --output=json
|
|
|
219
219
|
|
|
220
220
|
### 内置 Provider 默认值
|
|
221
221
|
|
|
222
|
-
setup 使用随 AICommit 一起发布并经过校验的 Provider
|
|
222
|
+
setup 使用随 AICommit 一起发布并经过校验的 Provider 默认值。OpenAI 预置 GPT-4o 与 GPT-5.6 系列,DeepSeek 预置 V4 Flash 与 Pro,OpenRouter 预置 Auto 以及当前常用的 GPT、Claude、Gemini、DeepSeek、Qwen、GLM、Kimi 和 Grok,Ollama 预置 Qwen 3、DeepSeek R1 与 GPT-OSS。这些配置只是起点:setup 仍允许编辑模型 ID,已有的用户自定义模型配置也会优先保留。需要其他 OpenAI 兼容服务时,直接在用户配置中增加命名 Provider;无需安装额外清单或执行第三方代码。
|
|
223
223
|
|
|
224
224
|
### 节省 Token
|
|
225
225
|
|
|
@@ -308,13 +308,38 @@ aicommit -h # 帮助
|
|
|
308
308
|
# Bash
|
|
309
309
|
aicommit completion bash > ~/.local/share/bash-completion/completions/aicommit
|
|
310
310
|
|
|
311
|
-
# Zsh
|
|
311
|
+
# Zsh
|
|
312
|
+
mkdir -p ~/.zfunc
|
|
312
313
|
aicommit completion zsh > ~/.zfunc/_aicommit
|
|
313
314
|
|
|
314
315
|
# Fish
|
|
315
316
|
aicommit completion fish > ~/.config/fish/completions/aicommit.fish
|
|
316
317
|
```
|
|
317
318
|
|
|
319
|
+
Zsh 还需要在 Oh My Zsh 或其他补全框架初始化之前,将补全目录加入 `fpath`。使用 Oh My Zsh 时,请在 `~/.zshrc` 的 `source "$ZSH/oh-my-zsh.sh"` 之前加入:
|
|
320
|
+
|
|
321
|
+
```zsh
|
|
322
|
+
fpath=("$HOME/.zfunc" $fpath)
|
|
323
|
+
source "$ZSH/oh-my-zsh.sh"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
如果没有使用负责初始化补全的 Zsh 框架,则改用:
|
|
327
|
+
|
|
328
|
+
```zsh
|
|
329
|
+
fpath=("$HOME/.zfunc" $fpath)
|
|
330
|
+
autoload -Uz compinit
|
|
331
|
+
compinit
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
修改后重启 Zsh。如果旧补全缓存导致脚本仍未被发现,可以只清理该缓存后再重启:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
rm -f "$HOME"/.zcompdump*
|
|
338
|
+
exec zsh
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
运行 `whence -w _aicommit` 验证注册结果;正常应输出 `_aicommit: function`。随后输入 `aicommit`、空一格并按 `Tab` 即可使用补全。
|
|
342
|
+
|
|
318
343
|
### 机器可读输出
|
|
319
344
|
|
|
320
345
|
脚本和 CI 请使用 `--output=json`。提交和 split 流程还必须使用 `--yes`,避免机器消费者卡在交互提示上。stdout 只包含一个 JSON 对象;进度、调试信息和诊断输出会写入 stderr。`doctor --output=json` 不要求 `--yes`。
|
|
@@ -392,7 +417,7 @@ aicommit completion fish > ~/.config/fish/completions/aicommit.fish
|
|
|
392
417
|
|
|
393
418
|
`aicommit split`(也可以显式写成 `aicommit split run`)会询问是对暂存 index 快照分组,还是对全部已暂存、未暂存和未跟踪变更做文件级分组。边界必须明确时请使用 `--scope=staged` 或 `--scope=all`,所有非交互运行都应显式指定范围。提交前可以审阅计划、为选中的组重新生成消息,或直接编辑 JSON 计划。敏感内容检测会在非交互 Provider 请求或自动暂存前 fail closed。
|
|
394
419
|
|
|
395
|
-
如需可审计的两步流程,使用 `aicommit split plan --scope=staged|all --file=<path>` 导出版本化 JSON 工件,再用 `aicommit split apply --file=<path>` 在接触 index 前重新校验 base commit
|
|
420
|
+
如需可审计的两步流程,使用 `aicommit split plan --scope=staged|all --file=<path>` 导出版本化 JSON 工件,再用 `aicommit split apply --file=<path>` 在接触 index 前重新校验 base commit、变更集和内容指纹。计划文件应保存在工作区之外或专用的 `.git/aicommit/` 目录下,避免被纳入自身计划;导出不会覆盖已有目标文件。
|
|
396
421
|
|
|
397
422
|
执行过程使用临时 index,并在 `.git/aicommit` 下保存不含代码内容的 checkpoint。hook、Git 错误、中断或崩溃发生后,已完成提交仍保留在历史中,待处理快照也会保留;失败报告会显示已 checkpoint、执行中、待处理,以及当前工作区 / index 状态。解决问题后运行 `aicommit split resume`。恢复流程会先协调“提交完成后崩溃”的可能窗口,再创建任何新提交,因此不会重复或遗漏已完成分组。如果你通过其他 Git 流程有意完成或替换了中断工作,请运行 `aicommit split abort`;它只删除过期 checkpoint,绝不会改写 HEAD、index 或工作区。新的 split 提交流程会在联系 Provider 前检测现有 checkpoint。如果规划或预检在第一组之前失败,不会创建任何 split 提交,真实 index 也保持不变。
|
|
398
423
|
|
package/docs/distribution.md
CHANGED
|
@@ -26,7 +26,7 @@ The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN
|
|
|
26
26
|
```bash
|
|
27
27
|
workdir=$(mktemp -d)
|
|
28
28
|
cd "$workdir"
|
|
29
|
-
npm install --package-lock-only @hifullmoon/aicommit@2.1
|
|
29
|
+
npm install --package-lock-only @hifullmoon/aicommit@2.2.1
|
|
30
30
|
npm audit signatures
|
|
31
31
|
```
|
|
32
32
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"kind": "aicommit-provider-presets",
|
|
3
3
|
"schemaVersion": 2,
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"compatibility": {
|
|
6
6
|
"coreMinimum": "1.5.1",
|
|
7
7
|
"coreMaximumExclusive": "3.0.0",
|
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
"gpt-4o": {
|
|
19
19
|
"label": "GPT-4o",
|
|
20
20
|
"modelId": "gpt-4o"
|
|
21
|
+
},
|
|
22
|
+
"gpt-5.6-sol": {
|
|
23
|
+
"label": "GPT-5.6 Sol",
|
|
24
|
+
"modelId": "gpt-5.6-sol"
|
|
25
|
+
},
|
|
26
|
+
"gpt-5.6-terra": {
|
|
27
|
+
"label": "GPT-5.6 Terra",
|
|
28
|
+
"modelId": "gpt-5.6-terra"
|
|
29
|
+
},
|
|
30
|
+
"gpt-5.6-luna": {
|
|
31
|
+
"label": "GPT-5.6 Luna",
|
|
32
|
+
"modelId": "gpt-5.6-luna"
|
|
21
33
|
}
|
|
22
34
|
}
|
|
23
35
|
},
|
|
@@ -31,6 +43,10 @@
|
|
|
31
43
|
"chat": {
|
|
32
44
|
"label": "DeepSeek Chat",
|
|
33
45
|
"modelId": "deepseek-v4-flash"
|
|
46
|
+
},
|
|
47
|
+
"pro": {
|
|
48
|
+
"label": "DeepSeek V4 Pro",
|
|
49
|
+
"modelId": "deepseek-v4-pro"
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
},
|
|
@@ -43,6 +59,42 @@
|
|
|
43
59
|
"models": {
|
|
44
60
|
"default": {
|
|
45
61
|
"modelId": "openai/gpt-4o-mini"
|
|
62
|
+
},
|
|
63
|
+
"auto": {
|
|
64
|
+
"label": "OpenRouter Auto",
|
|
65
|
+
"modelId": "openrouter/auto"
|
|
66
|
+
},
|
|
67
|
+
"gpt-5.6-terra": {
|
|
68
|
+
"label": "GPT-5.6 Terra",
|
|
69
|
+
"modelId": "openai/gpt-5.6-terra"
|
|
70
|
+
},
|
|
71
|
+
"claude-sonnet-5": {
|
|
72
|
+
"label": "Claude Sonnet 5",
|
|
73
|
+
"modelId": "anthropic/claude-sonnet-5"
|
|
74
|
+
},
|
|
75
|
+
"gemini-3.7-flash": {
|
|
76
|
+
"label": "Gemini 3.7 Flash",
|
|
77
|
+
"modelId": "google/gemini-3.7-flash"
|
|
78
|
+
},
|
|
79
|
+
"deepseek-v4-pro": {
|
|
80
|
+
"label": "DeepSeek V4 Pro",
|
|
81
|
+
"modelId": "deepseek/deepseek-v4-pro"
|
|
82
|
+
},
|
|
83
|
+
"qwen3.8-max": {
|
|
84
|
+
"label": "Qwen3.8 Max",
|
|
85
|
+
"modelId": "qwen/qwen3.8-max"
|
|
86
|
+
},
|
|
87
|
+
"glm-5.3": {
|
|
88
|
+
"label": "GLM-5.3",
|
|
89
|
+
"modelId": "z-ai/glm-5.3"
|
|
90
|
+
},
|
|
91
|
+
"kimi-k3": {
|
|
92
|
+
"label": "Kimi K3",
|
|
93
|
+
"modelId": "moonshotai/kimi-k3"
|
|
94
|
+
},
|
|
95
|
+
"grok-4.6": {
|
|
96
|
+
"label": "Grok 4.6",
|
|
97
|
+
"modelId": "x-ai/grok-4.6"
|
|
46
98
|
}
|
|
47
99
|
}
|
|
48
100
|
},
|
|
@@ -86,6 +138,14 @@
|
|
|
86
138
|
"qwen": {
|
|
87
139
|
"label": "Qwen 3 8B",
|
|
88
140
|
"modelId": "qwen3:8b"
|
|
141
|
+
},
|
|
142
|
+
"deepseek": {
|
|
143
|
+
"label": "DeepSeek R1 8B",
|
|
144
|
+
"modelId": "deepseek-r1:8b"
|
|
145
|
+
},
|
|
146
|
+
"gpt-oss": {
|
|
147
|
+
"label": "GPT-OSS 20B",
|
|
148
|
+
"modelId": "gpt-oss:20b"
|
|
89
149
|
}
|
|
90
150
|
}
|
|
91
151
|
}
|
package/src/completion.js
CHANGED
|
@@ -46,7 +46,9 @@ complete -F _aicommit aicommit
|
|
|
46
46
|
|
|
47
47
|
const ZSH = `#compdef aicommit
|
|
48
48
|
_aicommit() {
|
|
49
|
+
local curcontext="$curcontext" state state_descr line
|
|
49
50
|
local -a commands options
|
|
51
|
+
typeset -A opt_args
|
|
50
52
|
commands=(
|
|
51
53
|
'setup:interactive configuration wizard'
|
|
52
54
|
'doctor:diagnose configuration and connectivity'
|
|
@@ -71,15 +73,17 @@ _aicommit() {
|
|
|
71
73
|
'--output=[output mode]:output:(text json)'
|
|
72
74
|
'--debug[show debug details]'
|
|
73
75
|
)
|
|
74
|
-
_arguments -C $options '*::argument:->args'
|
|
76
|
+
_arguments -C $options '1:command:->command' '*::argument:->args'
|
|
75
77
|
case $state in
|
|
78
|
+
command)
|
|
79
|
+
_describe 'command' commands
|
|
80
|
+
;;
|
|
76
81
|
args)
|
|
77
|
-
case $
|
|
82
|
+
case $line[1] in
|
|
78
83
|
config) _values 'config action' show validate path ;;
|
|
79
84
|
policy) _values 'policy action' template check ;;
|
|
80
85
|
completion) _values 'shell' bash zsh fish ;;
|
|
81
86
|
split) _values 'split action' run plan apply resume abort ;;
|
|
82
|
-
*) _describe 'command' commands ;;
|
|
83
87
|
esac
|
|
84
88
|
;;
|
|
85
89
|
esac
|
package/src/context.js
CHANGED
|
@@ -381,20 +381,22 @@ function quotedValues(text) {
|
|
|
381
381
|
|
|
382
382
|
function enumRule(text, name) {
|
|
383
383
|
const index = text.search(new RegExp(`['"]?${name}['"]?\\s*:`));
|
|
384
|
-
if (index < 0) return
|
|
384
|
+
if (index < 0) return null;
|
|
385
385
|
const window = text.slice(index, index + 3000);
|
|
386
386
|
const nested = window.match(
|
|
387
|
-
/\[\s*[012]\s*,\s*['"](
|
|
387
|
+
/\[\s*([012])\s*,\s*['"](always|never)['"]\s*,\s*\[([\s\S]*?)\]\s*\]/i,
|
|
388
388
|
);
|
|
389
|
-
|
|
389
|
+
if (!nested || nested[1] === '0') return null;
|
|
390
|
+
return { condition: nested[2].toLowerCase(), values: quotedValues(nested[3]) };
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
function numberRule(text, name) {
|
|
393
394
|
const index = text.search(new RegExp(`['"]?${name}['"]?\\s*:`));
|
|
394
395
|
if (index < 0) return null;
|
|
395
396
|
const window = text.slice(index, index + 500);
|
|
396
|
-
const match = window.match(/\[\s*[012]\s*,\s*['"](
|
|
397
|
-
|
|
397
|
+
const match = window.match(/\[\s*([012])\s*,\s*['"](always|never)['"]\s*,\s*(\d{1,3})\s*\]/i);
|
|
398
|
+
if (!match || match[1] === '0') return null;
|
|
399
|
+
return { condition: match[2].toLowerCase(), value: Number(match[3]) };
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
export function detectCommitlintConstraints(
|
|
@@ -410,22 +412,53 @@ export function detectCommitlintConstraints(
|
|
|
410
412
|
) {
|
|
411
413
|
continue;
|
|
412
414
|
}
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
|
|
415
|
+
const typeRule = enumRule(text, 'type-enum');
|
|
416
|
+
const scopeRule = enumRule(text, 'scope-enum');
|
|
417
|
+
const subjectLengthRule = numberRule(text, 'subject-max-length');
|
|
418
|
+
const headerLengthRule = numberRule(text, 'header-max-length');
|
|
419
|
+
const validTypes = (typeRule?.values || []).filter((value) => /^[a-z][a-z0-9-]*$/.test(value));
|
|
420
|
+
const validScopes = (scopeRule?.values || []).filter((value) => /^[a-z0-9._/-]+$/i.test(value));
|
|
421
|
+
const types = typeRule?.condition === 'always' ? validTypes : [];
|
|
422
|
+
const disallowedTypes = typeRule?.condition === 'never' ? validTypes : [];
|
|
423
|
+
const scopes = scopeRule?.condition === 'always' ? validScopes : [];
|
|
424
|
+
const disallowedScopes = scopeRule?.condition === 'never' ? validScopes : [];
|
|
425
|
+
const subjectMaxLength =
|
|
426
|
+
subjectLengthRule?.condition === 'always' ? subjectLengthRule.value : null;
|
|
427
|
+
const headerMaxLength =
|
|
428
|
+
headerLengthRule?.condition === 'always' ? headerLengthRule.value : null;
|
|
429
|
+
const unsupported = [];
|
|
430
|
+
if (subjectLengthRule?.condition === 'never') unsupported.push('subject-max-length: never');
|
|
431
|
+
if (headerLengthRule?.condition === 'never') unsupported.push('header-max-length: never');
|
|
432
|
+
if (
|
|
433
|
+
!types.length &&
|
|
434
|
+
!disallowedTypes.length &&
|
|
435
|
+
!scopes.length &&
|
|
436
|
+
!disallowedScopes.length &&
|
|
437
|
+
!subjectMaxLength &&
|
|
438
|
+
!headerMaxLength &&
|
|
439
|
+
!unsupported.length
|
|
440
|
+
) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
418
443
|
const lines = [`Detected commitlint constraints from ${path}:`];
|
|
419
444
|
if (types.length) lines.push(`- allowed types: ${types.join(', ')}`);
|
|
445
|
+
if (disallowedTypes.length) lines.push(`- disallowed types: ${disallowedTypes.join(', ')}`);
|
|
420
446
|
if (scopes.length) lines.push(`- allowed scopes: ${scopes.join(', ')}`);
|
|
447
|
+
if (disallowedScopes.length) {
|
|
448
|
+
lines.push(`- disallowed scopes: ${disallowedScopes.join(', ')}`);
|
|
449
|
+
}
|
|
421
450
|
if (subjectMaxLength) lines.push(`- subject max length: ${subjectMaxLength}`);
|
|
422
451
|
if (headerMaxLength) lines.push(`- header max length: ${headerMaxLength}`);
|
|
452
|
+
if (unsupported.length) lines.push(`- unsupported inverted rules: ${unsupported.join(', ')}`);
|
|
423
453
|
return {
|
|
424
454
|
path,
|
|
425
455
|
types,
|
|
456
|
+
disallowedTypes,
|
|
426
457
|
scopes,
|
|
458
|
+
disallowedScopes,
|
|
427
459
|
subjectMaxLength,
|
|
428
460
|
headerMaxLength,
|
|
461
|
+
unsupported,
|
|
429
462
|
text: truncate(lines.join('\n'), settings.maxChars),
|
|
430
463
|
};
|
|
431
464
|
}
|
|
@@ -436,17 +469,72 @@ export function applyCommitlintPolicy(commitPolicy, constraints, fallbackLanguag
|
|
|
436
469
|
if (!constraints) return commitPolicy;
|
|
437
470
|
const current = normalizeCommitPolicy(commitPolicy, fallbackLanguage);
|
|
438
471
|
const patch = {};
|
|
439
|
-
if (constraints.
|
|
472
|
+
if (constraints.unsupported?.length) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`Invalid config: unsupported inverted commitlint rules: ${constraints.unsupported.join(', ')}.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let types = [...current.types];
|
|
479
|
+
if (constraints.types?.length) {
|
|
480
|
+
types = types.filter((type) => constraints.types.includes(type));
|
|
481
|
+
}
|
|
482
|
+
if (constraints.disallowedTypes?.length) {
|
|
483
|
+
types = types.filter((type) => !constraints.disallowedTypes.includes(type));
|
|
484
|
+
}
|
|
485
|
+
if (!types.length) {
|
|
486
|
+
throw new Error('Invalid config: commitPolicy and commitlint allow no common commit types.');
|
|
487
|
+
}
|
|
488
|
+
if (types.length !== current.types.length) patch.types = types;
|
|
489
|
+
|
|
490
|
+
let scopeMode = current.scope.mode;
|
|
491
|
+
let scopes = [...current.scope.values];
|
|
492
|
+
const disallowedScopes = new Set(current.scope.disallowedValues || []);
|
|
493
|
+
const hasFiniteScopeAllowlist = Boolean(
|
|
494
|
+
current.scope.values.length || constraints.scopes?.length,
|
|
495
|
+
);
|
|
440
496
|
if (constraints.scopes?.length) {
|
|
441
|
-
|
|
497
|
+
scopes = scopes.length
|
|
498
|
+
? scopes.filter((scope) => constraints.scopes.includes(scope))
|
|
499
|
+
: [...constraints.scopes];
|
|
500
|
+
}
|
|
501
|
+
for (const scope of constraints.disallowedScopes || []) disallowedScopes.add(scope);
|
|
502
|
+
if (scopes.length) scopes = scopes.filter((scope) => !disallowedScopes.has(scope));
|
|
503
|
+
if (!scopes.length && hasFiniteScopeAllowlist) {
|
|
504
|
+
if (scopeMode === 'required') {
|
|
505
|
+
throw new Error('Invalid config: commitPolicy and commitlint allow no common scopes.');
|
|
506
|
+
}
|
|
507
|
+
scopeMode = 'forbidden';
|
|
508
|
+
disallowedScopes.clear();
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
scopeMode !== current.scope.mode ||
|
|
512
|
+
JSON.stringify(scopes) !== JSON.stringify(current.scope.values) ||
|
|
513
|
+
JSON.stringify([...disallowedScopes]) !== JSON.stringify(current.scope.disallowedValues || [])
|
|
514
|
+
) {
|
|
515
|
+
const nextScope = {
|
|
442
516
|
...current.scope,
|
|
443
|
-
|
|
517
|
+
mode: scopeMode,
|
|
518
|
+
values: scopes,
|
|
519
|
+
...(disallowedScopes.size ? { disallowedValues: [...disallowedScopes] } : {}),
|
|
444
520
|
};
|
|
521
|
+
if (!disallowedScopes.size) delete nextScope.disallowedValues;
|
|
522
|
+
patch.scope = nextScope;
|
|
445
523
|
}
|
|
446
|
-
if (constraints.subjectMaxLength) {
|
|
524
|
+
if (constraints.subjectMaxLength || constraints.headerMaxLength) {
|
|
447
525
|
patch.subject = {
|
|
448
526
|
...current.subject,
|
|
449
|
-
maxLength:
|
|
527
|
+
maxLength: constraints.subjectMaxLength
|
|
528
|
+
? Math.min(current.subject.maxLength, constraints.subjectMaxLength)
|
|
529
|
+
: current.subject.maxLength,
|
|
530
|
+
...(constraints.headerMaxLength
|
|
531
|
+
? {
|
|
532
|
+
headerMaxLength: Math.min(
|
|
533
|
+
current.subject.headerMaxLength ?? Infinity,
|
|
534
|
+
constraints.headerMaxLength,
|
|
535
|
+
),
|
|
536
|
+
}
|
|
537
|
+
: {}),
|
|
450
538
|
};
|
|
451
539
|
}
|
|
452
540
|
const merged = mergeCommitPolicy(current, patch);
|
package/src/git.js
CHANGED
|
@@ -336,7 +336,7 @@ const SENSITIVE_FILE_RE =
|
|
|
336
336
|
const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/i;
|
|
337
337
|
const AWS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
|
|
338
338
|
const ASSIGNED_SECRET_RE =
|
|
339
|
-
/(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|token)\b
|
|
339
|
+
/(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|token)\b["']?\s*[:=]\s*["']?)([^\s,"'}]{8,})/gi;
|
|
340
340
|
|
|
341
341
|
export function isSensitiveFile(path) {
|
|
342
342
|
const normalized = String(path || '').replace(/\\/g, '/');
|
package/src/policy-command.js
CHANGED
|
@@ -21,8 +21,19 @@ function normalizedPolicyDocument(policy) {
|
|
|
21
21
|
return {
|
|
22
22
|
version: policy.version,
|
|
23
23
|
types: [...policy.types],
|
|
24
|
-
scope: {
|
|
25
|
-
|
|
24
|
+
scope: {
|
|
25
|
+
mode: policy.scope.mode,
|
|
26
|
+
values: [...policy.scope.values],
|
|
27
|
+
...(policy.scope.disallowedValues?.length
|
|
28
|
+
? { disallowedValues: [...policy.scope.disallowedValues] }
|
|
29
|
+
: {}),
|
|
30
|
+
},
|
|
31
|
+
subject: {
|
|
32
|
+
maxLength: policy.subject.maxLength,
|
|
33
|
+
...(policy.subject.headerMaxLength
|
|
34
|
+
? { headerMaxLength: policy.subject.headerMaxLength }
|
|
35
|
+
: {}),
|
|
36
|
+
},
|
|
26
37
|
body: { mode: policy.body.mode, maxLines: policy.body.maxLines },
|
|
27
38
|
breakingChange: policy.breakingChange,
|
|
28
39
|
language: policy.language,
|
package/src/policy.js
CHANGED
|
@@ -46,7 +46,13 @@ function clonePolicy(policy) {
|
|
|
46
46
|
return {
|
|
47
47
|
...policy,
|
|
48
48
|
types: [...policy.types],
|
|
49
|
-
scope: {
|
|
49
|
+
scope: {
|
|
50
|
+
...policy.scope,
|
|
51
|
+
values: [...policy.scope.values],
|
|
52
|
+
...(policy.scope.disallowedValues
|
|
53
|
+
? { disallowedValues: [...policy.scope.disallowedValues] }
|
|
54
|
+
: {}),
|
|
55
|
+
},
|
|
50
56
|
subject: { ...policy.subject },
|
|
51
57
|
body: { ...policy.body },
|
|
52
58
|
};
|
|
@@ -69,6 +75,14 @@ export function mergeCommitPolicy(base = DEFAULT_COMMIT_POLICY, override = {}) {
|
|
|
69
75
|
: base.scope.values,
|
|
70
76
|
}
|
|
71
77
|
: override.scope;
|
|
78
|
+
if (object(scope)) {
|
|
79
|
+
if (Array.isArray(scope.values)) scope.values = [...scope.values];
|
|
80
|
+
if (Array.isArray(scope.disallowedValues)) {
|
|
81
|
+
scope.disallowedValues = [...scope.disallowedValues];
|
|
82
|
+
} else {
|
|
83
|
+
delete scope.disallowedValues;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
72
86
|
const subject =
|
|
73
87
|
override.subject === undefined
|
|
74
88
|
? { ...base.subject }
|
|
@@ -111,6 +125,25 @@ function assertStringArray(values, name, { max = 64, pattern } = {}) {
|
|
|
111
125
|
}
|
|
112
126
|
}
|
|
113
127
|
|
|
128
|
+
function assertOptionalStringArray(values, name, { max = 64, pattern } = {}) {
|
|
129
|
+
if (values === undefined) return;
|
|
130
|
+
if (!Array.isArray(values) || values.length > max) {
|
|
131
|
+
throw new Error(`Invalid config "${name}": expected at most ${max} strings.`);
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
values.some(
|
|
135
|
+
(value) =>
|
|
136
|
+
typeof value !== 'string' ||
|
|
137
|
+
!value ||
|
|
138
|
+
value.length > 64 ||
|
|
139
|
+
(pattern && !pattern.test(value)),
|
|
140
|
+
) ||
|
|
141
|
+
new Set(values).size !== values.length
|
|
142
|
+
) {
|
|
143
|
+
throw new Error(`Invalid config "${name}": contains an invalid or duplicate value.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
114
147
|
export function validateCommitPolicyConfig(value) {
|
|
115
148
|
if (!object(value)) throw new Error('Invalid config "commitPolicy": expected an object.');
|
|
116
149
|
if (value.version !== 1) {
|
|
@@ -137,6 +170,14 @@ export function validateCommitPolicyConfig(value) {
|
|
|
137
170
|
) {
|
|
138
171
|
throw new Error('Invalid config "commitPolicy.scope.values": contains an invalid value.');
|
|
139
172
|
}
|
|
173
|
+
assertOptionalStringArray(value.scope.disallowedValues, 'commitPolicy.scope.disallowedValues', {
|
|
174
|
+
pattern: SCOPE_RE,
|
|
175
|
+
});
|
|
176
|
+
if (value.scope.disallowedValues?.some((scope) => value.scope.values.includes(scope))) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'Invalid config "commitPolicy.scope": allowed and disallowed values must not overlap.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
140
181
|
|
|
141
182
|
if (!object(value.subject)) {
|
|
142
183
|
throw new Error('Invalid config "commitPolicy.subject": expected an object.');
|
|
@@ -150,6 +191,16 @@ export function validateCommitPolicyConfig(value) {
|
|
|
150
191
|
'Invalid config "commitPolicy.subject.maxLength": expected an integer between 1 and 200.',
|
|
151
192
|
);
|
|
152
193
|
}
|
|
194
|
+
if (
|
|
195
|
+
value.subject.headerMaxLength !== undefined &&
|
|
196
|
+
(!Number.isInteger(value.subject.headerMaxLength) ||
|
|
197
|
+
value.subject.headerMaxLength < 1 ||
|
|
198
|
+
value.subject.headerMaxLength > 1000)
|
|
199
|
+
) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'Invalid config "commitPolicy.subject.headerMaxLength": expected an integer between 1 and 1000.',
|
|
202
|
+
);
|
|
203
|
+
}
|
|
153
204
|
|
|
154
205
|
if (!object(value.body)) {
|
|
155
206
|
throw new Error('Invalid config "commitPolicy.body": expected an object.');
|
|
@@ -197,7 +248,10 @@ function scopeRule(policy) {
|
|
|
197
248
|
const allowed = policy.scope.values.length
|
|
198
249
|
? `; allowed values: ${policy.scope.values.join(', ')}`
|
|
199
250
|
: '';
|
|
200
|
-
|
|
251
|
+
const disallowed = policy.scope.disallowedValues?.length
|
|
252
|
+
? `; disallowed values: ${policy.scope.disallowedValues.join(', ')}`
|
|
253
|
+
: '';
|
|
254
|
+
return `${policy.scope.mode}${allowed}${disallowed}`;
|
|
201
255
|
}
|
|
202
256
|
|
|
203
257
|
export function buildCommitPolicyPrompt(policy, customPrompt = '') {
|
|
@@ -219,6 +273,9 @@ export function buildCommitPolicyPrompt(policy, customPrompt = '') {
|
|
|
219
273
|
`- Allowed types: ${policy.types.join(', ')}`,
|
|
220
274
|
`- Scope: ${scopeRule(policy)}`,
|
|
221
275
|
`- Subject text: required, at most ${policy.subject.maxLength} characters`,
|
|
276
|
+
...(policy.subject.headerMaxLength
|
|
277
|
+
? [`- Complete header: at most ${policy.subject.headerMaxLength} characters`]
|
|
278
|
+
: []),
|
|
222
279
|
`- Body: ${policy.body.mode}, at most ${policy.body.maxLines} non-empty lines, separated from the subject by a blank line`,
|
|
223
280
|
`- Breaking change: ${policy.breakingChange}; use "!" and/or a "BREAKING CHANGE:" footer`,
|
|
224
281
|
`- Language: ${targetLanguage}`,
|
|
@@ -344,6 +401,9 @@ export function validateCommitCandidate(message, { policy, diff = '' } = {}) {
|
|
|
344
401
|
),
|
|
345
402
|
);
|
|
346
403
|
}
|
|
404
|
+
if (parsed.scope && normalizedPolicy.scope.disallowedValues?.includes(parsed.scope)) {
|
|
405
|
+
issues.push(issue('scope_value', `Scope "${parsed.scope}" is forbidden.`));
|
|
406
|
+
}
|
|
347
407
|
if ([...parsed.subject].length > normalizedPolicy.subject.maxLength) {
|
|
348
408
|
issues.push(
|
|
349
409
|
issue(
|
|
@@ -352,6 +412,17 @@ export function validateCommitCandidate(message, { policy, diff = '' } = {}) {
|
|
|
352
412
|
),
|
|
353
413
|
);
|
|
354
414
|
}
|
|
415
|
+
if (
|
|
416
|
+
normalizedPolicy.subject.headerMaxLength &&
|
|
417
|
+
[...parsedMessage.header].length > normalizedPolicy.subject.headerMaxLength
|
|
418
|
+
) {
|
|
419
|
+
issues.push(
|
|
420
|
+
issue(
|
|
421
|
+
'header_length',
|
|
422
|
+
`Header exceeds ${normalizedPolicy.subject.headerMaxLength} characters.`,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
355
426
|
if (effectiveLanguage === 'zh' && !/\p{Script=Han}/u.test(parsed.subject)) {
|
|
356
427
|
issues.push(issue('language', 'The subject must be written in Simplified Chinese.'));
|
|
357
428
|
}
|
|
@@ -414,6 +485,9 @@ export function buildPolicyCorrectionPrompt(badReply, errors, policy) {
|
|
|
414
485
|
`Allowed types: ${policy.types.join(', ')}`,
|
|
415
486
|
`Scope rule: ${scopeRule(policy)}`,
|
|
416
487
|
`Maximum subject length: ${policy.subject.maxLength}`,
|
|
488
|
+
...(policy.subject.headerMaxLength
|
|
489
|
+
? [`Maximum complete header length: ${policy.subject.headerMaxLength}`]
|
|
490
|
+
: []),
|
|
417
491
|
`Body rule: ${policy.body.mode}, maximum ${policy.body.maxLines} non-empty lines`,
|
|
418
492
|
`Breaking-change rule: ${policy.breakingChange}`,
|
|
419
493
|
'Rewrite it once. Output only the complete corrected commit message; do not include explanations or fences.',
|
package/src/split-plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, readFile,
|
|
1
|
+
import { link, lstat, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { normalizeCommitPolicy, validateCommitCandidate } from './policy.js';
|
|
@@ -35,7 +35,13 @@ function clonePolicy(policy, language) {
|
|
|
35
35
|
return {
|
|
36
36
|
version: normalized.version,
|
|
37
37
|
types: [...normalized.types],
|
|
38
|
-
scope: {
|
|
38
|
+
scope: {
|
|
39
|
+
...normalized.scope,
|
|
40
|
+
values: [...normalized.scope.values],
|
|
41
|
+
...(normalized.scope.disallowedValues
|
|
42
|
+
? { disallowedValues: [...normalized.scope.disallowedValues] }
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
39
45
|
subject: { ...normalized.subject },
|
|
40
46
|
body: { ...normalized.body },
|
|
41
47
|
breakingChange: normalized.breakingChange,
|
|
@@ -310,12 +316,19 @@ export async function writeSplitPlanArtifact(path, artifact) {
|
|
|
310
316
|
await writeFile(temporary, JSON.stringify(validated, null, 2) + '\n', {
|
|
311
317
|
encoding: 'utf8',
|
|
312
318
|
mode: 0o600,
|
|
319
|
+
flag: 'wx',
|
|
313
320
|
});
|
|
314
|
-
|
|
321
|
+
// A same-directory hard link installs the completed file atomically while
|
|
322
|
+
// failing with EEXIST instead of replacing an unrelated destination.
|
|
323
|
+
await link(temporary, absolute);
|
|
315
324
|
} catch (err) {
|
|
316
325
|
await unlink(temporary).catch(() => {});
|
|
326
|
+
if (err.code === 'EEXIST') {
|
|
327
|
+
throw new Error(`Split plan output already exists: ${absolute}`, { cause: err });
|
|
328
|
+
}
|
|
317
329
|
throw err;
|
|
318
330
|
}
|
|
331
|
+
await unlink(temporary).catch(() => {});
|
|
319
332
|
return absolute;
|
|
320
333
|
}
|
|
321
334
|
|
package/src/split.js
CHANGED
|
@@ -117,17 +117,22 @@ function canonicalDestination(path) {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function safeExportPlanPath(projectRoot, path) {
|
|
120
|
+
export function safeExportPlanPath(projectRoot, path) {
|
|
121
121
|
const absolute = resolve(path);
|
|
122
122
|
const rawGitDir = readGit(['rev-parse', '--git-dir'], projectRoot).trim();
|
|
123
123
|
const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(projectRoot, rawGitDir);
|
|
124
124
|
const canonicalRoot = canonicalExistingPath(projectRoot);
|
|
125
125
|
const canonicalGitDir = canonicalDestination(gitDir);
|
|
126
|
+
const canonicalAicommitDir = canonicalDestination(join(gitDir, 'aicommit'));
|
|
126
127
|
const canonicalPlan = canonicalDestination(absolute);
|
|
127
|
-
|
|
128
|
+
const insideAicommitDir = pathIsWithin(canonicalAicommitDir, canonicalPlan);
|
|
129
|
+
if (
|
|
130
|
+
(pathIsWithin(canonicalRoot, canonicalPlan) && !insideAicommitDir) ||
|
|
131
|
+
(pathIsWithin(canonicalGitDir, canonicalPlan) && !insideAicommitDir)
|
|
132
|
+
) {
|
|
128
133
|
throw fail(
|
|
129
134
|
ERROR_CATEGORIES.CONFIG,
|
|
130
|
-
|
|
135
|
+
"Split plan output must be outside the working tree or inside the repository Git directory's dedicated .git/aicommit/ metadata subdirectory.",
|
|
131
136
|
);
|
|
132
137
|
}
|
|
133
138
|
return absolute;
|
|
@@ -336,8 +341,13 @@ export async function generateSplitPlan(
|
|
|
336
341
|
: []),
|
|
337
342
|
'Rules:',
|
|
338
343
|
`- Each group must represent ONE logical change and use one allowed type: ${policy.types.join(', ')}.`,
|
|
339
|
-
`- Scope mode: ${policy.scope.mode}${policy.scope.values.length ? `; allowed scopes: ${policy.scope.values.join(', ')}` : ''}.`,
|
|
344
|
+
`- Scope mode: ${policy.scope.mode}${policy.scope.values.length ? `; allowed scopes: ${policy.scope.values.join(', ')}` : ''}${policy.scope.disallowedValues?.length ? `; disallowed scopes: ${policy.scope.disallowedValues.join(', ')}` : ''}.`,
|
|
340
345
|
`- Subject text must not exceed ${policy.subject.maxLength} characters.`,
|
|
346
|
+
...(policy.subject.headerMaxLength
|
|
347
|
+
? [
|
|
348
|
+
`- The complete commit header must not exceed ${policy.subject.headerMaxLength} characters.`,
|
|
349
|
+
]
|
|
350
|
+
: []),
|
|
341
351
|
`- Body mode: ${policy.body.mode}; at most ${policy.body.maxLines} non-empty lines.`,
|
|
342
352
|
`- Breaking changes: ${policy.breakingChange}.`,
|
|
343
353
|
'- Give every message a short subject line; when the subject alone does not say it all, add a body of bullet lines (what changed and why), each starting with "- " — the same format the single-commit flow produces.',
|
|
@@ -1277,13 +1287,39 @@ export function executeSplit(
|
|
|
1277
1287
|
groups,
|
|
1278
1288
|
hunkMode: allFiles.some((change) => change.hunks?.length),
|
|
1279
1289
|
});
|
|
1290
|
+
const fingerprintBeforeSnapshot = getSplitStateFingerprint(
|
|
1291
|
+
projectRoot,
|
|
1292
|
+
hasHead(projectRoot),
|
|
1293
|
+
undefined,
|
|
1294
|
+
scope,
|
|
1295
|
+
);
|
|
1296
|
+
if (fingerprintBeforeSnapshot !== plan.fingerprint) {
|
|
1297
|
+
throw fail(
|
|
1298
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
1299
|
+
'Split state changed before the transaction snapshot was captured.',
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
options.faultInjector?.('before_snapshot_capture', { plan });
|
|
1280
1303
|
const snapshots = captureCheckpointSnapshots(projectRoot, scope, allFiles);
|
|
1304
|
+
const fingerprintAfterSnapshot = getSplitStateFingerprint(
|
|
1305
|
+
projectRoot,
|
|
1306
|
+
hasHead(projectRoot),
|
|
1307
|
+
undefined,
|
|
1308
|
+
scope,
|
|
1309
|
+
);
|
|
1310
|
+
if (fingerprintAfterSnapshot !== plan.fingerprint) {
|
|
1311
|
+
throw fail(
|
|
1312
|
+
ERROR_CATEGORIES.CONCURRENT_MODIFICATION,
|
|
1313
|
+
'Split state changed while the transaction snapshot was being captured.',
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1281
1316
|
hunkExecution = validateHunkTransaction(projectRoot, plan, snapshots);
|
|
1282
1317
|
transaction = {
|
|
1283
1318
|
...createSplitCheckpoint(projectRoot, plan, snapshots),
|
|
1284
1319
|
faultInjector: options.faultInjector,
|
|
1285
1320
|
};
|
|
1286
1321
|
} catch (err) {
|
|
1322
|
+
if (err?.category === ERROR_CATEGORIES.CONCURRENT_MODIFICATION) throw err;
|
|
1287
1323
|
throw fail(ERROR_CATEGORIES.GIT_STATE, `Failed to create split checkpoint: ${err.message}`, {
|
|
1288
1324
|
cause: err,
|
|
1289
1325
|
});
|