agents-gitflow-guard 0.0.9 → 0.0.11
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 +12 -4
- package/README.zh.md +12 -4
- package/lib/cli.mjs +30 -13
- package/lib/index.mjs +1 -1
- package/lib/{src-CeEqUVQP.mjs → src-DTg5WMog.mjs} +294 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,12 +36,16 @@ You define your own branches —
|
|
|
36
36
|
|
|
37
37
|
## Quick Start — 30 seconds to a guarded repo
|
|
38
38
|
|
|
39
|
-
**Step 1 — install**,
|
|
39
|
+
**Step 1 — install**, then restart DSH (plugins load at process startup):
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
+
# installs the latest release
|
|
42
43
|
dsh plugin --profile web add agents-gitflow-guard
|
|
44
|
+
# ...or pin an exact known-good version (recommended; also bypasses stale registry caches)
|
|
45
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.9
|
|
43
46
|
```
|
|
44
47
|
|
|
48
|
+
> **Version gotcha**: a bare `add` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. The peer-dependency *warning* pnpm may print is expected: DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup (the plugin works normally).
|
|
45
49
|
**Step 2 — configure**, create `gitflow-guard.config.json` in your **project root**:
|
|
46
50
|
|
|
47
51
|
```jsonc
|
|
@@ -253,6 +257,7 @@ archive (optional; you archive after release)
|
|
|
253
257
|
"archive": ["main"] // optional
|
|
254
258
|
},
|
|
255
259
|
"locale": "en", // optional: message language ('en' default, or 'zh')
|
|
260
|
+
"strict": false, // optional: fail-closed — invalid config / internal errors block instead of warn-and-allow
|
|
256
261
|
"ci": { "enabled": true } // optional: gh pr checks logged as reference
|
|
257
262
|
}
|
|
258
263
|
```
|
|
@@ -263,6 +268,7 @@ archive (optional; you archive after release)
|
|
|
263
268
|
- Each branch entry is an exact name or a regex (auto-detected).
|
|
264
269
|
- **Language**: messages are English by default; add `"locale": "zh"` for Chinese.
|
|
265
270
|
- **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.
|
|
271
|
+
- **Strict mode**: by default a broken config warns on stderr once and lets the command pass (fail-open, so a typo can't wedge your tooling). `"strict": true` flips config errors and internal errors to **block** (fail-closed) — for high-risk repos. A missing file or explicit `enabled: false` stays silent either way.
|
|
266
272
|
|
|
267
273
|
---
|
|
268
274
|
|
|
@@ -295,7 +301,7 @@ The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab
|
|
|
295
301
|
**From the npm registry** — the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo):
|
|
296
302
|
|
|
297
303
|
```bash
|
|
298
|
-
dsh plugin --profile web add agents-gitflow-guard
|
|
304
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.9 # pin recommended, see note above
|
|
299
305
|
```
|
|
300
306
|
|
|
301
307
|
Then restart DSH. Upgrades are the same command, followed by another restart.
|
|
@@ -387,7 +393,9 @@ No. Add only the roles your flow actually has. A solo repo with just `develop` c
|
|
|
387
393
|
|
|
388
394
|
No, and it is important that you don't treat it as one. It is a workflow guard: it makes an agreed process mechanically enforceable. Text-based command recognition is inherently best-effort — an agent determined to obfuscate a command can slip past the parser.
|
|
389
395
|
|
|
390
|
-
|
|
396
|
+
Within its supported command forms, the role boundary is enforced locally: merging into a protected role branch (integration / preview / production / archive) requires the configured path (PR/MR, or a human merge for production/archive). Standard obfuscation wrappers are classified and blocked — shell wrappers (`sh -c` / `bash -lc`), subshells and backtick/`$()` nesting, `env`/`command`/`nohup`/`xargs` prefixes and `VAR=x` assignments, absolute paths, pipelines and `||` tails, git global options (`-C .`, `--git-dir=…`), wildcard refspecs (`refs/heads/*:refs/heads/*`), `git pull` used as fetch+merge, and the `send-pack`/`update-ref` plumbing. The executable adversarial corpus lives in `tests/accuracy-audit.spec.ts`.
|
|
397
|
+
|
|
398
|
+
What remains **locally non-defensible**: direct forge-API calls (`gh api repos/…/pulls/N/merge`, `curl`) and commands inside interpreter subprocesses (`node -e "child_process.exec('git push …')"`); arbitrarily deep quoting or encoding stays best-effort by nature. The real, non-bypassable boundary lives in branch-protection rules on your hosting service. Use both — treat this guard as instant feedback and audit trail, not as a security boundary.
|
|
391
399
|
|
|
392
400
|
---
|
|
393
401
|
|
|
@@ -399,7 +407,7 @@ Because the gate classifies those as **user-only** actions. An agent may create
|
|
|
399
407
|
|
|
400
408
|
### Do I need the `gh` or `glab` CLI?
|
|
401
409
|
|
|
402
|
-
No. They are optional adapters used only to resolve what a `pr merge` / `mr merge` is targeting, so the gate can tell "merge into integration/preview" (okay) from "merge into production/archive" (blocked).
|
|
410
|
+
No. They are optional adapters used only to resolve what a `pr merge` / `mr merge` is targeting, so the gate can tell "merge into integration/preview" (okay) from "merge into production/archive" (blocked). When neither CLI can confirm the target — missing, unauthenticated, offline, or the query fails — the gate **refuses the merge**, even when run from a feature branch: that PR could actually point at production/archive. Retry once the CLI works, or let the user click merge. Everything else works the same. The core enforcement never touches a hosting service, which is why it works identically on GitHub, GitLab, self-hosted, or offline.
|
|
403
411
|
|
|
404
412
|
---
|
|
405
413
|
|
package/README.zh.md
CHANGED
|
@@ -35,12 +35,16 @@
|
|
|
35
35
|
|
|
36
36
|
## 快速开始——30 秒用上
|
|
37
37
|
|
|
38
|
-
**第 1
|
|
38
|
+
**第 1 步——安装**,然后重启 DSH(插件在进程启动时加载):
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
+
# 安装最新版
|
|
41
42
|
dsh plugin --profile web add agents-gitflow-guard
|
|
43
|
+
# ...或锁定已知良好版本(推荐; 同时绕开 registry 陈旧缓存)
|
|
44
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.9
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
> **版本坑**: 裸 `add` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。pnpm 打印的 peer 依赖 *警告* 属预期: DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`(插件正常工作)。
|
|
44
48
|
**第 2 步——配置**,在**项目根目录**创建 `gitflow-guard.config.json`:
|
|
45
49
|
|
|
46
50
|
```jsonc
|
|
@@ -250,6 +254,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
250
254
|
"archive": ["main"] // 可选
|
|
251
255
|
},
|
|
252
256
|
"locale": "en", // 可选: 文案语言('en' 默认, 或 'zh')
|
|
257
|
+
"strict": false, // 可选: fail-closed —— 配置异常/内部错误改为拦截, 而非告警放行
|
|
253
258
|
"ci": { "enabled": true } // 可选: gh pr checks 作参考日志
|
|
254
259
|
}
|
|
255
260
|
```
|
|
@@ -260,6 +265,7 @@ archive(可选, 发布后你亲手归档)
|
|
|
260
265
|
- 每条分支条目是精确名或正则(自动识别)。
|
|
261
266
|
- **文案语言**:默认英文;加 `"locale": "zh"` 切中文。
|
|
262
267
|
- **校验**:`integration` 必填;角色条目重叠会被拒;非法正则会报错。**任何错误都会让该项目的插件禁用并上报**(而不是用半吊子配置)。
|
|
268
|
+
- **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)——供高风险仓库选用。文件不存在或显式 `enabled: false` 两种模式下都保持静默。
|
|
263
269
|
|
|
264
270
|
---
|
|
265
271
|
|
|
@@ -292,7 +298,7 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
|
|
|
292
298
|
**从 npm registry**——标准路径,已在[快速开始](#快速开始30-秒用上)覆盖:
|
|
293
299
|
|
|
294
300
|
```bash
|
|
295
|
-
dsh plugin --profile web add agents-gitflow-guard
|
|
301
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.9 # 建议锁版本, 见上文提示
|
|
296
302
|
```
|
|
297
303
|
|
|
298
304
|
然后重启 DSH。升级用同一命令,再重启一次。
|
|
@@ -384,7 +390,9 @@ hooks:
|
|
|
384
390
|
|
|
385
391
|
不是,请注意别把它当安全工具。它是工作流守卫:把既定流程变成可机制执行的东西。基于文本的命令识别天然是尽力而为——铁心混淆命令的 agent 可以绕过解析器。
|
|
386
392
|
|
|
387
|
-
|
|
393
|
+
在其支持的命令形态内,角色边界在本地强制生效:合入受保护角色分支(integration / preview / production / archive)必须走配置好的路径(PR/MR,或生产/归档的人工合并)。常见混淆包装已纳入分类与拦截——shell 包装(`sh -c` / `bash -lc`)、子 shell 与反引号/`$()` 内嵌、`env`/`command`/`nohup`/`xargs` 前缀与 `VAR=x` 赋值、绝对路径、管道与 `||` 后半段、git 全局选项(`-C .`、`--git-dir=…`)、通配 refspec(`refs/heads/*:refs/heads/*`)、当 fetch+merge 用的 `git pull`,以及 `send-pack`/`update-ref` 等 plumbing。可执行对抗语料见 `tests/accuracy-audit.spec.ts`。
|
|
394
|
+
|
|
395
|
+
已知**本地不可防**的通道:直连 forge API(`gh api repos/…/pulls/N/merge`、`curl`)与解释器子进程内嵌(`node -e "child_process.exec('git push …')"`);任意深度的引号/编码变换天然只能尽力而为。真正不可绕过的边界在你托管服务的分支保护设置。两边都用——把本守卫当作即时反馈与审计留痕,而不是安全边界。
|
|
388
396
|
|
|
389
397
|
---
|
|
390
398
|
|
|
@@ -396,7 +404,7 @@ hooks:
|
|
|
396
404
|
|
|
397
405
|
### 必须装 `gh` 或 `glab` CLI 吗?
|
|
398
406
|
|
|
399
|
-
不用。它们只是可选适配器,用来解析 `pr merge` / `mr merge` 到底指向哪个分支,好让门禁区分"合入 integration/preview"(放行)与"合入 production/archive"(拦截)
|
|
407
|
+
不用。它们只是可选适配器,用来解析 `pr merge` / `mr merge` 到底指向哪个分支,好让门禁区分"合入 integration/preview"(放行)与"合入 production/archive"(拦截)。当两个 CLI 都无法确认目标——未安装、未认证、离线或查询失败——门禁**一律拒绝合并**,即使在 feature 分支上执行也照拦:该 PR 可能实际指向生产/归档分支。等 CLI 可用后重试,或由用户亲手点合并。其余一切照常。核心校验不碰任何托管服务,所以它在 GitHub、GitLab、自托管或离线环境里行为一致。
|
|
400
408
|
|
|
401
409
|
---
|
|
402
410
|
|
package/lib/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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-
|
|
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-DTg5WMog.mjs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/platform.ts
|
|
@@ -205,35 +205,52 @@ function readStdin() {
|
|
|
205
205
|
process.stdin.on("error", () => resolve(data));
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
|
+
/** 按平台编码并输出 deny, 返回退出码 */
|
|
209
|
+
function emitDeny(platform, why, next, locale = "en") {
|
|
210
|
+
const enc = encodeDeny(platform, formatDeny(locale, why, next));
|
|
211
|
+
if (enc.stdout) process.stdout.write(enc.stdout + "\n");
|
|
212
|
+
if (enc.stderr) process.stderr.write(enc.stderr + "\n");
|
|
213
|
+
return enc.exitCode;
|
|
214
|
+
}
|
|
208
215
|
/** check: agent hook 门禁。读 stdin hook payload(或 --command), exit 0=放行 / 2=拦截(按平台编码) */
|
|
209
216
|
async function check(flags) {
|
|
210
|
-
const
|
|
217
|
+
const platformFlag = flags.platform ?? "auto";
|
|
218
|
+
let raw = "";
|
|
219
|
+
let strict = false;
|
|
211
220
|
try {
|
|
212
|
-
|
|
221
|
+
raw = flags.command != null ? "" : await readStdin();
|
|
213
222
|
const payload = flags.command != null ? {
|
|
214
223
|
command: flags.command,
|
|
215
224
|
cwd: flags.repo,
|
|
216
225
|
event: "pre"
|
|
217
|
-
} : extractHookPayload(raw,
|
|
226
|
+
} : extractHookPayload(raw, platformFlag);
|
|
218
227
|
if (!payload?.command) return 0;
|
|
219
228
|
const segments = classify(payload.command);
|
|
220
229
|
if (segments.length === 0 || segments.every((s) => s.kind === "other")) return 0;
|
|
221
230
|
const cwd = payload.cwd ?? process.cwd();
|
|
222
231
|
const repoRoot = flags.repo ?? await findRepoRoot(gitRunner, cwd);
|
|
223
232
|
if (!repoRoot) return 0;
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
const loaded = await loadConfig(repoRoot);
|
|
234
|
+
strict = loaded.strict === true || loaded.config?.strict === true;
|
|
235
|
+
if (!loaded.config?.enabled) {
|
|
236
|
+
if (loaded.errors.length > 0) {
|
|
237
|
+
const t = makeT("en");
|
|
238
|
+
if (strict) return emitDeny(platformFlag === "auto" ? detectPlatform(raw) : platformFlag, t("guardStrictConfigBroken.why"), t("guardStrictConfigBroken.next"));
|
|
239
|
+
process.stderr.write(`${t("cli.guardDisabledInvalidConfig", { err: loaded.errors.join("; ") })}\n`);
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const config = loaded.config;
|
|
244
|
+
const hookPlatform = platformFlag === "auto" ? detectPlatform(raw) : platformFlag;
|
|
227
245
|
const locale = resolveLocale(config.locale);
|
|
228
246
|
const result = await evaluateCommand(payload.command, { repoRoot });
|
|
229
|
-
if (result.outcome === "deny" && result.reason)
|
|
230
|
-
const enc = encodeDeny(hookPlatform, formatDeny(locale, result.reason.why, result.reason.next));
|
|
231
|
-
if (enc.stdout) process.stdout.write(enc.stdout + "\n");
|
|
232
|
-
if (enc.stderr) process.stderr.write(enc.stderr + "\n");
|
|
233
|
-
return enc.exitCode;
|
|
234
|
-
}
|
|
247
|
+
if (result.outcome === "deny" && result.reason) return emitDeny(hookPlatform, result.reason.why, result.reason.next, locale);
|
|
235
248
|
return 0;
|
|
236
249
|
} catch (e) {
|
|
250
|
+
if (strict) {
|
|
251
|
+
const t = makeT("en");
|
|
252
|
+
return emitDeny(platformFlag === "auto" ? detectPlatform(raw) : platformFlag, t("guardStrictInternalError.why", { msg: e.message }), t("guardStrictInternalError.next"));
|
|
253
|
+
}
|
|
237
254
|
process.stderr.write(`${makeT("en")("cli.checkInternalError", { msg: e.message })}\n`);
|
|
238
255
|
return 0;
|
|
239
256
|
}
|
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-DTg5WMog.mjs";
|
|
2
2
|
export { appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
|
|
@@ -2,11 +2,58 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
//#region src/classify.ts
|
|
5
|
-
/** 拆分命令为多段(&& / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
|
|
5
|
+
/** 拆分命令为多段(&& / || / | / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
|
|
6
6
|
function classify(command, ctx = {}) {
|
|
7
|
-
|
|
7
|
+
const { plain, nested } = extractNested(command);
|
|
8
|
+
return [...splitSegments(plain).flatMap((seg) => classifySegment(seg, ctx)), ...nested.flatMap((n) => classify(n, ctx))];
|
|
8
9
|
}
|
|
9
|
-
/**
|
|
10
|
+
/** 提取反引号与 $() 内层命令文本一并送分类(单引号内不展开, 与 shell 语义一致); 外层文本剥离内嵌段后返回 */
|
|
11
|
+
function extractNested(command) {
|
|
12
|
+
const nested = [];
|
|
13
|
+
let plain = "";
|
|
14
|
+
let inSingle = false;
|
|
15
|
+
for (let i = 0; i < command.length; i++) {
|
|
16
|
+
const ch = command[i];
|
|
17
|
+
if (inSingle) {
|
|
18
|
+
plain += ch;
|
|
19
|
+
if (ch === "'") inSingle = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "'") {
|
|
23
|
+
inSingle = true;
|
|
24
|
+
plain += ch;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "`") {
|
|
28
|
+
const end = command.indexOf("`", i + 1);
|
|
29
|
+
if (end === -1) {
|
|
30
|
+
plain += ch;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
nested.push(command.slice(i + 1, end));
|
|
34
|
+
i = end;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
38
|
+
let depth = 1;
|
|
39
|
+
let j = i + 2;
|
|
40
|
+
while (j < command.length && depth > 0) {
|
|
41
|
+
if (command[j] === "(") depth++;
|
|
42
|
+
else if (command[j] === ")") depth--;
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
nested.push(command.slice(i + 2, depth === 0 ? j - 1 : j));
|
|
46
|
+
i = j - 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
plain += ch;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
plain,
|
|
53
|
+
nested
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** 引号感知拆分: 保护 "..." 与 '...' 内的 && / || / | / ; / 换行 */
|
|
10
57
|
function splitSegments(command) {
|
|
11
58
|
const segments = [];
|
|
12
59
|
let current = "";
|
|
@@ -32,7 +79,12 @@ function splitSegments(command) {
|
|
|
32
79
|
i++;
|
|
33
80
|
continue;
|
|
34
81
|
}
|
|
35
|
-
if (ch === "
|
|
82
|
+
if (ch === "|" && command[i + 1] === "|") {
|
|
83
|
+
push();
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === ";" || ch === "\n" || ch === "|") {
|
|
36
88
|
push();
|
|
37
89
|
continue;
|
|
38
90
|
}
|
|
@@ -41,31 +93,128 @@ function splitSegments(command) {
|
|
|
41
93
|
push();
|
|
42
94
|
return segments;
|
|
43
95
|
}
|
|
96
|
+
/** shell 解释器包装(sh/bash/zsh -c "<script>"): 脚本文本整体重分类 */
|
|
97
|
+
const SHELLS = /* @__PURE__ */ new Set([
|
|
98
|
+
"sh",
|
|
99
|
+
"bash",
|
|
100
|
+
"zsh",
|
|
101
|
+
"dash",
|
|
102
|
+
"ksh"
|
|
103
|
+
]);
|
|
104
|
+
/** 可剥离的执行前缀(env/nohup/xargs/command 及 VAR=x 赋值) */
|
|
105
|
+
const WRAPPERS = /* @__PURE__ */ new Set([
|
|
106
|
+
"env",
|
|
107
|
+
"command",
|
|
108
|
+
"nohup",
|
|
109
|
+
"xargs"
|
|
110
|
+
]);
|
|
44
111
|
function classifySegment(segment, ctx) {
|
|
45
|
-
const
|
|
112
|
+
const trimmed = segment.trim();
|
|
113
|
+
const tokens = tokenize(trimmed.startsWith("(") && trimmed.endsWith(")") ? trimmed.slice(1, -1).trim() : trimmed);
|
|
46
114
|
if (tokens.length === 0) return [{ kind: "other" }];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
115
|
+
return classifyTokens(tokens, ctx);
|
|
116
|
+
}
|
|
117
|
+
/** 分派: 已知命令直接解析; 包装器剥壳后递归(token 只减不增, 必然终止) */
|
|
118
|
+
function classifyTokens(tokens, ctx) {
|
|
119
|
+
const rawCmd = tokens[0];
|
|
120
|
+
const cmd = rawCmd.includes("/") ? rawCmd.slice(rawCmd.lastIndexOf("/") + 1) : rawCmd;
|
|
121
|
+
if (SHELLS.has(cmd)) return classifyShellWrapped(tokens, ctx);
|
|
122
|
+
if (cmd === "env") return classifyTokens(stripEnvArgs(tokens.slice(1)), ctx);
|
|
123
|
+
if (WRAPPERS.has(cmd)) return classifyTokens(stripWrapperArgs(tokens.slice(1)), ctx);
|
|
124
|
+
if (/^[\w-][\w.-]*=/.test(rawCmd)) return classifyTokens(tokens.slice(1), ctx);
|
|
125
|
+
if (cmd === "git") return classifyGit(tokens.slice(1), ctx);
|
|
126
|
+
if (cmd === "gh") return classifyGh(tokens.slice(1));
|
|
127
|
+
if (cmd === "glab") return classifyGlab(tokens.slice(1));
|
|
51
128
|
if (cmd === "gitflow-guard") return [{
|
|
52
129
|
kind: "guard-cli",
|
|
53
|
-
sub: guardSub(
|
|
130
|
+
sub: guardSub(tokens.slice(1))
|
|
54
131
|
}];
|
|
55
132
|
return [{ kind: "other" }];
|
|
56
133
|
}
|
|
134
|
+
/** sh/bash -lc "<script>": 定位 -c(含合并短旗标如 -lc)取脚本文本递归; 取不到按 other 放行 */
|
|
135
|
+
function classifyShellWrapped(tokens, ctx) {
|
|
136
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
137
|
+
const a = tokens[i];
|
|
138
|
+
if (!(a === "-c" || a.startsWith("-") && !a.startsWith("--") && a.includes("c"))) continue;
|
|
139
|
+
const script = tokens[i + 1];
|
|
140
|
+
if (script == null) break;
|
|
141
|
+
return script.length > 0 ? classify(script, ctx) : [{ kind: "other" }];
|
|
142
|
+
}
|
|
143
|
+
return [{ kind: "other" }];
|
|
144
|
+
}
|
|
145
|
+
/** env 参数剥离: 旗标与 VAR=x 赋值;-u/--unset 消费下一个参数 */
|
|
146
|
+
function stripEnvArgs(args) {
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < args.length) {
|
|
149
|
+
const a = args[i];
|
|
150
|
+
if (a === "-u" || a === "--unset") {
|
|
151
|
+
i += args[i + 1] != null ? 2 : 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (a.startsWith("-") || /^[\w-]+=/.test(a)) {
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
return args.slice(i);
|
|
161
|
+
}
|
|
162
|
+
/** nohup/xargs/command 参数剥离: 旗标、纯数字(xargs -n 2 的值)与 VAR=x */
|
|
163
|
+
function stripWrapperArgs(args) {
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < args.length && (args[i].startsWith("-") || /^\d+$/.test(args[i]) || /^[\w-]+=/.test(args[i]))) i++;
|
|
166
|
+
return args.slice(i);
|
|
167
|
+
}
|
|
57
168
|
/** 分词: 引号内的空格不拆分 */
|
|
58
169
|
function tokenize(segment) {
|
|
59
170
|
return segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((t) => t.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
60
171
|
}
|
|
61
172
|
function classifyGit(args, ctx) {
|
|
62
|
-
const [sub, ...rest] = args;
|
|
173
|
+
const [sub, ...rest] = stripGlobalOptions(args);
|
|
63
174
|
if (sub === "push") return parsePush(rest, ctx);
|
|
175
|
+
if (sub === "pull") return parsePull(rest);
|
|
64
176
|
if (sub === "merge") return parseMerge(rest);
|
|
65
177
|
if (sub === "branch") return parseBranch(rest);
|
|
66
178
|
if (sub === "checkout" || sub === "switch") return parseCheckout(rest);
|
|
179
|
+
if (sub === "send-pack") return parseSendPack(rest);
|
|
180
|
+
if (sub === "update-ref") return parseUpdateRef(rest);
|
|
67
181
|
return [{ kind: "other" }];
|
|
68
182
|
}
|
|
183
|
+
/** 剥离子命令前的全局选项(-C <path> / -c <k=v> / --git-dir 等), 否则 git -C . push 会被判 other */
|
|
184
|
+
function stripGlobalOptions(args) {
|
|
185
|
+
const WITH_VALUE = /* @__PURE__ */ new Set([
|
|
186
|
+
"-C",
|
|
187
|
+
"-c",
|
|
188
|
+
"--git-dir",
|
|
189
|
+
"--work-tree",
|
|
190
|
+
"--namespace",
|
|
191
|
+
"--super-prefix"
|
|
192
|
+
]);
|
|
193
|
+
const BARE = /* @__PURE__ */ new Set([
|
|
194
|
+
"--bare",
|
|
195
|
+
"--no-pager",
|
|
196
|
+
"--no-optional-locks",
|
|
197
|
+
"--paginate",
|
|
198
|
+
"--no-replace-objects",
|
|
199
|
+
"--literal-pathspecs",
|
|
200
|
+
"-p",
|
|
201
|
+
"-P"
|
|
202
|
+
]);
|
|
203
|
+
let i = 0;
|
|
204
|
+
while (i < args.length) {
|
|
205
|
+
const a = args[i];
|
|
206
|
+
if (BARE.has(a) || /^--(git-dir|work-tree|namespace|super-prefix)=/.test(a)) {
|
|
207
|
+
i++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (WITH_VALUE.has(a)) {
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
return args.slice(i);
|
|
217
|
+
}
|
|
69
218
|
/** 分支切换: 门禁放行, 分支状态由 evaluateCommand 按段模拟 */
|
|
70
219
|
function parseCheckout(args) {
|
|
71
220
|
const first = args[0];
|
|
@@ -106,47 +255,72 @@ function parsePush(args, ctx) {
|
|
|
106
255
|
delete: false,
|
|
107
256
|
all: true
|
|
108
257
|
}];
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
258
|
+
const [first, second] = nonFlag;
|
|
259
|
+
if (first == null) return [{
|
|
111
260
|
kind: "push",
|
|
112
|
-
dst:
|
|
261
|
+
dst: null,
|
|
113
262
|
force,
|
|
114
263
|
delete: false
|
|
115
264
|
}];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
265
|
+
if (second == null) return [{
|
|
266
|
+
kind: "push",
|
|
267
|
+
dst: stripRefPrefix(first),
|
|
268
|
+
force,
|
|
269
|
+
delete: isDelete
|
|
270
|
+
}, {
|
|
271
|
+
kind: "push",
|
|
272
|
+
dst: null,
|
|
273
|
+
force,
|
|
274
|
+
delete: isDelete
|
|
275
|
+
}];
|
|
276
|
+
return mapRefspecs(nonFlag.slice(1), {
|
|
277
|
+
force,
|
|
278
|
+
delete: isDelete
|
|
279
|
+
}, ctx);
|
|
280
|
+
}
|
|
281
|
+
/** refspec → push 分类。含 * 视为批量推送(与 --all 同级); HEAD/空 dst 延迟为 null(门禁按模拟分支解析) */
|
|
282
|
+
function mapRefspecs(refspecs, base, ctx) {
|
|
283
|
+
return refspecs.map((raw) => {
|
|
284
|
+
let refForce = base.force;
|
|
285
|
+
if (raw.startsWith("+")) {
|
|
119
286
|
refForce = true;
|
|
120
|
-
|
|
287
|
+
raw = raw.slice(1);
|
|
121
288
|
}
|
|
122
|
-
if (
|
|
289
|
+
if (raw.includes("*")) return {
|
|
290
|
+
kind: "push",
|
|
291
|
+
dst: null,
|
|
292
|
+
force: refForce,
|
|
293
|
+
delete: false,
|
|
294
|
+
all: true
|
|
295
|
+
};
|
|
296
|
+
if (raw.startsWith(":")) return {
|
|
123
297
|
kind: "push",
|
|
124
|
-
dst: stripRefPrefix(
|
|
298
|
+
dst: stripRefPrefix(raw.slice(1)) || null,
|
|
125
299
|
force: refForce,
|
|
126
300
|
delete: true
|
|
127
301
|
};
|
|
128
|
-
const colon =
|
|
302
|
+
const colon = raw.indexOf(":");
|
|
129
303
|
if (colon >= 0) {
|
|
130
|
-
const deleteTarget =
|
|
131
|
-
const dst = deleteTarget ?
|
|
304
|
+
const deleteTarget = raw.endsWith(":");
|
|
305
|
+
const dst = deleteTarget ? raw.slice(colon + 1, raw.length - 1) || raw.slice(0, colon) : raw.slice(colon + 1);
|
|
132
306
|
return {
|
|
133
307
|
kind: "push",
|
|
134
308
|
dst: dst ? stripRefPrefix(dst) : null,
|
|
135
309
|
force: refForce,
|
|
136
|
-
delete: deleteTarget ||
|
|
310
|
+
delete: deleteTarget || base.delete
|
|
137
311
|
};
|
|
138
312
|
}
|
|
139
|
-
if (
|
|
313
|
+
if (raw === "HEAD") return {
|
|
140
314
|
kind: "push",
|
|
141
|
-
dst:
|
|
315
|
+
dst: null,
|
|
142
316
|
force: refForce,
|
|
143
|
-
delete:
|
|
317
|
+
delete: base.delete
|
|
144
318
|
};
|
|
145
319
|
return {
|
|
146
320
|
kind: "push",
|
|
147
|
-
dst: stripRefPrefix(
|
|
321
|
+
dst: stripRefPrefix(raw),
|
|
148
322
|
force: refForce,
|
|
149
|
-
delete:
|
|
323
|
+
delete: base.delete
|
|
150
324
|
};
|
|
151
325
|
});
|
|
152
326
|
}
|
|
@@ -154,6 +328,59 @@ function parsePush(args, ctx) {
|
|
|
154
328
|
function stripRefPrefix(branch) {
|
|
155
329
|
return branch.startsWith("refs/heads/") ? branch.slice(11) : branch;
|
|
156
330
|
}
|
|
331
|
+
/** git pull = fetch+merge: 取最后一个非 flag 参数为远端分支名(source), 交给本地合入门禁; 无 refspec 时 source=null(同步上游语义) */
|
|
332
|
+
function parsePull(args) {
|
|
333
|
+
const nonFlags = args.filter((a) => !a.startsWith("-"));
|
|
334
|
+
const last = nonFlags[nonFlags.length - 1];
|
|
335
|
+
return [{
|
|
336
|
+
kind: "local-merge",
|
|
337
|
+
source: nonFlags.length >= 2 && last ? last.replace(/^\+/, "") : null
|
|
338
|
+
}];
|
|
339
|
+
}
|
|
340
|
+
/** git send-pack(push 的底层等价物): 首个非 flag 是 host:path, 其后 refspec 按推送语义分类 */
|
|
341
|
+
function parseSendPack(args) {
|
|
342
|
+
let force = false;
|
|
343
|
+
let all = false;
|
|
344
|
+
const nonFlag = [];
|
|
345
|
+
for (const a of args) if (a === "-f" || a === "--force") force = true;
|
|
346
|
+
else if (a === "--all") all = true;
|
|
347
|
+
else if (!a.startsWith("-")) nonFlag.push(a);
|
|
348
|
+
if (all) return [{
|
|
349
|
+
kind: "push",
|
|
350
|
+
dst: null,
|
|
351
|
+
force,
|
|
352
|
+
delete: false,
|
|
353
|
+
all: true
|
|
354
|
+
}];
|
|
355
|
+
const refspecs = nonFlag.slice(1);
|
|
356
|
+
if (refspecs.length === 0) return [{ kind: "other" }];
|
|
357
|
+
return mapRefspecs(refspecs, {
|
|
358
|
+
force,
|
|
359
|
+
delete: false
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** git update-ref 直改 refs(plumbing): 提取目标 ref(剥 refs/heads/ 前缀供角色比对); -d 为删除 */
|
|
363
|
+
function parseUpdateRef(args) {
|
|
364
|
+
let isDelete = false;
|
|
365
|
+
for (let i = 0; i < args.length; i++) {
|
|
366
|
+
const a = args[i];
|
|
367
|
+
if (a === "-d") {
|
|
368
|
+
isDelete = true;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (a === "-m" || a === "--message") {
|
|
372
|
+
i++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (a.startsWith("-")) continue;
|
|
376
|
+
return [{
|
|
377
|
+
kind: "ref-update",
|
|
378
|
+
branch: stripRefPrefix(a),
|
|
379
|
+
delete: isDelete
|
|
380
|
+
}];
|
|
381
|
+
}
|
|
382
|
+
return [{ kind: "other" }];
|
|
383
|
+
}
|
|
157
384
|
function parseMerge(args) {
|
|
158
385
|
if (args.some((a) => a === "--abort")) return [{ kind: "other" }];
|
|
159
386
|
return [{
|
|
@@ -268,7 +495,7 @@ function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
|
268
495
|
errors
|
|
269
496
|
};
|
|
270
497
|
}
|
|
271
|
-
/** 合并默认值并校验; 任何校验错误都会导致未启用 */
|
|
498
|
+
/** 合并默认值并校验; 任何校验错误都会导致未启用(strict 位仍从原文提取, 供 fail-closed 判定) */
|
|
272
499
|
function mergeConfig(raw) {
|
|
273
500
|
const errors = [];
|
|
274
501
|
if (typeof raw !== "object" || raw === null) return {
|
|
@@ -276,6 +503,8 @@ function mergeConfig(raw) {
|
|
|
276
503
|
errors: ["Config file must be a JSON object"]
|
|
277
504
|
};
|
|
278
505
|
const r = raw;
|
|
506
|
+
const strict = r.strict === true ? true : r.strict === false ? false : void 0;
|
|
507
|
+
if (r.strict !== void 0 && typeof r.strict !== "boolean") errors.push("strict must be a boolean");
|
|
279
508
|
const config = {
|
|
280
509
|
...DEFAULT_CONFIG,
|
|
281
510
|
ci: { ...DEFAULT_CONFIG.ci },
|
|
@@ -312,10 +541,12 @@ function mergeConfig(raw) {
|
|
|
312
541
|
}
|
|
313
542
|
const ci = r.ci ?? {};
|
|
314
543
|
if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
|
|
544
|
+
if (strict !== void 0) config.strict = strict;
|
|
315
545
|
errors.push(...validateConfig(config));
|
|
316
546
|
return {
|
|
317
547
|
config: errors.length > 0 ? null : config,
|
|
318
|
-
errors
|
|
548
|
+
errors,
|
|
549
|
+
...strict !== void 0 ? { strict } : {}
|
|
319
550
|
};
|
|
320
551
|
}
|
|
321
552
|
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
@@ -346,9 +577,9 @@ function validateConfig(config) {
|
|
|
346
577
|
}
|
|
347
578
|
/** 从项目根加载配置; 无文件 = 未启用(opt-in) */
|
|
348
579
|
async function loadConfig(repoRoot) {
|
|
580
|
+
let text;
|
|
349
581
|
try {
|
|
350
|
-
|
|
351
|
-
return mergeConfig(JSON.parse(text));
|
|
582
|
+
text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
352
583
|
} catch (e) {
|
|
353
584
|
if (e.code === "ENOENT") return {
|
|
354
585
|
config: null,
|
|
@@ -359,6 +590,18 @@ async function loadConfig(repoRoot) {
|
|
|
359
590
|
errors: [`Failed to read config file: ${e.message}`]
|
|
360
591
|
};
|
|
361
592
|
}
|
|
593
|
+
let raw;
|
|
594
|
+
try {
|
|
595
|
+
raw = JSON.parse(text);
|
|
596
|
+
} catch (e) {
|
|
597
|
+
const strict = /"strict"\s*:\s*true/.test(text) || void 0;
|
|
598
|
+
return {
|
|
599
|
+
config: null,
|
|
600
|
+
errors: [`Failed to read config file: ${e.message}`],
|
|
601
|
+
...strict ? { strict } : {}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return mergeConfig(raw);
|
|
362
605
|
}
|
|
363
606
|
//#endregion
|
|
364
607
|
//#region src/i18n.ts
|
|
@@ -372,6 +615,8 @@ const en = {
|
|
|
372
615
|
"head.unknown": () => "current branch",
|
|
373
616
|
"denyDeleteOrForce.why": (v) => `Protected branch "${v.branch}" may not be deleted or force-pushed`,
|
|
374
617
|
"denyDeleteOrForce.next": () => "Delete/force-push on a feature branch outside the protected branches; protected branches are managed by you.",
|
|
618
|
+
"refUpdateProtected.why": (v) => `Protected branch "${v.branch}" forbids direct ref updates (update-ref)`,
|
|
619
|
+
"refUpdateProtected.next": () => "Update protected branches via PR/MR; they are managed by you.",
|
|
375
620
|
"pushAll.why": () => "--all/--mirror push would include protected branches",
|
|
376
621
|
"pushAll.next": () => "Push branch by branch with an explicit refspec.",
|
|
377
622
|
"pushDetached.why": () => "Cannot determine the push target (detached HEAD?)",
|
|
@@ -393,8 +638,6 @@ const en = {
|
|
|
393
638
|
"prMergeArchive.next": () => "Let the user do the archive merge in their terminal or UI.",
|
|
394
639
|
"prMergeUnknown.why": () => "Cannot confirm the PR/MR target branch",
|
|
395
640
|
"prMergeUnknown.next": () => "Retry once gh/glab is available, or let the user handle it.",
|
|
396
|
-
"prMergeHead.why": () => "Cannot confirm the PR/MR target, and the head is not a feature branch",
|
|
397
|
-
"prMergeHead.next": () => "Confirm the platform CLI is available, or let the user handle it.",
|
|
398
641
|
"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}\`.`,
|
|
399
642
|
"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).`,
|
|
400
643
|
"next.production": (v) => `Production branch (${v.branch}) is PR/MR-only, and you click the merge yourself.`,
|
|
@@ -417,6 +660,11 @@ const en = {
|
|
|
417
660
|
"cli.statusLocalBranches": () => "Local branches (by role):",
|
|
418
661
|
"cli.auditEmpty": () => " No audit entries yet",
|
|
419
662
|
"cli.checkInternalError": (v) => `[gitflow-guard] check internal error, allowed through: ${v.msg}`,
|
|
663
|
+
"cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] guard disabled: invalid config: ${v.err}`,
|
|
664
|
+
"guardStrictConfigBroken.why": () => "Guard config is invalid while strict mode is enabled",
|
|
665
|
+
"guardStrictConfigBroken.next": () => "Fix gitflow-guard.config.json (or remove \"strict\": true) before retrying.",
|
|
666
|
+
"guardStrictInternalError.why": (v) => `Guard internal error while strict mode is enabled: ${v.msg}`,
|
|
667
|
+
"guardStrictInternalError.next": () => "Fix or remove \"strict\": true in gitflow-guard.config.json.",
|
|
420
668
|
"usage.text": () => `gitflow-guard — GitFlow guard CLI
|
|
421
669
|
|
|
422
670
|
Usage:
|
|
@@ -440,6 +688,8 @@ const zh = {
|
|
|
440
688
|
"head.unknown": () => "当前分支",
|
|
441
689
|
"denyDeleteOrForce.why": (v) => `受保护分支「${v.branch}」禁止删除或强推`,
|
|
442
690
|
"denyDeleteOrForce.next": () => "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理",
|
|
691
|
+
"refUpdateProtected.why": (v) => `受保护分支「${v.branch}」禁止直接改写 refs(update-ref)`,
|
|
692
|
+
"refUpdateProtected.next": () => "请通过 PR/MR 更新受保护分支; 受保护分支由用户亲手管理",
|
|
443
693
|
"pushAll.why": () => "--all/--mirror 推送会包含受保护分支",
|
|
444
694
|
"pushAll.next": () => "请逐分支推送并显式指定 refspec",
|
|
445
695
|
"pushDetached.why": () => "无法确定推送目标分支(可能处于 detached HEAD)",
|
|
@@ -461,8 +711,6 @@ const zh = {
|
|
|
461
711
|
"prMergeArchive.next": () => "请让用户在自己终端或 UI 完成归档合并",
|
|
462
712
|
"prMergeUnknown.why": () => "无法确认 PR/MR 的目标分支",
|
|
463
713
|
"prMergeUnknown.next": () => "请确认 gh/glab 可用后重试, 或让用户亲手处理",
|
|
464
|
-
"prMergeHead.why": () => "无法确认 PR/MR 目标, 且 head 不是 feature 分支",
|
|
465
|
-
"prMergeHead.next": () => "请确认平台 CLI 可用, 或让用户亲手处理",
|
|
466
714
|
"next.integration": (v) => `集成分支(${v.branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${v.branch} / glab mr create --target-branch ${v.branch}`,
|
|
467
715
|
"next.preview": (v) => `预览分支(${v.branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${v.base} 等集成分支内容先进 feature 发布分支)`,
|
|
468
716
|
"next.production": (v) => `生产分支(${v.branch})只能 PR/MR, 且合并由你亲手点击`,
|
|
@@ -485,6 +733,11 @@ const zh = {
|
|
|
485
733
|
"cli.statusLocalBranches": () => "本地分支(按角色):",
|
|
486
734
|
"cli.auditEmpty": () => " 暂无审计记录",
|
|
487
735
|
"cli.checkInternalError": (v) => `[gitflow-guard] check 内部错误, 已放行: ${v.msg}`,
|
|
736
|
+
"cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] 守卫未启用: 配置无效: ${v.err}`,
|
|
737
|
+
"guardStrictConfigBroken.why": () => "守卫配置无效, 且已启用 strict 模式",
|
|
738
|
+
"guardStrictConfigBroken.next": () => "请先修复 gitflow-guard.config.json(或移除 \"strict\": true)后重试",
|
|
739
|
+
"guardStrictInternalError.why": (v) => `守卫内部错误, 且已启用 strict 模式: ${v.msg}`,
|
|
740
|
+
"guardStrictInternalError.next": () => "请修复 gitflow-guard.config.json 或移除 \"strict\": true",
|
|
488
741
|
"usage.text": () => `gitflow-guard — GitFlow 流程守卫 CLI
|
|
489
742
|
|
|
490
743
|
用法:
|
|
@@ -564,6 +817,7 @@ function decide(classified, facts, config, t = defaultT) {
|
|
|
564
817
|
case "pr-create": return decidePrCreate(classified, facts, config, t);
|
|
565
818
|
case "pr-merge": return decidePrMerge(classified, facts, config, t);
|
|
566
819
|
case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(t("denyDeleteOrForce.why", { branch: classified.branch ?? "" }), t("denyDeleteOrForce.next")) : { kind: "allow" };
|
|
820
|
+
case "ref-update": return classified.branch != null && isProtected(roleOfBranch(classified.branch, config)) ? deny(t("refUpdateProtected.why", { branch: classified.branch }), t("refUpdateProtected.next")) : { kind: "allow" };
|
|
567
821
|
case "guard-cli": return { kind: "allow" };
|
|
568
822
|
case "checkout": return { kind: "allow" };
|
|
569
823
|
default: return { kind: "allow" };
|
|
@@ -611,18 +865,14 @@ function decidePrCreate(c, facts, config, t) {
|
|
|
611
865
|
}
|
|
612
866
|
function decidePrMerge(c, facts, config, t) {
|
|
613
867
|
const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
|
|
614
|
-
|
|
615
|
-
const
|
|
868
|
+
if (!resolved) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
869
|
+
const role = resolved.role;
|
|
616
870
|
if (role === "production") {
|
|
617
871
|
if (config.branches.production?.mergeBy === "user") return deny(t("prMergeProduction.why"), t("prMergeProduction.next"));
|
|
618
872
|
return { kind: "allow" };
|
|
619
873
|
}
|
|
620
874
|
if (role === "archive") return deny(t("prMergeArchive.why"), t("prMergeArchive.next"));
|
|
621
|
-
|
|
622
|
-
if (role === "feature" || role === "other") return { kind: "allow" };
|
|
623
|
-
if (head == null) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
624
|
-
if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
|
|
625
|
-
return deny(t("prMergeHead.why"), t("prMergeHead.next"));
|
|
875
|
+
return { kind: "allow" };
|
|
626
876
|
}
|
|
627
877
|
/** 受保护分支被拦后的下一步引导 */
|
|
628
878
|
function branchNext(branch, config, t) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-gitflow-guard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity) — integration/preview/production/archive, each with its own update rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|