agents-gitflow-guard 0.0.1 → 0.0.3
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 +156 -211
- package/README.zh.md +185 -240
- package/lib/cli.mjs +171 -96
- package/lib/index.d.mts +11 -16
- package/lib/index.mjs +2 -2
- package/lib/src-LvZrsHEn.mjs +890 -0
- package/package.json +7 -7
- package/lib/src-DWE1n9Zh.mjs +0 -813
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
//#region src/classify.ts
|
|
5
|
+
/** 拆分命令为多段(&& / 分号 / 换行), 每段独立分类; 引号内的分隔符不算 */
|
|
6
|
+
function classify(command, ctx = {}) {
|
|
7
|
+
return splitSegments(command).flatMap((seg) => classifySegment(seg, ctx));
|
|
8
|
+
}
|
|
9
|
+
/** 引号感知拆分: 保护 "..." 与 '...' 内的 && / ; / 换行 */
|
|
10
|
+
function splitSegments(command) {
|
|
11
|
+
const segments = [];
|
|
12
|
+
let current = "";
|
|
13
|
+
let quote = null;
|
|
14
|
+
const push = () => {
|
|
15
|
+
if (current.trim()) segments.push(current.trim());
|
|
16
|
+
current = "";
|
|
17
|
+
};
|
|
18
|
+
for (let i = 0; i < command.length; i++) {
|
|
19
|
+
const ch = command[i];
|
|
20
|
+
if (quote != null) {
|
|
21
|
+
current += ch;
|
|
22
|
+
if (ch === quote) quote = null;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (ch === "\"" || ch === "'") {
|
|
26
|
+
quote = ch;
|
|
27
|
+
current += ch;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
31
|
+
push();
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (ch === ";" || ch === "\n") {
|
|
36
|
+
push();
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
current += ch;
|
|
40
|
+
}
|
|
41
|
+
push();
|
|
42
|
+
return segments;
|
|
43
|
+
}
|
|
44
|
+
function classifySegment(segment, ctx) {
|
|
45
|
+
const tokens = tokenize(segment);
|
|
46
|
+
if (tokens.length === 0) return [{ kind: "other" }];
|
|
47
|
+
const [cmd, ...rest] = tokens;
|
|
48
|
+
if (cmd === "git") return classifyGit(rest, ctx);
|
|
49
|
+
if (cmd === "gh") return classifyGh(rest);
|
|
50
|
+
if (cmd === "glab") return classifyGlab(rest);
|
|
51
|
+
if (cmd === "gitflow-guard") return [{
|
|
52
|
+
kind: "guard-cli",
|
|
53
|
+
sub: guardSub(rest)
|
|
54
|
+
}];
|
|
55
|
+
return [{ kind: "other" }];
|
|
56
|
+
}
|
|
57
|
+
/** 分词: 引号内的空格不拆分 */
|
|
58
|
+
function tokenize(segment) {
|
|
59
|
+
return segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((t) => t.replace(/^['"]|['"]$/g, "")) ?? [];
|
|
60
|
+
}
|
|
61
|
+
function classifyGit(args, ctx) {
|
|
62
|
+
const [sub, ...rest] = args;
|
|
63
|
+
if (sub === "push") return parsePush(rest, ctx);
|
|
64
|
+
if (sub === "merge") return parseMerge(rest);
|
|
65
|
+
if (sub === "branch") return parseBranch(rest);
|
|
66
|
+
if (sub === "checkout" || sub === "switch") return parseCheckout(rest);
|
|
67
|
+
return [{ kind: "other" }];
|
|
68
|
+
}
|
|
69
|
+
/** 分支切换: 门禁放行, 分支状态由 evaluateCommand 按段模拟 */
|
|
70
|
+
function parseCheckout(args) {
|
|
71
|
+
const first = args[0];
|
|
72
|
+
if (first === "--") return [{
|
|
73
|
+
kind: "checkout",
|
|
74
|
+
branch: null
|
|
75
|
+
}];
|
|
76
|
+
if (first === "-b" || first === "-B" || first === "-c" || first === "-C") {
|
|
77
|
+
const name = args[1];
|
|
78
|
+
return [{
|
|
79
|
+
kind: "checkout",
|
|
80
|
+
branch: name && !name.startsWith("-") ? name : null
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
if (first && !first.startsWith("-")) return [{
|
|
84
|
+
kind: "checkout",
|
|
85
|
+
branch: first
|
|
86
|
+
}];
|
|
87
|
+
return [{
|
|
88
|
+
kind: "checkout",
|
|
89
|
+
branch: null
|
|
90
|
+
}];
|
|
91
|
+
}
|
|
92
|
+
function parsePush(args, ctx) {
|
|
93
|
+
let force = false;
|
|
94
|
+
let isDelete = false;
|
|
95
|
+
let all = false;
|
|
96
|
+
const nonFlag = [];
|
|
97
|
+
for (const a of args) if (a === "-f" || a === "--force" || a === "--force-with-lease" || a.startsWith("--force-with-lease=")) force = true;
|
|
98
|
+
else if (a === "--delete" || a === "-d") isDelete = true;
|
|
99
|
+
else if (a === "--all" || a === "--mirror") all = true;
|
|
100
|
+
else if (a.startsWith("-")) {} else nonFlag.push(a);
|
|
101
|
+
if (all) return [{
|
|
102
|
+
kind: "push",
|
|
103
|
+
dst: null,
|
|
104
|
+
force,
|
|
105
|
+
delete: false,
|
|
106
|
+
all: true
|
|
107
|
+
}];
|
|
108
|
+
const refspecs = nonFlag.slice(1);
|
|
109
|
+
if (refspecs.length === 0) return [{
|
|
110
|
+
kind: "push",
|
|
111
|
+
dst: ctx.currentBranch ?? null,
|
|
112
|
+
force,
|
|
113
|
+
delete: false
|
|
114
|
+
}];
|
|
115
|
+
return refspecs.map((ref) => {
|
|
116
|
+
if (ref.startsWith(":")) return {
|
|
117
|
+
kind: "push",
|
|
118
|
+
dst: stripRefPrefix(ref.slice(1)) || null,
|
|
119
|
+
force,
|
|
120
|
+
delete: true
|
|
121
|
+
};
|
|
122
|
+
const colon = ref.indexOf(":");
|
|
123
|
+
if (colon >= 0) {
|
|
124
|
+
const deleteTarget = ref.endsWith(":");
|
|
125
|
+
const dst = deleteTarget ? ref.slice(colon + 1, ref.length - 1) || ref.slice(0, colon) : ref.slice(colon + 1);
|
|
126
|
+
return {
|
|
127
|
+
kind: "push",
|
|
128
|
+
dst: dst ? stripRefPrefix(dst) : null,
|
|
129
|
+
force,
|
|
130
|
+
delete: deleteTarget || isDelete
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (ref === "HEAD") return {
|
|
134
|
+
kind: "push",
|
|
135
|
+
dst: ctx.currentBranch ?? null,
|
|
136
|
+
force,
|
|
137
|
+
delete: isDelete
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
kind: "push",
|
|
141
|
+
dst: stripRefPrefix(ref),
|
|
142
|
+
force,
|
|
143
|
+
delete: isDelete
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** 全限定 refspec(refs/heads/x)剥离前缀, 与角色分支名比对 */
|
|
148
|
+
function stripRefPrefix(branch) {
|
|
149
|
+
return branch.startsWith("refs/heads/") ? branch.slice(11) : branch;
|
|
150
|
+
}
|
|
151
|
+
function parseMerge(args) {
|
|
152
|
+
if (args.some((a) => a === "--abort")) return [{ kind: "other" }];
|
|
153
|
+
return [{
|
|
154
|
+
kind: "local-merge",
|
|
155
|
+
source: args.find((a, i) => !a.startsWith("-") && args[i - 1] !== "-m" && args[i - 1] !== "--message") ?? null
|
|
156
|
+
}];
|
|
157
|
+
}
|
|
158
|
+
function parseBranch(args) {
|
|
159
|
+
const [flag, name] = args;
|
|
160
|
+
if ((flag === "-d" || flag === "-D" || flag === "--delete") && name && !name.startsWith("-")) return [{
|
|
161
|
+
kind: "branch-delete",
|
|
162
|
+
branch: name,
|
|
163
|
+
force: flag === "-D"
|
|
164
|
+
}];
|
|
165
|
+
return [{ kind: "other" }];
|
|
166
|
+
}
|
|
167
|
+
function classifyGh(args) {
|
|
168
|
+
const [sub, action, ...rest] = args;
|
|
169
|
+
if (sub !== "pr") return [{ kind: "other" }];
|
|
170
|
+
if (action === "create") return parsePrCreate(rest, ["--base", "-B"]);
|
|
171
|
+
if (action === "merge") return parsePrMerge(rest);
|
|
172
|
+
return [{ kind: "other" }];
|
|
173
|
+
}
|
|
174
|
+
/** GitLab: glab mr create --target-branch <b> / glab mr merge <id> */
|
|
175
|
+
function classifyGlab(args) {
|
|
176
|
+
const [sub, action, ...rest] = args;
|
|
177
|
+
if (sub !== "mr") return [{ kind: "other" }];
|
|
178
|
+
if (action === "create") return parsePrCreate(rest, ["--target-branch"]);
|
|
179
|
+
if (action === "merge") return parsePrMerge(rest);
|
|
180
|
+
return [{ kind: "other" }];
|
|
181
|
+
}
|
|
182
|
+
function parsePrCreate(args, targetFlags) {
|
|
183
|
+
if (hasHelpFlag(args)) return [{ kind: "other" }];
|
|
184
|
+
const out = {
|
|
185
|
+
kind: "pr-create",
|
|
186
|
+
target: null
|
|
187
|
+
};
|
|
188
|
+
for (let i = 0; i < args.length; i++) {
|
|
189
|
+
const a = args[i];
|
|
190
|
+
const flag = targetFlags.find((f) => a === f || a.startsWith(`${f}=`));
|
|
191
|
+
if (!flag) continue;
|
|
192
|
+
if (a === flag) {
|
|
193
|
+
const value = args[i + 1];
|
|
194
|
+
if (value && !value.startsWith("-")) out.target = value;
|
|
195
|
+
} else out.target = a.slice(flag.length + 1) || null;
|
|
196
|
+
}
|
|
197
|
+
return [out];
|
|
198
|
+
}
|
|
199
|
+
function parsePrMerge(args) {
|
|
200
|
+
if (hasHelpFlag(args)) return [{ kind: "other" }];
|
|
201
|
+
return [{
|
|
202
|
+
kind: "pr-merge",
|
|
203
|
+
pr: args.find((a) => !a.startsWith("-") && /^\d+$/.test(a)) ?? null
|
|
204
|
+
}];
|
|
205
|
+
}
|
|
206
|
+
function hasHelpFlag(args) {
|
|
207
|
+
return args.some((a) => a === "-h" || a === "--help" || a === "--version");
|
|
208
|
+
}
|
|
209
|
+
function guardSub(args) {
|
|
210
|
+
if (args[0] === "status") return "status";
|
|
211
|
+
return "other";
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/config.ts
|
|
215
|
+
const CONFIG_FILE = "gitflow-guard.config.json";
|
|
216
|
+
/** 默认配置(分支角色必须由项目显式配置, 无默认) */
|
|
217
|
+
const DEFAULT_CONFIG = {
|
|
218
|
+
enabled: false,
|
|
219
|
+
featurePattern: "feature/[\\w-]+",
|
|
220
|
+
ci: { enabled: true },
|
|
221
|
+
locale: "en"
|
|
222
|
+
};
|
|
223
|
+
const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
|
|
224
|
+
/** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
|
|
225
|
+
function matchBranchSpec(branch, spec) {
|
|
226
|
+
if (REGEX_CHARS.test(spec)) try {
|
|
227
|
+
return new RegExp(`^(?:${spec})$`).test(branch);
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return branch === spec;
|
|
232
|
+
}
|
|
233
|
+
/** 判断分支是否命中某个角色(任一分支条目) */
|
|
234
|
+
function roleMatches(branch, role) {
|
|
235
|
+
if (!branch) return false;
|
|
236
|
+
return role.branches.some((spec) => matchBranchSpec(branch, spec));
|
|
237
|
+
}
|
|
238
|
+
/** 规范化用户输入的某个角色: 数组 或 {branches:[...], update?, mergeBy?} */
|
|
239
|
+
function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
240
|
+
const errors = [];
|
|
241
|
+
let arr;
|
|
242
|
+
let update = void 0;
|
|
243
|
+
let mergeBy = void 0;
|
|
244
|
+
if (Array.isArray(raw)) arr = raw;
|
|
245
|
+
else if (typeof raw === "object" && raw !== null) {
|
|
246
|
+
const o = raw;
|
|
247
|
+
arr = o.branches;
|
|
248
|
+
update = o.update;
|
|
249
|
+
mergeBy = o.mergeBy;
|
|
250
|
+
} else return {
|
|
251
|
+
role: { branches: [] },
|
|
252
|
+
errors: ["Branch role must be an array or { branches: [...] }"]
|
|
253
|
+
};
|
|
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");
|
|
255
|
+
const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
|
|
256
|
+
if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
|
|
257
|
+
else errors.push("update must be \"pr\" or \"flexible\"");
|
|
258
|
+
if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
|
|
259
|
+
else errors.push("mergeBy must be \"user\" or \"anyone\"");
|
|
260
|
+
return {
|
|
261
|
+
role,
|
|
262
|
+
errors
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** 合并默认值并校验; 任何校验错误都会导致未启用 */
|
|
266
|
+
function mergeConfig(raw) {
|
|
267
|
+
const errors = [];
|
|
268
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
269
|
+
config: null,
|
|
270
|
+
errors: ["Config file must be a JSON object"]
|
|
271
|
+
};
|
|
272
|
+
const r = raw;
|
|
273
|
+
const config = {
|
|
274
|
+
...DEFAULT_CONFIG,
|
|
275
|
+
ci: { ...DEFAULT_CONFIG.ci },
|
|
276
|
+
branches: { integration: {
|
|
277
|
+
branches: [],
|
|
278
|
+
update: "pr",
|
|
279
|
+
mergeBy: "anyone"
|
|
280
|
+
} }
|
|
281
|
+
};
|
|
282
|
+
if (typeof r.enabled === "boolean") config.enabled = r.enabled;
|
|
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\"");
|
|
286
|
+
const b = r.branches ?? {};
|
|
287
|
+
if ("integration" in b) {
|
|
288
|
+
const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
|
|
289
|
+
config.branches.integration = role;
|
|
290
|
+
errors.push(...e);
|
|
291
|
+
} else errors.push("branches.integration is required");
|
|
292
|
+
if (b.preview !== void 0) {
|
|
293
|
+
const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
|
|
294
|
+
config.branches.preview = role;
|
|
295
|
+
errors.push(...e);
|
|
296
|
+
}
|
|
297
|
+
if (b.production !== void 0) {
|
|
298
|
+
const { role, errors: e } = normalizeRole(b.production, "pr", "user");
|
|
299
|
+
config.branches.production = role;
|
|
300
|
+
errors.push(...e);
|
|
301
|
+
}
|
|
302
|
+
if (b.archive !== void 0) {
|
|
303
|
+
const { role, errors: e } = normalizeRole(b.archive, "pr", "user");
|
|
304
|
+
config.branches.archive = role;
|
|
305
|
+
errors.push(...e);
|
|
306
|
+
}
|
|
307
|
+
const ci = r.ci ?? {};
|
|
308
|
+
if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
|
|
309
|
+
errors.push(...validateConfig(config));
|
|
310
|
+
return {
|
|
311
|
+
config: errors.length > 0 ? null : config,
|
|
312
|
+
errors
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
316
|
+
function validateConfig(config) {
|
|
317
|
+
const errors = [];
|
|
318
|
+
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches is required");
|
|
319
|
+
try {
|
|
320
|
+
new RegExp(config.featurePattern);
|
|
321
|
+
} catch {
|
|
322
|
+
errors.push(`featurePattern is not a valid regex: ${config.featurePattern}`);
|
|
323
|
+
}
|
|
324
|
+
const allRoles = [
|
|
325
|
+
"integration",
|
|
326
|
+
"preview",
|
|
327
|
+
"production",
|
|
328
|
+
"archive"
|
|
329
|
+
];
|
|
330
|
+
for (let i = 0; i < allRoles.length; i++) {
|
|
331
|
+
const a = config.branches[allRoles[i]];
|
|
332
|
+
if (!a) continue;
|
|
333
|
+
for (let j = i + 1; j < allRoles.length; j++) {
|
|
334
|
+
const bb = config.branches[allRoles[j]];
|
|
335
|
+
if (!bb) continue;
|
|
336
|
+
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} and branches.${allRoles[j]} share the same entries`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return errors;
|
|
340
|
+
}
|
|
341
|
+
/** 从项目根加载配置; 无文件 = 未启用(opt-in) */
|
|
342
|
+
async function loadConfig(repoRoot) {
|
|
343
|
+
try {
|
|
344
|
+
const text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
345
|
+
return mergeConfig(JSON.parse(text));
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (e.code === "ENOENT") return {
|
|
348
|
+
config: null,
|
|
349
|
+
errors: []
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
config: null,
|
|
353
|
+
errors: [`Failed to read config file: ${e.message}`]
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
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
|
|
521
|
+
//#region src/gate.ts
|
|
522
|
+
const PROTECTED_ROLES = /* @__PURE__ */ new Set([
|
|
523
|
+
"integration",
|
|
524
|
+
"preview",
|
|
525
|
+
"production",
|
|
526
|
+
"archive"
|
|
527
|
+
]);
|
|
528
|
+
/** 判别的默认英文(纯函数裸调用/测试时默认; 运行时由 evaluateCommand 按配置 locale 注入) */
|
|
529
|
+
const defaultT = makeT("en");
|
|
530
|
+
/** 判定分支角色: 优先角色配置, 其次 featurePattern, 其余 other */
|
|
531
|
+
function roleOfBranch(branch, config) {
|
|
532
|
+
if (!branch) return "other";
|
|
533
|
+
if (config.branches.production && roleMatches(branch, config.branches.production)) return "production";
|
|
534
|
+
if (config.branches.preview && roleMatches(branch, config.branches.preview)) return "preview";
|
|
535
|
+
if (config.branches.integration && roleMatches(branch, config.branches.integration)) return "integration";
|
|
536
|
+
if (config.branches.archive && roleMatches(branch, config.branches.archive)) return "archive";
|
|
537
|
+
try {
|
|
538
|
+
if (new RegExp(config.featurePattern).test(branch)) return "feature";
|
|
539
|
+
} catch {}
|
|
540
|
+
return "other";
|
|
541
|
+
}
|
|
542
|
+
function isProtected(role) {
|
|
543
|
+
return PROTECTED_ROLES.has(role);
|
|
544
|
+
}
|
|
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) {
|
|
558
|
+
switch (classified.kind) {
|
|
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" };
|
|
564
|
+
case "guard-cli": return { kind: "allow" };
|
|
565
|
+
case "checkout": return { kind: "allow" };
|
|
566
|
+
default: return { kind: "allow" };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function decidePush(c, facts, config, t) {
|
|
570
|
+
if (c.all) return deny(t("pushAll.why"), t("pushAll.next"));
|
|
571
|
+
const dst = c.dst ?? facts.currentBranch;
|
|
572
|
+
if (dst == null) return deny(t("pushDetached.why"), t("pushDetached.next"));
|
|
573
|
+
const role = roleOfBranch(dst, config);
|
|
574
|
+
if (isProtected(role)) {
|
|
575
|
+
const flexRole = role === "integration" ? config.branches.integration : config.branches.preview;
|
|
576
|
+
if ((role === "integration" || role === "preview") && flexRole?.update === "flexible" && !c.delete) return { kind: "allow" };
|
|
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));
|
|
579
|
+
}
|
|
580
|
+
return { kind: "allow" };
|
|
581
|
+
}
|
|
582
|
+
function decideMerge(c, facts, config, t) {
|
|
583
|
+
const currentRole = roleOfBranch(facts.currentBranch, config);
|
|
584
|
+
const source = c.source;
|
|
585
|
+
const sourceRole = source ? roleOfBranch(source, config) : null;
|
|
586
|
+
if (currentRole === "production" || currentRole === "archive") return deny(t("mergeProtected.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeProtected.next"));
|
|
587
|
+
if (currentRole === "integration" || currentRole === "preview") {
|
|
588
|
+
if (source == null) return { kind: "allow" };
|
|
589
|
+
if (sourceRole != null && isProtected(sourceRole)) return { kind: "allow" };
|
|
590
|
+
if ((currentRole === "integration" ? config.branches.integration : config.branches.preview)?.update === "flexible") return { kind: "allow" };
|
|
591
|
+
return deny(t("mergeFeature.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeFeature.next", { branch: facts.currentBranch ?? "" }));
|
|
592
|
+
}
|
|
593
|
+
return { kind: "allow" };
|
|
594
|
+
}
|
|
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] ?? "" }));
|
|
597
|
+
const targetRole = roleOfBranch(c.target, config);
|
|
598
|
+
const head = facts.currentBranch;
|
|
599
|
+
const headRole = roleOfBranch(head, config);
|
|
600
|
+
if (targetRole === "archive") return deny(t("prCreateArchive.why"), t("prCreateArchive.next"));
|
|
601
|
+
if (targetRole === "integration" || targetRole === "preview" || targetRole === "production") {
|
|
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"));
|
|
606
|
+
return { kind: "allow" };
|
|
607
|
+
}
|
|
608
|
+
return { kind: "allow" };
|
|
609
|
+
}
|
|
610
|
+
function decidePrMerge(c, facts, config, t) {
|
|
611
|
+
const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
|
|
612
|
+
const role = resolved?.role ?? null;
|
|
613
|
+
const head = resolved?.head ?? facts.currentBranch;
|
|
614
|
+
if (role === "production") {
|
|
615
|
+
if (config.branches.production?.mergeBy === "user") return deny(t("prMergeProduction.why"), t("prMergeProduction.next"));
|
|
616
|
+
return { kind: "allow" };
|
|
617
|
+
}
|
|
618
|
+
if (role === "archive") return deny(t("prMergeArchive.why"), t("prMergeArchive.next"));
|
|
619
|
+
if (role === "integration" || role === "preview") return { kind: "allow" };
|
|
620
|
+
if (role === "feature" || role === "other") return { kind: "allow" };
|
|
621
|
+
if (head == null) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
|
|
622
|
+
if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
|
|
623
|
+
return deny(t("prMergeHead.why"), t("prMergeHead.next"));
|
|
624
|
+
}
|
|
625
|
+
/** 受保护分支被拦后的下一步引导 */
|
|
626
|
+
function branchNext(branch, config, t) {
|
|
627
|
+
if (branch == null) return t("next.unspecified");
|
|
628
|
+
const base = config.branches.integration.branches[0] ?? "";
|
|
629
|
+
switch (roleOfBranch(branch, config)) {
|
|
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 });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/repo.ts
|
|
641
|
+
function makeRunner(bin) {
|
|
642
|
+
return { async run(args, cwd) {
|
|
643
|
+
return await new Promise((resolve) => {
|
|
644
|
+
execFile(bin, args, {
|
|
645
|
+
cwd,
|
|
646
|
+
maxBuffer: 16777216
|
|
647
|
+
}, (err, stdout, stderr) => {
|
|
648
|
+
resolve({
|
|
649
|
+
code: err ? typeof err.code === "number" ? err.code : 1 : 0,
|
|
650
|
+
stdout: stdout ?? "",
|
|
651
|
+
stderr: stderr ?? ""
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
} };
|
|
656
|
+
}
|
|
657
|
+
const gitRunner = makeRunner("git");
|
|
658
|
+
/** GitHub 适配器: gh */
|
|
659
|
+
const ghRunner = makeRunner("gh");
|
|
660
|
+
/** GitLab 适配器: glab */
|
|
661
|
+
const glabRunner = makeRunner("glab");
|
|
662
|
+
async function findRepoRoot(runner, cwd) {
|
|
663
|
+
const r = await runner.run(["rev-parse", "--show-toplevel"], cwd);
|
|
664
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
665
|
+
}
|
|
666
|
+
async function currentBranch(runner, cwd) {
|
|
667
|
+
const r = await runner.run(["branch", "--show-current"], cwd);
|
|
668
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
669
|
+
}
|
|
670
|
+
/** gh pr view: 返回 base/head 分支名; PR 不存在或 gh 不可用 → null */
|
|
671
|
+
async function ghPrInfo(runner, cwd, pr) {
|
|
672
|
+
return viewPrInfo(runner, pr ? [
|
|
673
|
+
"pr",
|
|
674
|
+
"view",
|
|
675
|
+
pr,
|
|
676
|
+
"--json",
|
|
677
|
+
"baseRefName,headRefName"
|
|
678
|
+
] : [
|
|
679
|
+
"pr",
|
|
680
|
+
"view",
|
|
681
|
+
"--json",
|
|
682
|
+
"baseRefName,headRefName"
|
|
683
|
+
], cwd, ["baseRefName", "headRefName"]);
|
|
684
|
+
}
|
|
685
|
+
/** glab mr view: 返回 target(基地)/source(源) 分支名; 失败返回 null */
|
|
686
|
+
async function glabMrInfo(runner, cwd, mr) {
|
|
687
|
+
const args = mr ? [
|
|
688
|
+
"mr",
|
|
689
|
+
"view",
|
|
690
|
+
mr,
|
|
691
|
+
"--output",
|
|
692
|
+
"json"
|
|
693
|
+
] : [
|
|
694
|
+
"mr",
|
|
695
|
+
"view",
|
|
696
|
+
"--output",
|
|
697
|
+
"json"
|
|
698
|
+
];
|
|
699
|
+
const r = await runner.run(args, cwd);
|
|
700
|
+
if (r.code !== 0) return null;
|
|
701
|
+
try {
|
|
702
|
+
const j = JSON.parse(r.stdout);
|
|
703
|
+
if (typeof j.target_branch === "string" && typeof j.source_branch === "string") return {
|
|
704
|
+
base: j.target_branch,
|
|
705
|
+
head: j.source_branch
|
|
706
|
+
};
|
|
707
|
+
return null;
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async function viewPrInfo(runner, args, cwd, fields) {
|
|
713
|
+
const r = await runner.run(args, cwd);
|
|
714
|
+
if (r.code !== 0) return null;
|
|
715
|
+
try {
|
|
716
|
+
const j = JSON.parse(r.stdout);
|
|
717
|
+
if (typeof j[fields[0]] === "string" && typeof j[fields[1]] === "string") return {
|
|
718
|
+
base: j[fields[0]],
|
|
719
|
+
head: j[fields[1]]
|
|
720
|
+
};
|
|
721
|
+
return null;
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** gh pr checks: 返回 PR 检查状态(SUCCESS/FAILURE/PENDING/...); 查不到返回 null(自动跳过) */
|
|
727
|
+
async function ghPrChecks(runner, cwd, pr) {
|
|
728
|
+
if (pr == null) return null;
|
|
729
|
+
const r = await runner.run([
|
|
730
|
+
"pr",
|
|
731
|
+
"checks",
|
|
732
|
+
pr,
|
|
733
|
+
"--json",
|
|
734
|
+
"state"
|
|
735
|
+
], cwd);
|
|
736
|
+
if (r.code !== 0) return null;
|
|
737
|
+
try {
|
|
738
|
+
const states = JSON.parse(r.stdout);
|
|
739
|
+
if (!Array.isArray(states) || states.length === 0) return null;
|
|
740
|
+
const distinct = new Set(states.map((s) => String(s.state ?? "")));
|
|
741
|
+
if (distinct.has("FAILURE")) return "FAILURE";
|
|
742
|
+
if (distinct.has("PENDING") || distinct.has("IN_PROGRESS") || distinct.has("QUEUED")) return "PENDING";
|
|
743
|
+
return "SUCCESS";
|
|
744
|
+
} catch {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/** 把 PR/MR 的 base(target) 分支映射为角色; 无法解析返回 null */
|
|
749
|
+
function resolvePrTarget(info, config) {
|
|
750
|
+
if (!info) return null;
|
|
751
|
+
return {
|
|
752
|
+
target: info.base,
|
|
753
|
+
role: roleOfBranch(info.base, config),
|
|
754
|
+
head: info.head
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/index.ts
|
|
759
|
+
const name = "gitflow-guard";
|
|
760
|
+
function stateDir(repoRoot) {
|
|
761
|
+
return join(repoRoot, ".git", "gitflow-guard");
|
|
762
|
+
}
|
|
763
|
+
/** 审计留痕; 失败不阻断门禁 */
|
|
764
|
+
async function appendAudit(repoRoot, entry) {
|
|
765
|
+
try {
|
|
766
|
+
await mkdir(stateDir(repoRoot), { recursive: true });
|
|
767
|
+
await appendFile(join(stateDir(repoRoot), "audit.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
|
|
768
|
+
} catch {}
|
|
769
|
+
}
|
|
770
|
+
/** 解析一条命令: 分类 → git 事实 → 门禁 → allow/deny */
|
|
771
|
+
async function evaluateCommand(command, opts) {
|
|
772
|
+
const runner = opts.runner ?? gitRunner;
|
|
773
|
+
const gh = opts.ghRunner ?? ghRunner;
|
|
774
|
+
const glab = opts.glabRunner ?? glabRunner;
|
|
775
|
+
const { config } = await loadConfig(opts.repoRoot);
|
|
776
|
+
if (!config?.enabled) return {
|
|
777
|
+
outcome: "skipped",
|
|
778
|
+
segmentCount: 0,
|
|
779
|
+
locale: "en"
|
|
780
|
+
};
|
|
781
|
+
const locale = resolveLocale(config.locale);
|
|
782
|
+
const t = makeT(locale);
|
|
783
|
+
const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
|
|
784
|
+
const env = {
|
|
785
|
+
repoRoot: opts.repoRoot,
|
|
786
|
+
config,
|
|
787
|
+
branch,
|
|
788
|
+
runner,
|
|
789
|
+
gh,
|
|
790
|
+
glab
|
|
791
|
+
};
|
|
792
|
+
const segments = classify(command, { currentBranch: branch });
|
|
793
|
+
let simulatedBranch = branch;
|
|
794
|
+
for (const seg of segments) {
|
|
795
|
+
const { facts } = await factsFor(seg, {
|
|
796
|
+
...env,
|
|
797
|
+
branch: simulatedBranch
|
|
798
|
+
});
|
|
799
|
+
const decision = decide(seg, facts, config, t);
|
|
800
|
+
if (decision.kind === "deny") {
|
|
801
|
+
await appendAudit(env.repoRoot, {
|
|
802
|
+
time: Date.now(),
|
|
803
|
+
event: "deny",
|
|
804
|
+
command,
|
|
805
|
+
reason: decision.reason
|
|
806
|
+
});
|
|
807
|
+
return {
|
|
808
|
+
outcome: "deny",
|
|
809
|
+
reason: {
|
|
810
|
+
why: decision.reason,
|
|
811
|
+
next: decision.next
|
|
812
|
+
},
|
|
813
|
+
segmentCount: segments.length,
|
|
814
|
+
locale
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
await logCiReference(seg, env);
|
|
818
|
+
if (seg.kind === "checkout" && seg.branch != null) simulatedBranch = seg.branch;
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
outcome: "allow",
|
|
822
|
+
segmentCount: segments.length,
|
|
823
|
+
locale
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/** CI 参考(可选适配器): gh pr checks 状态记入审计日志, 查不到自动跳过 */
|
|
827
|
+
async function logCiReference(seg, env) {
|
|
828
|
+
if (!env.config.ci.enabled) return;
|
|
829
|
+
if (seg.kind !== "pr-merge") return;
|
|
830
|
+
const state = await ghPrChecks(env.gh, env.repoRoot, seg.pr);
|
|
831
|
+
if (state == null) return;
|
|
832
|
+
await appendAudit(env.repoRoot, {
|
|
833
|
+
time: Date.now(),
|
|
834
|
+
event: "ci",
|
|
835
|
+
command: seg.pr ?? void 0,
|
|
836
|
+
role: state
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
/** 按段预取 git 事实(异步 I/O 全部前置, 门禁保持纯函数) */
|
|
840
|
+
async function factsFor(seg, env) {
|
|
841
|
+
const { config, repoRoot, branch } = env;
|
|
842
|
+
let head = null;
|
|
843
|
+
let prRes = null;
|
|
844
|
+
if (seg.kind === "pr-merge") {
|
|
845
|
+
prRes = resolvePrTarget(await ghPrInfo(env.gh, repoRoot, seg.pr), config);
|
|
846
|
+
if (!prRes) prRes = resolvePrTarget(await glabMrInfo(env.glab, repoRoot, seg.pr), config);
|
|
847
|
+
head = prRes?.head ?? branch;
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
head,
|
|
851
|
+
facts: {
|
|
852
|
+
currentBranch: branch,
|
|
853
|
+
...prRes ? { resolvePrTarget: () => prRes } : {}
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function commandText(exec) {
|
|
858
|
+
const args = exec.arguments;
|
|
859
|
+
return typeof args?.command === "string" ? args.command : "";
|
|
860
|
+
}
|
|
861
|
+
function formatDeny(locale, why, next) {
|
|
862
|
+
const t = makeT(locale);
|
|
863
|
+
return `${t("deny.header", { why })}\n${t("deny.next", { next })}`;
|
|
864
|
+
}
|
|
865
|
+
function apply(ctx, pluginConfig = {}) {
|
|
866
|
+
const toolNames = new Set(pluginConfig.toolNames ?? ["pwsh", "bash"]);
|
|
867
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
868
|
+
try {
|
|
869
|
+
const command = commandText(exec);
|
|
870
|
+
if (!command || !toolNames.has(exec.name)) return next();
|
|
871
|
+
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
872
|
+
const repoRoot = await findRepoRoot(gitRunner, cwd);
|
|
873
|
+
if (!repoRoot) return next();
|
|
874
|
+
const result = await evaluateCommand(command, {
|
|
875
|
+
repoRoot,
|
|
876
|
+
runner: gitRunner
|
|
877
|
+
});
|
|
878
|
+
if (result.outcome === "deny" && result.reason) return {
|
|
879
|
+
kind: "deny",
|
|
880
|
+
reason: formatDeny(result.locale, result.reason.why, result.reason.next)
|
|
881
|
+
};
|
|
882
|
+
return next();
|
|
883
|
+
} catch (e) {
|
|
884
|
+
ctx.logger?.warn?.(`gitflow-guard: 门禁内部错误, 已放行: ${e.message}`);
|
|
885
|
+
return next();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
//#endregion
|
|
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 };
|