agents-gitflow-guard 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -18
- package/README.zh.md +23 -9
- package/lib/cli.mjs +31 -29
- package/lib/index.d.mts +7 -1
- package/lib/index.mjs +1 -1
- package/lib/{src-Dv6jKDsO.mjs → src-LvZrsHEn.mjs} +241 -67
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> **Are you tired of agents skipping your GitFlow?**
|
|
4
4
|
|
|
5
|
-
A configurable branch-role guard for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH)
|
|
5
|
+
A configurable branch-role guard for AI coding agents — [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH), Claude Code, and Codex.
|
|
6
|
+
You define your own branches —
|
|
6
7
|
**integration** (features merge in via PR/MR), **preview** (env endpoints), **production**, **archive** — each with its own update rules. Agents can't skip the flow, and sensitive merges stay in your hands.
|
|
7
8
|
|
|
8
9
|
[中文文档](README.zh.md) · [License](LICENSE)
|
|
@@ -48,8 +49,8 @@ dsh plugin --profile web add agents-gitflow-guard
|
|
|
48
49
|
"enabled": true,
|
|
49
50
|
"featurePattern": "feature/[\\w-]+",
|
|
50
51
|
"branches": {
|
|
51
|
-
"integration": ["develop"], //
|
|
52
|
-
"archive": ["main"] //
|
|
52
|
+
"integration": ["develop"], // integration: features merge in via PR, protected
|
|
53
|
+
"archive": ["main"] // archive: archived by you after release
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
```
|
|
@@ -59,11 +60,11 @@ This one file is the entire setup: `integration` is the **only required** role;
|
|
|
59
60
|
**Step 3 — verify.** Ask the agent (or run in a DSH session) to `git push origin develop`. Expect the tool call to be denied:
|
|
60
61
|
|
|
61
62
|
```text
|
|
62
|
-
Error: [gitflow-guard]
|
|
63
|
-
|
|
63
|
+
Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
|
|
64
|
+
Next: Integration branch (develop) is updated via PR/MR from a feature branch: push the feature first, then `gh pr create --base develop` / `glab mr create --target-branch develop`.
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
Messages are English by default; add `"locale": "zh"` to the config to switch to Chinese (see [Configuration Reference](#configuration-reference)).
|
|
67
68
|
|
|
68
69
|
**Done.** The guard is live for this repo. Keep reading for the [Configuration](#configuration-reference) to map your own branches, or the [Gate Matrix](#gate-matrix--what-gets-blocked-what-passes) for the full decision table.
|
|
69
70
|
|
|
@@ -189,12 +190,12 @@ No plugin code decides "is this merge OK?" for production or archive. The gate s
|
|
|
189
190
|
Only **`integration`** is required. Every other role is optional — configure what your flow actually uses, and each entry is an exact branch name **or** a regex pattern.
|
|
190
191
|
|
|
191
192
|
```text
|
|
192
|
-
feature branches ──(free)──> integration
|
|
193
|
+
feature branches ──(free)──> integration (integration branch; updates via PR/MR)
|
|
193
194
|
│
|
|
194
|
-
├──> preview
|
|
195
|
+
├──> preview (optional; env endpoints; updates via PR/MR)
|
|
195
196
|
│
|
|
196
|
-
└──> production
|
|
197
|
-
archive
|
|
197
|
+
└──> production (optional; PR/MR + only you click merge)
|
|
198
|
+
archive (optional; you archive after release)
|
|
198
199
|
```
|
|
199
200
|
|
|
200
201
|
| role | config key | required? | enforced behavior |
|
|
@@ -207,7 +208,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
207
208
|
|
|
208
209
|
### Customizing branch names & rules — any naming works
|
|
209
210
|
|
|
210
|
-
|
|
211
|
+
**Small team (solo / 2–3 devs) — minimal: integration only:**
|
|
211
212
|
|
|
212
213
|
```jsonc
|
|
213
214
|
{
|
|
@@ -217,7 +218,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
217
218
|
}
|
|
218
219
|
```
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
**Larger team (multiple preview envs + production + archive):**
|
|
221
222
|
|
|
222
223
|
```jsonc
|
|
223
224
|
{
|
|
@@ -251,6 +252,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
251
252
|
"production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // optional
|
|
252
253
|
"archive": ["main"] // optional
|
|
253
254
|
},
|
|
255
|
+
"locale": "en", // optional: message language ('en' default, or 'zh')
|
|
254
256
|
"ci": { "enabled": true } // optional: gh pr checks logged as reference
|
|
255
257
|
}
|
|
256
258
|
```
|
|
@@ -259,6 +261,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
259
261
|
- `update`: `pr` (default) = updates only via PR/MR; `flexible` = allow direct/local merges (small teams).
|
|
260
262
|
- `mergeBy` (production): `user` (default) = only you click merge; `anyone` = allow PR merge through.
|
|
261
263
|
- Each branch entry is an exact name or a regex (auto-detected).
|
|
264
|
+
- **Language**: messages are English by default; add `"locale": "zh"` for Chinese.
|
|
262
265
|
- **Validation**: `integration` is required; overlapping role entries are rejected; invalid regex is rejected. **Any error disables the plugin for that project** (reported) rather than applying a half-guessed setup.
|
|
263
266
|
|
|
264
267
|
---
|
|
@@ -306,9 +309,10 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
|
|
|
306
309
|
|
|
307
310
|
The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes it a profile layer — no manual profile editing.
|
|
308
311
|
|
|
309
|
-
**Claude Code
|
|
312
|
+
**Claude Code / Codex hooks** — the same guard inside those agents, no DSH required. This repo ships project configs at `.claude/settings.json` (Claude Code) and `.codex/hooks.json` (Codex); any other repo adds its own:
|
|
310
313
|
|
|
311
|
-
```
|
|
314
|
+
```jsonc
|
|
315
|
+
// Claude Code — .claude/settings.json
|
|
312
316
|
{
|
|
313
317
|
"hooks": {
|
|
314
318
|
"PreToolUse": [
|
|
@@ -318,9 +322,20 @@ The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes
|
|
|
318
322
|
}
|
|
319
323
|
```
|
|
320
324
|
|
|
321
|
-
|
|
325
|
+
```jsonc
|
|
326
|
+
// Codex — .codex/hooks.json
|
|
327
|
+
{
|
|
328
|
+
"hooks": {
|
|
329
|
+
"PreToolUse": [
|
|
330
|
+
{ "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
|
|
331
|
+
]
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- The hook reads the payload on stdin and answers with that platform's protocol: Claude Code → `exit 2` (stderr is the reason + "next step" hint); Codex → JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}` on stdout (Codex also accepts the legacy `{"decision":"block","reason":...}` shape, which `--platform antigravity` uses).
|
|
322
337
|
- Only `PreToolUse` is needed: the guard blocks *before* the command runs. There is no permit to consume afterwards, so no `PostToolUse` hooks are required.
|
|
323
|
-
- Use an **absolute path** to the binary — hook subprocesses may not inherit your shell `PATH`. `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs` (
|
|
338
|
+
- Use an **absolute path** to the binary — hook subprocesses may not inherit your shell `PATH`. `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs` (Claude Code) or `node bin/gitflow-guard.mjs` (Codex, runs from the project working directory) also work from a checkout.
|
|
324
339
|
- Fully opt-in: the hook does nothing unless the repo has `gitflow-guard.config.json` with `enabled: true`.
|
|
325
340
|
|
|
326
341
|
---
|
|
@@ -409,7 +424,7 @@ If it saves your team from a shortcut gone wrong, the coffee button at the top o
|
|
|
409
424
|
|
|
410
425
|
## Roadmap
|
|
411
426
|
|
|
412
|
-
- **i18n — localized block messages
|
|
427
|
+
- **i18n — localized block messages** ✅ (0.0.3): English by default, `"locale": "zh"` for Chinese.
|
|
413
428
|
- **v2 — audit sync**: sync `.git/gitflow-guard/audit.jsonl` across machines (audit is local-only today).
|
|
414
429
|
- **v2 — more pre-built templates**: ready-made config templates for common flows (solo `develop`, multi-env enterprise) as community-contributed presets.
|
|
415
430
|
- **v2 — CI hard-gating research**: whether `pr checks` could become a real gate without hurting the platform-agnostic core.
|
|
@@ -433,9 +448,12 @@ npm install
|
|
|
433
448
|
npm test # unit tests: classify / gate / config / cli / repo / platform
|
|
434
449
|
npm run typecheck # tsc --noEmit, 0 errors
|
|
435
450
|
npm run build # tsdown → lib/ (CLI and plugin share the build)
|
|
451
|
+
npm run verify:matrix # continuous cross-agent regression: DSH logic + Claude Code / Codex / antigravity hook wiring
|
|
436
452
|
```
|
|
437
453
|
|
|
438
|
-
**Rule**: any logic change must pass a 0-error build + all green tests before done.
|
|
454
|
+
**Rule**: any logic change must pass a 0-error build + all green tests + a green `verify:matrix` before done.
|
|
455
|
+
|
|
456
|
+
**Adding a new agent client** (e.g. Gemini / OpenCode / Cursor): all of these must change in one commit — `src/platform.ts` (+tests, `HookPlatform` union), a repo hook config beside `.claude/settings.json` / `.codex/hooks.json`, `.agents/hooks/references/<tool>.md`, `scripts/verify-matrix.mjs`, the README hook section and the top tagline, `package.json` description/keywords, and `CHANGELOG`. Done only when `npm run verify:matrix` is green. (Same checklist in [AGENTS.md](AGENTS.md) §8.)
|
|
439
457
|
|
|
440
458
|
---
|
|
441
459
|
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> **有没有受够了 agent 跳过你的合入流程?**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
一个可自由配置分支角色的流程守卫,为 AI 编码 agent 而生——[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)、Claude Code、Codex。
|
|
6
6
|
你自己定义分支——**集成分支**(feature 经 PR/MR 合入)、**预览分支**(环境终点)、**生产分支**、**归档分支**——每个角色各自配规则。agent 无法跳过流程,敏感合并始终留在你手上。
|
|
7
7
|
|
|
8
8
|
[English](README.md) · [许可证](LICENSE)
|
|
@@ -59,11 +59,11 @@ dsh plugin --profile web add agents-gitflow-guard
|
|
|
59
59
|
**第 3 步——验证**。让 agent 执行 `git push origin develop`,预期工具调用被拒绝:
|
|
60
60
|
|
|
61
61
|
```text
|
|
62
|
-
Error: [gitflow-guard]
|
|
63
|
-
|
|
62
|
+
Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
|
|
63
|
+
Next: Integration branch (develop) is updated via PR/MR from a feature branch: push the feature first, then `gh pr create --base develop` / `glab mr create --target-branch develop`.
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
**文案默认是英文**(面向国际化)。要在你的项目里看中文,在 `gitflow-guard.config.json` 里加 `"locale": "zh"`;中文效果是:*已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……*
|
|
67
67
|
|
|
68
68
|
**完成。** 守卫对该仓库生效。继续往下看[配置参考](#配置参考)映射自己的分支,或看[门禁矩阵](#门禁矩阵拦什么放什么)的完整判定表。
|
|
69
69
|
|
|
@@ -249,6 +249,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
249
249
|
"production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // 可选
|
|
250
250
|
"archive": ["main"] // 可选
|
|
251
251
|
},
|
|
252
|
+
"locale": "en", // 可选: 文案语言('en' 默认, 或 'zh')
|
|
252
253
|
"ci": { "enabled": true } // 可选: gh pr checks 作参考日志
|
|
253
254
|
}
|
|
254
255
|
```
|
|
@@ -257,6 +258,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
257
258
|
- `update`:`pr`(默认)= 只能 PR/MR 合入;`flexible` = 允许直推/本地合入(小团队)。
|
|
258
259
|
- `mergeBy`(生产):`user`(默认)= 只能你点合并;`anyone` = 放行 PR 合并。
|
|
259
260
|
- 每条分支条目是精确名或正则(自动识别)。
|
|
261
|
+
- **文案语言**:默认英文;加 `"locale": "zh"` 切中文。
|
|
260
262
|
- **校验**:`integration` 必填;角色条目重叠会被拒;非法正则会报错。**任何错误都会让该项目的插件禁用并上报**(而不是用半吊子配置)。
|
|
261
263
|
|
|
262
264
|
---
|
|
@@ -304,9 +306,10 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
|
|
|
304
306
|
|
|
305
307
|
包自带 `dsh.bundle.patch` 声明,`dsh plugin add` 自动把它挂为 profile 层,无需手工编辑 profile。
|
|
306
308
|
|
|
307
|
-
**Claude Code hook
|
|
309
|
+
**Claude Code / Codex hook**——同一守卫也能在这些 agent 里跑,不依赖 DSH。本仓库已自带 `.claude/settings.json`(Claude Code)和 `.codex/hooks.json`(Codex);其他仓库加自己的 hooks:
|
|
308
310
|
|
|
309
|
-
```
|
|
311
|
+
```jsonc
|
|
312
|
+
// Claude Code — .claude/settings.json
|
|
310
313
|
{
|
|
311
314
|
"hooks": {
|
|
312
315
|
"PreToolUse": [
|
|
@@ -316,9 +319,20 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
|
|
|
316
319
|
}
|
|
317
320
|
```
|
|
318
321
|
|
|
319
|
-
|
|
322
|
+
```jsonc
|
|
323
|
+
// Codex — .codex/hooks.json
|
|
324
|
+
{
|
|
325
|
+
"hooks": {
|
|
326
|
+
"PreToolUse": [
|
|
327
|
+
{ "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
|
|
328
|
+
]
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- hook 读 stdin payload,按**各平台协议**作答:Claude Code → `exit 2`(stderr 展示原因 + "下一步"提示);Codex → stdout 输出 JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}`(Codex 也兼容旧的 `{"decision":"block","reason":...}` 形状,`--platform antigravity` 用的就是它)。
|
|
320
334
|
- 只需要 `PreToolUse`:守卫在命令执行*之前*拦截;没有特许可事后消费,因此无需 `PostToolUse` 钩子。
|
|
321
|
-
- 用**绝对路径**指向二进制——hook 子进程不一定继承你的 shell PATH
|
|
335
|
+
- 用**绝对路径**指向二进制——hook 子进程不一定继承你的 shell PATH。Claude Code 用 `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs`,Codex 用 `node bin/gitflow-guard.mjs`(在项目目录执行)也可以。
|
|
322
336
|
- 完全 opt-in:仓库没有 `gitflow-guard.config.json`(或 `enabled` 非 true)时 hook 什么都不做。
|
|
323
337
|
|
|
324
338
|
---
|
|
@@ -407,7 +421,7 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
|
|
|
407
421
|
|
|
408
422
|
## 路线图
|
|
409
423
|
|
|
410
|
-
- **i18n
|
|
424
|
+
- **i18n——拦截文案本地化** ✅(0.0.3):默认英文,`"locale": "zh"` 切中文。
|
|
411
425
|
- **v2——审计同步**:跨机器同步 `.git/gitflow-guard/audit.jsonl`(现仅本地)。
|
|
412
426
|
- **v2——更多预制模板**:常用流程(solo `develop`、多环境企业)的现成配置模板,由社区贡献。
|
|
413
427
|
- **v2——CI 硬门槛研究**:`pr checks` 能否在不伤平台无关核心的前提下变成真实门槛。
|
package/lib/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as findRepoRoot, d as
|
|
1
|
+
import { c as findRepoRoot, d as resolveLocale, f as loadConfig, i as formatDeny, l as gitRunner, m as classify, o as stateDir, p as roleMatches, r as evaluateCommand, s as currentBranch, u as makeT } from "./src-LvZrsHEn.mjs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/platform.ts
|
|
@@ -84,17 +84,6 @@ function encodeDeny(platform, reason) {
|
|
|
84
84
|
}
|
|
85
85
|
//#endregion
|
|
86
86
|
//#region src/cli.ts
|
|
87
|
-
const USAGE = `gitflow-guard — GitFlow 流程守卫 CLI
|
|
88
|
-
|
|
89
|
-
用法:
|
|
90
|
-
gitflow-guard status [--repo <路径>]
|
|
91
|
-
gitflow-guard audit [--lines <数量>] [--repo <路径>]
|
|
92
|
-
gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <路径>]
|
|
93
|
-
gitflow-guard --help
|
|
94
|
-
|
|
95
|
-
说明:
|
|
96
|
-
status/audit 只读, agent 可自查。
|
|
97
|
-
check 读 stdin hook payload 做门禁(exit 0=放行 / 2=拦截), 供 Claude Code 等 agent 的 pre/post hook 调用。`;
|
|
98
87
|
function parseFlags(argv) {
|
|
99
88
|
const flags = {};
|
|
100
89
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -117,9 +106,10 @@ async function resolveRepo(flags) {
|
|
|
117
106
|
}
|
|
118
107
|
async function main(argv, opts = {}) {
|
|
119
108
|
const runner = opts.runner ?? gitRunner;
|
|
109
|
+
const usage = makeT("en")("usage.text");
|
|
120
110
|
const [cmd, ...rest] = argv;
|
|
121
111
|
if (cmd === "--help" || cmd === "help" || cmd === void 0) {
|
|
122
|
-
console.log(
|
|
112
|
+
console.log(usage);
|
|
123
113
|
return 0;
|
|
124
114
|
}
|
|
125
115
|
const flags = parseFlags(rest);
|
|
@@ -127,7 +117,7 @@ async function main(argv, opts = {}) {
|
|
|
127
117
|
if (cmd === "status") return await status(flags, runner);
|
|
128
118
|
if (cmd === "audit") return await audit(flags);
|
|
129
119
|
if (cmd === "check") return await check(flags);
|
|
130
|
-
console.error(
|
|
120
|
+
console.error(`${makeT("en")("cli.unknownCommand", { cmd: cmd ?? "" })}\n\n${usage}`);
|
|
131
121
|
return 1;
|
|
132
122
|
} catch (e) {
|
|
133
123
|
console.error(`[gitflow-guard] ${e.message}`);
|
|
@@ -137,25 +127,36 @@ async function main(argv, opts = {}) {
|
|
|
137
127
|
async function status(flags, runner) {
|
|
138
128
|
const repoRoot = await resolveRepo(flags);
|
|
139
129
|
if (!repoRoot) {
|
|
140
|
-
console.error("
|
|
130
|
+
console.error(makeT("en")("cli.cannotLocate"));
|
|
141
131
|
return 1;
|
|
142
132
|
}
|
|
143
133
|
const { config, errors } = await loadConfig(repoRoot);
|
|
144
134
|
const enabled = config?.enabled === true;
|
|
145
|
-
|
|
135
|
+
const t = makeT(resolveLocale(config?.locale));
|
|
136
|
+
console.log(t("cli.statusTitle", { repo: repoRoot }));
|
|
146
137
|
if (!enabled) {
|
|
147
|
-
console.log("
|
|
148
|
-
for (const e of errors) console.log(
|
|
138
|
+
console.log(t("cli.statusDisabled"));
|
|
139
|
+
for (const e of errors) console.log(t("cli.statusConfigError", { err: e }));
|
|
149
140
|
return 0;
|
|
150
141
|
}
|
|
151
142
|
const branch = await currentBranch(runner, repoRoot);
|
|
152
143
|
const c = config;
|
|
153
|
-
console.log(
|
|
154
|
-
console.log(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
console.log(
|
|
144
|
+
console.log(t("cli.statusEnabled", { pattern: c.featurePattern }));
|
|
145
|
+
console.log(t("cli.statusIntegration", {
|
|
146
|
+
list: c.branches.integration.branches.join(", "),
|
|
147
|
+
mode: c.branches.integration.update || "pr"
|
|
148
|
+
}));
|
|
149
|
+
if (c.branches.preview) console.log(t("cli.statusPreview", {
|
|
150
|
+
list: c.branches.preview.branches.join(", "),
|
|
151
|
+
mode: c.branches.preview.update || "pr"
|
|
152
|
+
}));
|
|
153
|
+
if (c.branches.production) console.log(t("cli.statusProduction", {
|
|
154
|
+
list: c.branches.production.branches.join(", "),
|
|
155
|
+
mode: c.branches.production.update || "pr",
|
|
156
|
+
merge: c.branches.production.mergeBy || "user"
|
|
157
|
+
}));
|
|
158
|
+
if (c.branches.archive) console.log(t("cli.statusArchive", { list: c.branches.archive.branches.join(", ") }));
|
|
159
|
+
console.log(t("cli.statusCurrentBranch", { branch: branch ?? t("cli.statusUnknownBranch") }));
|
|
159
160
|
const r = await runner.run([
|
|
160
161
|
"for-each-ref",
|
|
161
162
|
"--format=%(refname:short)",
|
|
@@ -170,14 +171,14 @@ async function status(flags, runner) {
|
|
|
170
171
|
if (new RegExp(c.featurePattern).test(b)) return "feature";
|
|
171
172
|
return "other";
|
|
172
173
|
};
|
|
173
|
-
console.log(
|
|
174
|
+
console.log(t("cli.statusLocalBranches"));
|
|
174
175
|
for (const b of localBranches) console.log(` ${b} → ${classifyBranch(b)}`);
|
|
175
176
|
return 0;
|
|
176
177
|
}
|
|
177
178
|
async function audit(flags) {
|
|
178
179
|
const repoRoot = await resolveRepo(flags);
|
|
179
180
|
if (!repoRoot) {
|
|
180
|
-
console.error("
|
|
181
|
+
console.error(makeT("en")("cli.cannotLocate"));
|
|
181
182
|
return 1;
|
|
182
183
|
}
|
|
183
184
|
const lines = flags.lines != null && Number.isFinite(flags.lines) && flags.lines > 0 ? Math.floor(flags.lines) : 20;
|
|
@@ -190,7 +191,7 @@ async function audit(flags) {
|
|
|
190
191
|
console.log(` ${line}`);
|
|
191
192
|
}
|
|
192
193
|
} catch {
|
|
193
|
-
console.log("
|
|
194
|
+
console.log(makeT("en")("cli.auditEmpty"));
|
|
194
195
|
}
|
|
195
196
|
return 0;
|
|
196
197
|
}
|
|
@@ -222,16 +223,17 @@ async function check(flags) {
|
|
|
222
223
|
const { config } = await loadConfig(repoRoot);
|
|
223
224
|
if (!config?.enabled) return 0;
|
|
224
225
|
const hookPlatform = platform === "auto" ? detectPlatform(raw) : platform;
|
|
226
|
+
const locale = resolveLocale(config.locale);
|
|
225
227
|
const result = await evaluateCommand(payload.command, { repoRoot });
|
|
226
228
|
if (result.outcome === "deny" && result.reason) {
|
|
227
|
-
const enc = encodeDeny(hookPlatform, formatDeny(result.reason.why, result.reason.next));
|
|
229
|
+
const enc = encodeDeny(hookPlatform, formatDeny(locale, result.reason.why, result.reason.next));
|
|
228
230
|
if (enc.stdout) process.stdout.write(enc.stdout + "\n");
|
|
229
231
|
if (enc.stderr) process.stderr.write(enc.stderr + "\n");
|
|
230
232
|
return enc.exitCode;
|
|
231
233
|
}
|
|
232
234
|
return 0;
|
|
233
235
|
} catch (e) {
|
|
234
|
-
process.stderr.write(
|
|
236
|
+
process.stderr.write(`${makeT("en")("cli.checkInternalError", { msg: e.message })}\n`);
|
|
235
237
|
return 0;
|
|
236
238
|
}
|
|
237
239
|
}
|
package/lib/index.d.mts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { t as Runner } from "./repo-DrgptHl1.mjs";
|
|
2
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/** 文案语言: 默认 en; 'zh' 切中文 */
|
|
5
|
+
type Locale = 'en' | 'zh';
|
|
6
|
+
//#endregion
|
|
3
7
|
//#region src/index.d.ts
|
|
4
8
|
declare const name = "gitflow-guard";
|
|
5
9
|
interface PluginConfig {
|
|
@@ -23,6 +27,8 @@ interface EvaluateResult {
|
|
|
23
27
|
next: string;
|
|
24
28
|
};
|
|
25
29
|
segmentCount: number;
|
|
30
|
+
/** 本次评估使用的文案语言(供 formatDeny/审计一致) */
|
|
31
|
+
locale: Locale;
|
|
26
32
|
}
|
|
27
33
|
interface AuditEntry {
|
|
28
34
|
time: number;
|
|
@@ -36,7 +42,7 @@ declare function stateDir(repoRoot: string): string;
|
|
|
36
42
|
declare function appendAudit(repoRoot: string, entry: AuditEntry): Promise<void>;
|
|
37
43
|
/** 解析一条命令: 分类 → git 事实 → 门禁 → allow/deny */
|
|
38
44
|
declare function evaluateCommand(command: string, opts: EvaluateOptions): Promise<EvaluateResult>;
|
|
39
|
-
declare function formatDeny(why: string, next: string): string;
|
|
45
|
+
declare function formatDeny(locale: Locale, why: string, next: string): string;
|
|
40
46
|
declare function apply(ctx: Context, pluginConfig?: PluginConfig): void;
|
|
41
47
|
//#endregion
|
|
42
48
|
export { AuditEntry, EvaluateOptions, EvaluateResult, PluginConfig, appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
|
package/lib/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as name, i as formatDeny, n as apply, o as stateDir, r as evaluateCommand, t as appendAudit } from "./src-
|
|
1
|
+
import { a as name, i as formatDeny, n as apply, o as stateDir, r as evaluateCommand, t as appendAudit } from "./src-LvZrsHEn.mjs";
|
|
2
2
|
export { appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
|
|
@@ -217,7 +217,8 @@ const CONFIG_FILE = "gitflow-guard.config.json";
|
|
|
217
217
|
const DEFAULT_CONFIG = {
|
|
218
218
|
enabled: false,
|
|
219
219
|
featurePattern: "feature/[\\w-]+",
|
|
220
|
-
ci: { enabled: true }
|
|
220
|
+
ci: { enabled: true },
|
|
221
|
+
locale: "en"
|
|
221
222
|
};
|
|
222
223
|
const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
|
|
223
224
|
/** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
|
|
@@ -248,14 +249,14 @@ function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
|
248
249
|
mergeBy = o.mergeBy;
|
|
249
250
|
} else return {
|
|
250
251
|
role: { branches: [] },
|
|
251
|
-
errors: ["
|
|
252
|
+
errors: ["Branch role must be an array or { branches: [...] }"]
|
|
252
253
|
};
|
|
253
|
-
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches
|
|
254
|
+
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches must be a non-empty array of strings");
|
|
254
255
|
const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
|
|
255
256
|
if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
|
|
256
|
-
else errors.push("update
|
|
257
|
+
else errors.push("update must be \"pr\" or \"flexible\"");
|
|
257
258
|
if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
|
|
258
|
-
else errors.push("mergeBy
|
|
259
|
+
else errors.push("mergeBy must be \"user\" or \"anyone\"");
|
|
259
260
|
return {
|
|
260
261
|
role,
|
|
261
262
|
errors
|
|
@@ -266,7 +267,7 @@ function mergeConfig(raw) {
|
|
|
266
267
|
const errors = [];
|
|
267
268
|
if (typeof raw !== "object" || raw === null) return {
|
|
268
269
|
config: null,
|
|
269
|
-
errors: ["
|
|
270
|
+
errors: ["Config file must be a JSON object"]
|
|
270
271
|
};
|
|
271
272
|
const r = raw;
|
|
272
273
|
const config = {
|
|
@@ -280,12 +281,14 @@ function mergeConfig(raw) {
|
|
|
280
281
|
};
|
|
281
282
|
if (typeof r.enabled === "boolean") config.enabled = r.enabled;
|
|
282
283
|
if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
|
|
284
|
+
if (r.locale === "en" || r.locale === "zh") config.locale = r.locale;
|
|
285
|
+
else if (r.locale !== void 0) errors.push("locale must be \"en\" or \"zh\"");
|
|
283
286
|
const b = r.branches ?? {};
|
|
284
287
|
if ("integration" in b) {
|
|
285
288
|
const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
|
|
286
289
|
config.branches.integration = role;
|
|
287
290
|
errors.push(...e);
|
|
288
|
-
} else errors.push("branches.integration
|
|
291
|
+
} else errors.push("branches.integration is required");
|
|
289
292
|
if (b.preview !== void 0) {
|
|
290
293
|
const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
|
|
291
294
|
config.branches.preview = role;
|
|
@@ -312,11 +315,11 @@ function mergeConfig(raw) {
|
|
|
312
315
|
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
313
316
|
function validateConfig(config) {
|
|
314
317
|
const errors = [];
|
|
315
|
-
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches
|
|
318
|
+
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches is required");
|
|
316
319
|
try {
|
|
317
320
|
new RegExp(config.featurePattern);
|
|
318
321
|
} catch {
|
|
319
|
-
errors.push(`featurePattern
|
|
322
|
+
errors.push(`featurePattern is not a valid regex: ${config.featurePattern}`);
|
|
320
323
|
}
|
|
321
324
|
const allRoles = [
|
|
322
325
|
"integration",
|
|
@@ -330,7 +333,7 @@ function validateConfig(config) {
|
|
|
330
333
|
for (let j = i + 1; j < allRoles.length; j++) {
|
|
331
334
|
const bb = config.branches[allRoles[j]];
|
|
332
335
|
if (!bb) continue;
|
|
333
|
-
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]}
|
|
336
|
+
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} and branches.${allRoles[j]} share the same entries`);
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
339
|
return errors;
|
|
@@ -347,19 +350,183 @@ async function loadConfig(repoRoot) {
|
|
|
347
350
|
};
|
|
348
351
|
return {
|
|
349
352
|
config: null,
|
|
350
|
-
errors: [
|
|
353
|
+
errors: [`Failed to read config file: ${e.message}`]
|
|
351
354
|
};
|
|
352
355
|
}
|
|
353
356
|
}
|
|
354
357
|
//#endregion
|
|
358
|
+
//#region src/i18n.ts
|
|
359
|
+
const en = {
|
|
360
|
+
"role.integration": () => "integration branch",
|
|
361
|
+
"role.preview": () => "preview branch",
|
|
362
|
+
"role.production": () => "production branch",
|
|
363
|
+
"role.archive": () => "archive branch",
|
|
364
|
+
"role.feature": () => "feature branch",
|
|
365
|
+
"role.other": () => "other branch",
|
|
366
|
+
"head.unknown": () => "current branch",
|
|
367
|
+
"denyDeleteOrForce.why": (v) => `Protected branch "${v.branch}" may not be deleted or force-pushed`,
|
|
368
|
+
"denyDeleteOrForce.next": () => "Delete/force-push on a feature branch outside the protected branches; protected branches are managed by you.",
|
|
369
|
+
"pushAll.why": () => "--all/--mirror push would include protected branches",
|
|
370
|
+
"pushAll.next": () => "Push branch by branch with an explicit refspec.",
|
|
371
|
+
"pushDetached.why": () => "Cannot determine the push target (detached HEAD?)",
|
|
372
|
+
"pushDetached.next": () => "Specify the refspec explicitly, e.g. `git push origin <branch>`.",
|
|
373
|
+
"pushProtectedDelete.why": (v) => `Protected branch "${v.branch}" may not be deleted`,
|
|
374
|
+
"pushProtectedDirect.why": (v) => `Protected branch "${v.branch}" forbids direct push`,
|
|
375
|
+
"pushProtectedDirectForce.why": (v) => `Protected branch "${v.branch}" forbids direct push (force)`,
|
|
376
|
+
"mergeProtected.why": (v) => `Merging into ${v.role} is allowed only by the user`,
|
|
377
|
+
"mergeProtected.next": () => `Do the merge in your own terminal (or UI); the agent can't do it for you.`,
|
|
378
|
+
"mergeFeature.why": (v) => `${v.role} forbids local merge of a feature: use PR/MR`,
|
|
379
|
+
"mergeFeature.next": (v) => `Push the feature branch first, then open a PR/MR into ${v.branch}.`,
|
|
380
|
+
"prCreateNoTarget.why": () => "Cannot determine the PR/MR target branch",
|
|
381
|
+
"prCreateNoTarget.next": (v) => `Specify --base/--target-branch explicitly (e.g. \`gh pr create --base ${v.base}\`).`,
|
|
382
|
+
"prCreateArchive.why": () => "Archive branches are user-managed; PRs/MRs into them are not allowed",
|
|
383
|
+
"prCreateArchive.next": () => "Do the release/archive yourself in your terminal or UI.",
|
|
384
|
+
"prCreateHead.why": (v) => `Current branch (${v.head}) is not a feature branch, so it cannot be the source of a PR/MR into ${v.role}`,
|
|
385
|
+
"prCreateHead.next": () => "Open the PR/MR into integration/preview/production from a feature/topic branch.",
|
|
386
|
+
"prMergeProduction.why": () => "Merging into production is allowed only by you (click merge)",
|
|
387
|
+
"prMergeProduction.next": () => "Click merge yourself on the PR/MR page.",
|
|
388
|
+
"prMergeArchive.why": () => "Merging into an archive branch is allowed only by the user",
|
|
389
|
+
"prMergeArchive.next": () => "Let the user do the archive merge in their terminal or UI.",
|
|
390
|
+
"prMergeUnknown.why": () => "Cannot confirm the PR/MR target branch",
|
|
391
|
+
"prMergeUnknown.next": () => "Retry once gh/glab is available, or let the user handle it.",
|
|
392
|
+
"prMergeHead.why": () => "Cannot confirm the PR/MR target, and the head is not a feature branch",
|
|
393
|
+
"prMergeHead.next": () => "Confirm the platform CLI is available, or let the user handle it.",
|
|
394
|
+
"next.integration": (v) => `Integration branch (${v.branch}) is updated via PR/MR from a feature branch: push the feature first, then \`gh pr create --base ${v.branch}\` / \`glab mr create --target-branch ${v.branch}\`.`,
|
|
395
|
+
"next.preview": (v) => `Preview branch (${v.branch}) only accepts PR/MRs: open a PR/MR into it from a feature/release branch (content of ${v.base} etc. first goes into a feature release branch).`,
|
|
396
|
+
"next.production": (v) => `Production branch (${v.branch}) is PR/MR-only, and you click the merge yourself.`,
|
|
397
|
+
"next.archive": (v) => `Archive branch (${v.branch}) is user-managed only.`,
|
|
398
|
+
"next.unspecified": () => "Retry once the target branch is clear.",
|
|
399
|
+
"deny.header": (v) => `[gitflow-guard] blocked: ${v.why}`,
|
|
400
|
+
"deny.next": (v) => `Next: ${v.next}`,
|
|
401
|
+
"cli.unknownCommand": (v) => `[gitflow-guard] unknown subcommand: ${v.cmd}`,
|
|
402
|
+
"cli.cannotLocate": () => "Cannot locate a git repository",
|
|
403
|
+
"cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
|
|
404
|
+
"cli.statusDisabled": () => "Config: not enabled (no gitflow-guard.config.json or enabled=false)",
|
|
405
|
+
"cli.statusConfigError": (v) => ` config error: ${v.err}`,
|
|
406
|
+
"cli.statusEnabled": (v) => `Config: enabled | featurePattern: ${v.pattern}`,
|
|
407
|
+
"cli.statusIntegration": (v) => `Integration: ${v.list} (update=${v.mode})`,
|
|
408
|
+
"cli.statusPreview": (v) => `Preview: ${v.list} (update=${v.mode})`,
|
|
409
|
+
"cli.statusProduction": (v) => `Production: ${v.list} (update=${v.mode}, merge=${v.merge})`,
|
|
410
|
+
"cli.statusArchive": (v) => `Archive: ${v.list}`,
|
|
411
|
+
"cli.statusCurrentBranch": (v) => `Current branch: ${v.branch}`,
|
|
412
|
+
"cli.statusUnknownBranch": () => "(unknown)",
|
|
413
|
+
"cli.statusLocalBranches": () => "Local branches (by role):",
|
|
414
|
+
"cli.auditEmpty": () => " No audit entries yet",
|
|
415
|
+
"cli.checkInternalError": (v) => `[gitflow-guard] check internal error, allowed through: ${v.msg}`,
|
|
416
|
+
"usage.text": () => `gitflow-guard — GitFlow guard CLI
|
|
417
|
+
|
|
418
|
+
Usage:
|
|
419
|
+
gitflow-guard status [--repo <path>]
|
|
420
|
+
gitflow-guard audit [--lines <count>] [--repo <path>]
|
|
421
|
+
gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <path>]
|
|
422
|
+
gitflow-guard --help
|
|
423
|
+
|
|
424
|
+
Notes:
|
|
425
|
+
status/audit are read-only; the agent can self-inspect.
|
|
426
|
+
check reads the hook payload on stdin (exit 0 = allow / 2 = block) and is meant for
|
|
427
|
+
pre/post hooks of agents such as Claude Code.`
|
|
428
|
+
};
|
|
429
|
+
const zh = {
|
|
430
|
+
"role.integration": () => "集成分支",
|
|
431
|
+
"role.preview": () => "预览分支",
|
|
432
|
+
"role.production": () => "生产分支",
|
|
433
|
+
"role.archive": () => "归档分支",
|
|
434
|
+
"role.feature": () => "feature 分支",
|
|
435
|
+
"role.other": () => "普通分支",
|
|
436
|
+
"head.unknown": () => "当前分支",
|
|
437
|
+
"denyDeleteOrForce.why": (v) => `受保护分支「${v.branch}」禁止删除或强推`,
|
|
438
|
+
"denyDeleteOrForce.next": () => "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理",
|
|
439
|
+
"pushAll.why": () => "--all/--mirror 推送会包含受保护分支",
|
|
440
|
+
"pushAll.next": () => "请逐分支推送并显式指定 refspec",
|
|
441
|
+
"pushDetached.why": () => "无法确定推送目标分支(可能处于 detached HEAD)",
|
|
442
|
+
"pushDetached.next": () => "请显式指定 refspec, 如 git push origin <分支名>",
|
|
443
|
+
"pushProtectedDelete.why": (v) => `受保护分支「${v.branch}」禁止删除`,
|
|
444
|
+
"pushProtectedDirect.why": (v) => `受保护分支「${v.branch}」禁止直推`,
|
|
445
|
+
"pushProtectedDirectForce.why": (v) => `受保护分支「${v.branch}」禁止直推(含强推)`,
|
|
446
|
+
"mergeProtected.why": (v) => `合入${v.role}仅允许用户亲手执行`,
|
|
447
|
+
"mergeProtected.next": () => "请在你自己终端(或 UI)完成该合并; agent 不能替你操作",
|
|
448
|
+
"mergeFeature.why": (v) => `${v.role}禁止本地合入 feature: 须通过 PR/MR`,
|
|
449
|
+
"mergeFeature.next": (v) => `先推 feature 分支, 再创建指向 ${v.branch} 的 PR/MR`,
|
|
450
|
+
"prCreateNoTarget.why": () => "无法确定 PR/MR 目标分支",
|
|
451
|
+
"prCreateNoTarget.next": (v) => `请显式指定 --base/--target-branch(如 gh pr create --base ${v.base})`,
|
|
452
|
+
"prCreateArchive.why": () => "归档分支(archive)仅用户亲手操作, 不允许创建指向它的 PR/MR",
|
|
453
|
+
"prCreateArchive.next": () => "发布/归档由你自己在终端或 UI 完成",
|
|
454
|
+
"prCreateHead.why": (v) => `当前分支(${v.head})不是 feature 分支, 不能作为指向${v.role}的 PR/MR 源`,
|
|
455
|
+
"prCreateHead.next": () => "请从 feature/topic 分支上创建指向集成/预览/生产分支的 PR/MR",
|
|
456
|
+
"prMergeProduction.why": () => "合入生产(production)分支仅允许用户亲手点合并",
|
|
457
|
+
"prMergeProduction.next": () => "请在 GitLab/GitHub 的 MR/PR 页面上由你本人点击合并",
|
|
458
|
+
"prMergeArchive.why": () => "合入归档分支(archive)仅允许用户亲手执行",
|
|
459
|
+
"prMergeArchive.next": () => "请让用户在自己终端或 UI 完成归档合并",
|
|
460
|
+
"prMergeUnknown.why": () => "无法确认 PR/MR 的目标分支",
|
|
461
|
+
"prMergeUnknown.next": () => "请确认 gh/glab 可用后重试, 或让用户亲手处理",
|
|
462
|
+
"prMergeHead.why": () => "无法确认 PR/MR 目标, 且 head 不是 feature 分支",
|
|
463
|
+
"prMergeHead.next": () => "请确认平台 CLI 可用, 或让用户亲手处理",
|
|
464
|
+
"next.integration": (v) => `集成分支(${v.branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${v.branch} / glab mr create --target-branch ${v.branch}`,
|
|
465
|
+
"next.preview": (v) => `预览分支(${v.branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${v.base} 等集成分支内容先进 feature 发布分支)`,
|
|
466
|
+
"next.production": (v) => `生产分支(${v.branch})只能 PR/MR, 且合并由你亲手点击`,
|
|
467
|
+
"next.archive": (v) => `归档分支(${v.branch})仅用户亲手操作`,
|
|
468
|
+
"next.unspecified": () => "请明确目标分支后重试",
|
|
469
|
+
"deny.header": (v) => `[gitflow-guard] 已拦截: ${v.why}`,
|
|
470
|
+
"deny.next": (v) => `下一步: ${v.next}`,
|
|
471
|
+
"cli.unknownCommand": (v) => `[gitflow-guard] 未知子命令: ${v.cmd}`,
|
|
472
|
+
"cli.cannotLocate": () => "无法定位 git 仓库",
|
|
473
|
+
"cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
|
|
474
|
+
"cli.statusDisabled": () => "配置: 未启用(不存在 gitflow-guard.config.json 或 enabled=false)",
|
|
475
|
+
"cli.statusConfigError": (v) => ` 配置错误: ${v.err}`,
|
|
476
|
+
"cli.statusEnabled": (v) => `配置: 已启用 | featurePattern: ${v.pattern}`,
|
|
477
|
+
"cli.statusIntegration": (v) => `集成分支: ${v.list} (update=${v.mode})`,
|
|
478
|
+
"cli.statusPreview": (v) => `预览分支: ${v.list} (update=${v.mode})`,
|
|
479
|
+
"cli.statusProduction": (v) => `生产分支: ${v.list} (update=${v.mode}, 合并=${v.merge})`,
|
|
480
|
+
"cli.statusArchive": (v) => `归档分支: ${v.list}`,
|
|
481
|
+
"cli.statusCurrentBranch": (v) => `当前分支: ${v.branch}`,
|
|
482
|
+
"cli.statusUnknownBranch": () => "(未知)",
|
|
483
|
+
"cli.statusLocalBranches": () => "本地分支(按角色):",
|
|
484
|
+
"cli.auditEmpty": () => " 暂无审计记录",
|
|
485
|
+
"cli.checkInternalError": (v) => `[gitflow-guard] check 内部错误, 已放行: ${v.msg}`,
|
|
486
|
+
"usage.text": () => `gitflow-guard — GitFlow 流程守卫 CLI
|
|
487
|
+
|
|
488
|
+
用法:
|
|
489
|
+
gitflow-guard status [--repo <路径>]
|
|
490
|
+
gitflow-guard audit [--lines <数量>] [--repo <路径>]
|
|
491
|
+
gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <路径>]
|
|
492
|
+
gitflow-guard --help
|
|
493
|
+
|
|
494
|
+
说明:
|
|
495
|
+
status/audit 只读, agent 可自查。
|
|
496
|
+
check 读 stdin hook payload 做门禁(exit 0=放行 / 2=拦截), 供 Claude Code 等 agent 的 pre/post hook 调用。`
|
|
497
|
+
};
|
|
498
|
+
const MESSAGE_KEYS = Object.keys(en);
|
|
499
|
+
/** key 合法性与 en/zh 双字典完整性检查(开发期一次) */
|
|
500
|
+
if (Object.keys(zh).length !== MESSAGE_KEYS.length || MESSAGE_KEYS.some((k) => !(k in zh))) throw new Error("i18n: en/zh 字典键不一致");
|
|
501
|
+
/**
|
|
502
|
+
* 生成翻译函数。未知 key 回退英文原文(开发/防御性)。
|
|
503
|
+
*/
|
|
504
|
+
function makeT(locale) {
|
|
505
|
+
const dict = locale === "zh" ? zh : en;
|
|
506
|
+
return (key, vars = {}) => {
|
|
507
|
+
const entry = dict[key] ?? en[key];
|
|
508
|
+
if (!entry) return key;
|
|
509
|
+
try {
|
|
510
|
+
return entry(vars);
|
|
511
|
+
} catch {
|
|
512
|
+
return key;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** 解析配置里的 locale 值: 仅 'zh' 视为中文, 其余(含未定义)一律英文 */
|
|
517
|
+
function resolveLocale(v) {
|
|
518
|
+
return v === "zh" ? "zh" : "en";
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
355
521
|
//#region src/gate.ts
|
|
356
|
-
const FEATURE_UNKNOWN = "当前分支";
|
|
357
522
|
const PROTECTED_ROLES = /* @__PURE__ */ new Set([
|
|
358
523
|
"integration",
|
|
359
524
|
"preview",
|
|
360
525
|
"production",
|
|
361
526
|
"archive"
|
|
362
527
|
]);
|
|
528
|
+
/** 判别的默认英文(纯函数裸调用/测试时默认; 运行时由 evaluateCommand 按配置 locale 注入) */
|
|
529
|
+
const defaultT = makeT("en");
|
|
363
530
|
/** 判定分支角色: 优先角色配置, 其次 featurePattern, 其余 other */
|
|
364
531
|
function roleOfBranch(branch, config) {
|
|
365
532
|
if (!branch) return "other";
|
|
@@ -375,97 +542,98 @@ function roleOfBranch(branch, config) {
|
|
|
375
542
|
function isProtected(role) {
|
|
376
543
|
return PROTECTED_ROLES.has(role);
|
|
377
544
|
}
|
|
378
|
-
function
|
|
545
|
+
function deny(why, next) {
|
|
546
|
+
return {
|
|
547
|
+
kind: "deny",
|
|
548
|
+
reason: why,
|
|
549
|
+
next
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/** 角色名展示: 集成分支(main) 等 */
|
|
553
|
+
function roleLabel(role, t, branch) {
|
|
554
|
+
const label = t(`role.${role}`);
|
|
555
|
+
return branch ? `${label}(${branch})` : label;
|
|
556
|
+
}
|
|
557
|
+
function decide(classified, facts, config, t = defaultT) {
|
|
379
558
|
switch (classified.kind) {
|
|
380
|
-
case "push": return decidePush(classified, facts, config);
|
|
381
|
-
case "local-merge": return decideMerge(classified, facts, config);
|
|
382
|
-
case "pr-create": return decidePrCreate(classified, facts, config);
|
|
383
|
-
case "pr-merge": return decidePrMerge(classified, facts, config);
|
|
384
|
-
case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(
|
|
559
|
+
case "push": return decidePush(classified, facts, config, t);
|
|
560
|
+
case "local-merge": return decideMerge(classified, facts, config, t);
|
|
561
|
+
case "pr-create": return decidePrCreate(classified, facts, config, t);
|
|
562
|
+
case "pr-merge": return decidePrMerge(classified, facts, config, t);
|
|
563
|
+
case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(t("denyDeleteOrForce.why", { branch: classified.branch ?? "" }), t("denyDeleteOrForce.next")) : { kind: "allow" };
|
|
385
564
|
case "guard-cli": return { kind: "allow" };
|
|
386
565
|
case "checkout": return { kind: "allow" };
|
|
387
566
|
default: return { kind: "allow" };
|
|
388
567
|
}
|
|
389
568
|
}
|
|
390
|
-
function decidePush(c, facts, config) {
|
|
391
|
-
if (c.all) return deny("
|
|
569
|
+
function decidePush(c, facts, config, t) {
|
|
570
|
+
if (c.all) return deny(t("pushAll.why"), t("pushAll.next"));
|
|
392
571
|
const dst = c.dst ?? facts.currentBranch;
|
|
393
|
-
if (dst == null) return deny(
|
|
572
|
+
if (dst == null) return deny(t("pushDetached.why"), t("pushDetached.next"));
|
|
394
573
|
const role = roleOfBranch(dst, config);
|
|
395
574
|
if (isProtected(role)) {
|
|
396
575
|
const flexRole = role === "integration" ? config.branches.integration : config.branches.preview;
|
|
397
576
|
if ((role === "integration" || role === "preview") && flexRole?.update === "flexible" && !c.delete) return { kind: "allow" };
|
|
398
|
-
|
|
577
|
+
if (c.delete) return deny(t("pushProtectedDelete.why", { branch: dst }), branchNext(dst, config, t));
|
|
578
|
+
return deny(t(c.force ? "pushProtectedDirectForce.why" : "pushProtectedDirect.why", { branch: dst }), branchNext(dst, config, t));
|
|
399
579
|
}
|
|
400
580
|
return { kind: "allow" };
|
|
401
581
|
}
|
|
402
|
-
function decideMerge(c, facts, config) {
|
|
582
|
+
function decideMerge(c, facts, config, t) {
|
|
403
583
|
const currentRole = roleOfBranch(facts.currentBranch, config);
|
|
404
584
|
const source = c.source;
|
|
405
585
|
const sourceRole = source ? roleOfBranch(source, config) : null;
|
|
406
|
-
if (currentRole === "production" || currentRole === "archive") return deny(
|
|
586
|
+
if (currentRole === "production" || currentRole === "archive") return deny(t("mergeProtected.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeProtected.next"));
|
|
407
587
|
if (currentRole === "integration" || currentRole === "preview") {
|
|
408
588
|
if (source == null) return { kind: "allow" };
|
|
409
589
|
if (sourceRole != null && isProtected(sourceRole)) return { kind: "allow" };
|
|
410
590
|
if ((currentRole === "integration" ? config.branches.integration : config.branches.preview)?.update === "flexible") return { kind: "allow" };
|
|
411
|
-
return deny(
|
|
591
|
+
return deny(t("mergeFeature.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeFeature.next", { branch: facts.currentBranch ?? "" }));
|
|
412
592
|
}
|
|
413
593
|
return { kind: "allow" };
|
|
414
594
|
}
|
|
415
|
-
function decidePrCreate(c, facts, config) {
|
|
416
|
-
if (c.target == null) return deny("
|
|
595
|
+
function decidePrCreate(c, facts, config, t) {
|
|
596
|
+
if (c.target == null) return deny(t("prCreateNoTarget.why"), t("prCreateNoTarget.next", { base: config.branches.integration.branches[0] ?? "" }));
|
|
417
597
|
const targetRole = roleOfBranch(c.target, config);
|
|
418
598
|
const head = facts.currentBranch;
|
|
419
599
|
const headRole = roleOfBranch(head, config);
|
|
420
|
-
if (targetRole === "archive") return deny("
|
|
600
|
+
if (targetRole === "archive") return deny(t("prCreateArchive.why"), t("prCreateArchive.next"));
|
|
421
601
|
if (targetRole === "integration" || targetRole === "preview" || targetRole === "production") {
|
|
422
|
-
if (headRole !== "feature") return deny(
|
|
602
|
+
if (headRole !== "feature") return deny(t("prCreateHead.why", {
|
|
603
|
+
head: head ?? t("head.unknown"),
|
|
604
|
+
role: roleLabel(targetRole, t, c.target)
|
|
605
|
+
}), t("prCreateHead.next"));
|
|
423
606
|
return { kind: "allow" };
|
|
424
607
|
}
|
|
425
608
|
return { kind: "allow" };
|
|
426
609
|
}
|
|
427
|
-
function decidePrMerge(c, facts, config) {
|
|
610
|
+
function decidePrMerge(c, facts, config, t) {
|
|
428
611
|
const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
|
|
429
612
|
const role = resolved?.role ?? null;
|
|
430
613
|
const head = resolved?.head ?? facts.currentBranch;
|
|
431
614
|
if (role === "production") {
|
|
432
|
-
if (config.branches.production?.mergeBy === "user") return deny("
|
|
615
|
+
if (config.branches.production?.mergeBy === "user") return deny(t("prMergeProduction.why"), t("prMergeProduction.next"));
|
|
433
616
|
return { kind: "allow" };
|
|
434
617
|
}
|
|
435
|
-
if (role === "archive") return deny("
|
|
618
|
+
if (role === "archive") return deny(t("prMergeArchive.why"), t("prMergeArchive.next"));
|
|
436
619
|
if (role === "integration" || role === "preview") return { kind: "allow" };
|
|
437
620
|
if (role === "feature" || role === "other") return { kind: "allow" };
|
|
438
|
-
if (head == null) return deny("
|
|
621
|
+
if (head == null) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
439
622
|
if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
|
|
440
|
-
return deny("
|
|
441
|
-
}
|
|
442
|
-
function deny(reason, next) {
|
|
443
|
-
return {
|
|
444
|
-
kind: "deny",
|
|
445
|
-
reason,
|
|
446
|
-
next
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
function roleLabel(role, branch) {
|
|
450
|
-
const map = {
|
|
451
|
-
integration: "集成分支",
|
|
452
|
-
preview: "预览分支",
|
|
453
|
-
production: "生产分支",
|
|
454
|
-
archive: "归档分支",
|
|
455
|
-
feature: "feature 分支",
|
|
456
|
-
other: "普通分支"
|
|
457
|
-
};
|
|
458
|
-
return branch ? `${map[role]}(${branch})` : map[role];
|
|
623
|
+
return deny(t("prMergeHead.why"), t("prMergeHead.next"));
|
|
459
624
|
}
|
|
460
625
|
/** 受保护分支被拦后的下一步引导 */
|
|
461
|
-
function branchNext(branch, config) {
|
|
462
|
-
if (branch == null) return "
|
|
463
|
-
const base = config.branches.integration.branches[0] ?? "
|
|
626
|
+
function branchNext(branch, config, t) {
|
|
627
|
+
if (branch == null) return t("next.unspecified");
|
|
628
|
+
const base = config.branches.integration.branches[0] ?? "";
|
|
464
629
|
switch (roleOfBranch(branch, config)) {
|
|
465
|
-
case "integration": return
|
|
466
|
-
case "preview": return
|
|
467
|
-
|
|
468
|
-
|
|
630
|
+
case "integration": return t("next.integration", { branch });
|
|
631
|
+
case "preview": return t("next.preview", {
|
|
632
|
+
branch,
|
|
633
|
+
base
|
|
634
|
+
});
|
|
635
|
+
case "production": return t("next.production", { branch });
|
|
636
|
+
default: return t("next.archive", { branch });
|
|
469
637
|
}
|
|
470
638
|
}
|
|
471
639
|
//#endregion
|
|
@@ -607,8 +775,11 @@ async function evaluateCommand(command, opts) {
|
|
|
607
775
|
const { config } = await loadConfig(opts.repoRoot);
|
|
608
776
|
if (!config?.enabled) return {
|
|
609
777
|
outcome: "skipped",
|
|
610
|
-
segmentCount: 0
|
|
778
|
+
segmentCount: 0,
|
|
779
|
+
locale: "en"
|
|
611
780
|
};
|
|
781
|
+
const locale = resolveLocale(config.locale);
|
|
782
|
+
const t = makeT(locale);
|
|
612
783
|
const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
|
|
613
784
|
const env = {
|
|
614
785
|
repoRoot: opts.repoRoot,
|
|
@@ -625,7 +796,7 @@ async function evaluateCommand(command, opts) {
|
|
|
625
796
|
...env,
|
|
626
797
|
branch: simulatedBranch
|
|
627
798
|
});
|
|
628
|
-
const decision = decide(seg, facts, config);
|
|
799
|
+
const decision = decide(seg, facts, config, t);
|
|
629
800
|
if (decision.kind === "deny") {
|
|
630
801
|
await appendAudit(env.repoRoot, {
|
|
631
802
|
time: Date.now(),
|
|
@@ -639,7 +810,8 @@ async function evaluateCommand(command, opts) {
|
|
|
639
810
|
why: decision.reason,
|
|
640
811
|
next: decision.next
|
|
641
812
|
},
|
|
642
|
-
segmentCount: segments.length
|
|
813
|
+
segmentCount: segments.length,
|
|
814
|
+
locale
|
|
643
815
|
};
|
|
644
816
|
}
|
|
645
817
|
await logCiReference(seg, env);
|
|
@@ -647,7 +819,8 @@ async function evaluateCommand(command, opts) {
|
|
|
647
819
|
}
|
|
648
820
|
return {
|
|
649
821
|
outcome: "allow",
|
|
650
|
-
segmentCount: segments.length
|
|
822
|
+
segmentCount: segments.length,
|
|
823
|
+
locale
|
|
651
824
|
};
|
|
652
825
|
}
|
|
653
826
|
/** CI 参考(可选适配器): gh pr checks 状态记入审计日志, 查不到自动跳过 */
|
|
@@ -685,8 +858,9 @@ function commandText(exec) {
|
|
|
685
858
|
const args = exec.arguments;
|
|
686
859
|
return typeof args?.command === "string" ? args.command : "";
|
|
687
860
|
}
|
|
688
|
-
function formatDeny(why, next) {
|
|
689
|
-
|
|
861
|
+
function formatDeny(locale, why, next) {
|
|
862
|
+
const t = makeT(locale);
|
|
863
|
+
return `${t("deny.header", { why })}\n${t("deny.next", { next })}`;
|
|
690
864
|
}
|
|
691
865
|
function apply(ctx, pluginConfig = {}) {
|
|
692
866
|
const toolNames = new Set(pluginConfig.toolNames ?? ["pwsh", "bash"]);
|
|
@@ -703,7 +877,7 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
703
877
|
});
|
|
704
878
|
if (result.outcome === "deny" && result.reason) return {
|
|
705
879
|
kind: "deny",
|
|
706
|
-
reason: formatDeny(result.reason.why, result.reason.next)
|
|
880
|
+
reason: formatDeny(result.locale, result.reason.why, result.reason.next)
|
|
707
881
|
};
|
|
708
882
|
return next();
|
|
709
883
|
} catch (e) {
|
|
@@ -713,4 +887,4 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
713
887
|
});
|
|
714
888
|
}
|
|
715
889
|
//#endregion
|
|
716
|
-
export { name as a, findRepoRoot as c,
|
|
890
|
+
export { name as a, findRepoRoot as c, resolveLocale as d, loadConfig as f, formatDeny as i, gitRunner as l, classify as m, apply as n, stateDir as o, roleMatches as p, evaluateCommand as r, currentBranch as s, appendAudit as t, makeT as u };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-gitflow-guard",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex) — integration/preview/production/archive, each with its own update rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.mjs",
|
|
@@ -26,7 +26,10 @@
|
|
|
26
26
|
"keywords": [
|
|
27
27
|
"dsh",
|
|
28
28
|
"deepseek-harness",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"codex",
|
|
29
31
|
"plugin",
|
|
32
|
+
"hook",
|
|
30
33
|
"gitflow",
|
|
31
34
|
"git",
|
|
32
35
|
"workflow",
|
|
@@ -46,7 +49,8 @@
|
|
|
46
49
|
"build": "tsdown",
|
|
47
50
|
"prepack": "npm run build",
|
|
48
51
|
"test": "vitest run",
|
|
49
|
-
"typecheck": "tsc --noEmit"
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"verify:matrix": "npm run build && node scripts/verify-matrix.mjs"
|
|
50
54
|
},
|
|
51
55
|
"peerDependencies": {
|
|
52
56
|
"@deepseek-ai/cordis": "^4.0.1",
|