agents-gitflow-guard 0.0.1 → 0.0.2
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 +152 -209
- package/README.zh.md +182 -239
- package/lib/cli.mjs +153 -80
- package/lib/index.d.mts +7 -18
- package/lib/index.mjs +2 -2
- package/lib/src-Dv6jKDsO.mjs +716 -0
- package/package.json +7 -7
- package/lib/src-DWE1n9Zh.mjs +0 -813
|
@@ -0,0 +1,716 @@
|
|
|
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
|
+
};
|
|
222
|
+
const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
|
|
223
|
+
/** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
|
|
224
|
+
function matchBranchSpec(branch, spec) {
|
|
225
|
+
if (REGEX_CHARS.test(spec)) try {
|
|
226
|
+
return new RegExp(`^(?:${spec})$`).test(branch);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return branch === spec;
|
|
231
|
+
}
|
|
232
|
+
/** 判断分支是否命中某个角色(任一分支条目) */
|
|
233
|
+
function roleMatches(branch, role) {
|
|
234
|
+
if (!branch) return false;
|
|
235
|
+
return role.branches.some((spec) => matchBranchSpec(branch, spec));
|
|
236
|
+
}
|
|
237
|
+
/** 规范化用户输入的某个角色: 数组 或 {branches:[...], update?, mergeBy?} */
|
|
238
|
+
function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
|
|
239
|
+
const errors = [];
|
|
240
|
+
let arr;
|
|
241
|
+
let update = void 0;
|
|
242
|
+
let mergeBy = void 0;
|
|
243
|
+
if (Array.isArray(raw)) arr = raw;
|
|
244
|
+
else if (typeof raw === "object" && raw !== null) {
|
|
245
|
+
const o = raw;
|
|
246
|
+
arr = o.branches;
|
|
247
|
+
update = o.update;
|
|
248
|
+
mergeBy = o.mergeBy;
|
|
249
|
+
} else return {
|
|
250
|
+
role: { branches: [] },
|
|
251
|
+
errors: ["分支角色必须是数组或 { branches: [...] }"]
|
|
252
|
+
};
|
|
253
|
+
if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches 必须是非空字符串数组");
|
|
254
|
+
const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
|
|
255
|
+
if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
|
|
256
|
+
else errors.push("update 必须是 \"pr\" 或 \"flexible\"");
|
|
257
|
+
if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
|
|
258
|
+
else errors.push("mergeBy 必须是 \"user\" 或 \"anyone\"");
|
|
259
|
+
return {
|
|
260
|
+
role,
|
|
261
|
+
errors
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** 合并默认值并校验; 任何校验错误都会导致未启用 */
|
|
265
|
+
function mergeConfig(raw) {
|
|
266
|
+
const errors = [];
|
|
267
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
268
|
+
config: null,
|
|
269
|
+
errors: ["配置文件必须是 JSON 对象"]
|
|
270
|
+
};
|
|
271
|
+
const r = raw;
|
|
272
|
+
const config = {
|
|
273
|
+
...DEFAULT_CONFIG,
|
|
274
|
+
ci: { ...DEFAULT_CONFIG.ci },
|
|
275
|
+
branches: { integration: {
|
|
276
|
+
branches: [],
|
|
277
|
+
update: "pr",
|
|
278
|
+
mergeBy: "anyone"
|
|
279
|
+
} }
|
|
280
|
+
};
|
|
281
|
+
if (typeof r.enabled === "boolean") config.enabled = r.enabled;
|
|
282
|
+
if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
|
|
283
|
+
const b = r.branches ?? {};
|
|
284
|
+
if ("integration" in b) {
|
|
285
|
+
const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
|
|
286
|
+
config.branches.integration = role;
|
|
287
|
+
errors.push(...e);
|
|
288
|
+
} else errors.push("branches.integration 必填");
|
|
289
|
+
if (b.preview !== void 0) {
|
|
290
|
+
const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
|
|
291
|
+
config.branches.preview = role;
|
|
292
|
+
errors.push(...e);
|
|
293
|
+
}
|
|
294
|
+
if (b.production !== void 0) {
|
|
295
|
+
const { role, errors: e } = normalizeRole(b.production, "pr", "user");
|
|
296
|
+
config.branches.production = role;
|
|
297
|
+
errors.push(...e);
|
|
298
|
+
}
|
|
299
|
+
if (b.archive !== void 0) {
|
|
300
|
+
const { role, errors: e } = normalizeRole(b.archive, "pr", "user");
|
|
301
|
+
config.branches.archive = role;
|
|
302
|
+
errors.push(...e);
|
|
303
|
+
}
|
|
304
|
+
const ci = r.ci ?? {};
|
|
305
|
+
if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
|
|
306
|
+
errors.push(...validateConfig(config));
|
|
307
|
+
return {
|
|
308
|
+
config: errors.length > 0 ? null : config,
|
|
309
|
+
errors
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/** 配置校验: 角色分支重叠 / 正则合法等 */
|
|
313
|
+
function validateConfig(config) {
|
|
314
|
+
const errors = [];
|
|
315
|
+
if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches 必填");
|
|
316
|
+
try {
|
|
317
|
+
new RegExp(config.featurePattern);
|
|
318
|
+
} catch {
|
|
319
|
+
errors.push(`featurePattern 不是合法正则: ${config.featurePattern}`);
|
|
320
|
+
}
|
|
321
|
+
const allRoles = [
|
|
322
|
+
"integration",
|
|
323
|
+
"preview",
|
|
324
|
+
"production",
|
|
325
|
+
"archive"
|
|
326
|
+
];
|
|
327
|
+
for (let i = 0; i < allRoles.length; i++) {
|
|
328
|
+
const a = config.branches[allRoles[i]];
|
|
329
|
+
if (!a) continue;
|
|
330
|
+
for (let j = i + 1; j < allRoles.length; j++) {
|
|
331
|
+
const bb = config.branches[allRoles[j]];
|
|
332
|
+
if (!bb) continue;
|
|
333
|
+
if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} 与 branches.${allRoles[j]} 含有相同条目`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return errors;
|
|
337
|
+
}
|
|
338
|
+
/** 从项目根加载配置; 无文件 = 未启用(opt-in) */
|
|
339
|
+
async function loadConfig(repoRoot) {
|
|
340
|
+
try {
|
|
341
|
+
const text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
|
|
342
|
+
return mergeConfig(JSON.parse(text));
|
|
343
|
+
} catch (e) {
|
|
344
|
+
if (e.code === "ENOENT") return {
|
|
345
|
+
config: null,
|
|
346
|
+
errors: []
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
config: null,
|
|
350
|
+
errors: [`读取配置文件失败: ${e.message}`]
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/gate.ts
|
|
356
|
+
const FEATURE_UNKNOWN = "当前分支";
|
|
357
|
+
const PROTECTED_ROLES = /* @__PURE__ */ new Set([
|
|
358
|
+
"integration",
|
|
359
|
+
"preview",
|
|
360
|
+
"production",
|
|
361
|
+
"archive"
|
|
362
|
+
]);
|
|
363
|
+
/** 判定分支角色: 优先角色配置, 其次 featurePattern, 其余 other */
|
|
364
|
+
function roleOfBranch(branch, config) {
|
|
365
|
+
if (!branch) return "other";
|
|
366
|
+
if (config.branches.production && roleMatches(branch, config.branches.production)) return "production";
|
|
367
|
+
if (config.branches.preview && roleMatches(branch, config.branches.preview)) return "preview";
|
|
368
|
+
if (config.branches.integration && roleMatches(branch, config.branches.integration)) return "integration";
|
|
369
|
+
if (config.branches.archive && roleMatches(branch, config.branches.archive)) return "archive";
|
|
370
|
+
try {
|
|
371
|
+
if (new RegExp(config.featurePattern).test(branch)) return "feature";
|
|
372
|
+
} catch {}
|
|
373
|
+
return "other";
|
|
374
|
+
}
|
|
375
|
+
function isProtected(role) {
|
|
376
|
+
return PROTECTED_ROLES.has(role);
|
|
377
|
+
}
|
|
378
|
+
function decide(classified, facts, config) {
|
|
379
|
+
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(`受保护分支「${classified.branch}」禁止删除或强推`, "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理") : { kind: "allow" };
|
|
385
|
+
case "guard-cli": return { kind: "allow" };
|
|
386
|
+
case "checkout": return { kind: "allow" };
|
|
387
|
+
default: return { kind: "allow" };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function decidePush(c, facts, config) {
|
|
391
|
+
if (c.all) return deny("--all/--mirror 推送会包含受保护分支", "请逐分支推送并显式指定 refspec");
|
|
392
|
+
const dst = c.dst ?? facts.currentBranch;
|
|
393
|
+
if (dst == null) return deny("无法确定推送目标分支(可能处于 detached HEAD)", "请显式指定 refspec, 如 git push origin <分支名>");
|
|
394
|
+
const role = roleOfBranch(dst, config);
|
|
395
|
+
if (isProtected(role)) {
|
|
396
|
+
const flexRole = role === "integration" ? config.branches.integration : config.branches.preview;
|
|
397
|
+
if ((role === "integration" || role === "preview") && flexRole?.update === "flexible" && !c.delete) return { kind: "allow" };
|
|
398
|
+
return deny(c.delete ? `受保护分支「${dst}」禁止删除` : `受保护分支「${dst}」禁止直推${c.force ? "(含强推)" : ""}`, branchNext(dst, config));
|
|
399
|
+
}
|
|
400
|
+
return { kind: "allow" };
|
|
401
|
+
}
|
|
402
|
+
function decideMerge(c, facts, config) {
|
|
403
|
+
const currentRole = roleOfBranch(facts.currentBranch, config);
|
|
404
|
+
const source = c.source;
|
|
405
|
+
const sourceRole = source ? roleOfBranch(source, config) : null;
|
|
406
|
+
if (currentRole === "production" || currentRole === "archive") return deny(`合入${roleLabel(currentRole, facts.currentBranch)}仅允许用户亲手执行`, "请在你自己终端(或 UI)完成该合并; agent 不能替你操作");
|
|
407
|
+
if (currentRole === "integration" || currentRole === "preview") {
|
|
408
|
+
if (source == null) return { kind: "allow" };
|
|
409
|
+
if (sourceRole != null && isProtected(sourceRole)) return { kind: "allow" };
|
|
410
|
+
if ((currentRole === "integration" ? config.branches.integration : config.branches.preview)?.update === "flexible") return { kind: "allow" };
|
|
411
|
+
return deny(`${roleLabel(currentRole, facts.currentBranch)}(${facts.currentBranch})禁止本地合入 feature: 须通过 PR/MR`, `先推 feature 分支, 再创建指向 ${facts.currentBranch} 的 PR/MR`);
|
|
412
|
+
}
|
|
413
|
+
return { kind: "allow" };
|
|
414
|
+
}
|
|
415
|
+
function decidePrCreate(c, facts, config) {
|
|
416
|
+
if (c.target == null) return deny("无法确定 PR/MR 目标分支", `请显式指定 --base/--target-branch(如 gh pr create --base ${config.branches.integration.branches[0]})`);
|
|
417
|
+
const targetRole = roleOfBranch(c.target, config);
|
|
418
|
+
const head = facts.currentBranch;
|
|
419
|
+
const headRole = roleOfBranch(head, config);
|
|
420
|
+
if (targetRole === "archive") return deny("归档分支(archive)仅用户亲手操作, 不允许创建指向它的 PR/MR", "发布/归档由你自己在终端或 UI 完成");
|
|
421
|
+
if (targetRole === "integration" || targetRole === "preview" || targetRole === "production") {
|
|
422
|
+
if (headRole !== "feature") return deny(`当前分支(${head ?? FEATURE_UNKNOWN})不是 feature 分支, 不能作为指向${roleLabel(targetRole, c.target)}的 PR/MR 源`, "请从 feature/topic 分支上创建指向集成/预览/生产分支的 PR/MR");
|
|
423
|
+
return { kind: "allow" };
|
|
424
|
+
}
|
|
425
|
+
return { kind: "allow" };
|
|
426
|
+
}
|
|
427
|
+
function decidePrMerge(c, facts, config) {
|
|
428
|
+
const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
|
|
429
|
+
const role = resolved?.role ?? null;
|
|
430
|
+
const head = resolved?.head ?? facts.currentBranch;
|
|
431
|
+
if (role === "production") {
|
|
432
|
+
if (config.branches.production?.mergeBy === "user") return deny("合入生产(production)分支仅允许用户亲手点合并", "请在 GitLab/GitHub 的 MR/PR 页面上由你本人点击合并");
|
|
433
|
+
return { kind: "allow" };
|
|
434
|
+
}
|
|
435
|
+
if (role === "archive") return deny("合入归档分支(archive)仅允许用户亲手执行", "请让用户在自己终端或 UI 完成归档合并");
|
|
436
|
+
if (role === "integration" || role === "preview") return { kind: "allow" };
|
|
437
|
+
if (role === "feature" || role === "other") return { kind: "allow" };
|
|
438
|
+
if (head == null) return deny("无法确认 PR/MR 的目标分支", "请确认 gh/glab 可用后重试, 或让用户亲手处理");
|
|
439
|
+
if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
|
|
440
|
+
return deny("无法确认 PR/MR 目标, 且 head 不是 feature 分支", "请确认平台 CLI 可用, 或让用户亲手处理");
|
|
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];
|
|
459
|
+
}
|
|
460
|
+
/** 受保护分支被拦后的下一步引导 */
|
|
461
|
+
function branchNext(branch, config) {
|
|
462
|
+
if (branch == null) return "请明确目标分支后重试";
|
|
463
|
+
const base = config.branches.integration.branches[0] ?? "集成分支";
|
|
464
|
+
switch (roleOfBranch(branch, config)) {
|
|
465
|
+
case "integration": return `集成分支(${branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${branch} / glab mr create --target-branch ${branch}`;
|
|
466
|
+
case "preview": return `预览分支(${branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${base} 等集成分支内容先进 feature 发布分支)`;
|
|
467
|
+
case "production": return `生产分支(${branch})只能 PR/MR, 且合并由你亲手点击`;
|
|
468
|
+
default: return `归档分支(${branch})仅用户亲手操作`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/repo.ts
|
|
473
|
+
function makeRunner(bin) {
|
|
474
|
+
return { async run(args, cwd) {
|
|
475
|
+
return await new Promise((resolve) => {
|
|
476
|
+
execFile(bin, args, {
|
|
477
|
+
cwd,
|
|
478
|
+
maxBuffer: 16777216
|
|
479
|
+
}, (err, stdout, stderr) => {
|
|
480
|
+
resolve({
|
|
481
|
+
code: err ? typeof err.code === "number" ? err.code : 1 : 0,
|
|
482
|
+
stdout: stdout ?? "",
|
|
483
|
+
stderr: stderr ?? ""
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
} };
|
|
488
|
+
}
|
|
489
|
+
const gitRunner = makeRunner("git");
|
|
490
|
+
/** GitHub 适配器: gh */
|
|
491
|
+
const ghRunner = makeRunner("gh");
|
|
492
|
+
/** GitLab 适配器: glab */
|
|
493
|
+
const glabRunner = makeRunner("glab");
|
|
494
|
+
async function findRepoRoot(runner, cwd) {
|
|
495
|
+
const r = await runner.run(["rev-parse", "--show-toplevel"], cwd);
|
|
496
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
497
|
+
}
|
|
498
|
+
async function currentBranch(runner, cwd) {
|
|
499
|
+
const r = await runner.run(["branch", "--show-current"], cwd);
|
|
500
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
501
|
+
}
|
|
502
|
+
/** gh pr view: 返回 base/head 分支名; PR 不存在或 gh 不可用 → null */
|
|
503
|
+
async function ghPrInfo(runner, cwd, pr) {
|
|
504
|
+
return viewPrInfo(runner, pr ? [
|
|
505
|
+
"pr",
|
|
506
|
+
"view",
|
|
507
|
+
pr,
|
|
508
|
+
"--json",
|
|
509
|
+
"baseRefName,headRefName"
|
|
510
|
+
] : [
|
|
511
|
+
"pr",
|
|
512
|
+
"view",
|
|
513
|
+
"--json",
|
|
514
|
+
"baseRefName,headRefName"
|
|
515
|
+
], cwd, ["baseRefName", "headRefName"]);
|
|
516
|
+
}
|
|
517
|
+
/** glab mr view: 返回 target(基地)/source(源) 分支名; 失败返回 null */
|
|
518
|
+
async function glabMrInfo(runner, cwd, mr) {
|
|
519
|
+
const args = mr ? [
|
|
520
|
+
"mr",
|
|
521
|
+
"view",
|
|
522
|
+
mr,
|
|
523
|
+
"--output",
|
|
524
|
+
"json"
|
|
525
|
+
] : [
|
|
526
|
+
"mr",
|
|
527
|
+
"view",
|
|
528
|
+
"--output",
|
|
529
|
+
"json"
|
|
530
|
+
];
|
|
531
|
+
const r = await runner.run(args, cwd);
|
|
532
|
+
if (r.code !== 0) return null;
|
|
533
|
+
try {
|
|
534
|
+
const j = JSON.parse(r.stdout);
|
|
535
|
+
if (typeof j.target_branch === "string" && typeof j.source_branch === "string") return {
|
|
536
|
+
base: j.target_branch,
|
|
537
|
+
head: j.source_branch
|
|
538
|
+
};
|
|
539
|
+
return null;
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function viewPrInfo(runner, args, cwd, fields) {
|
|
545
|
+
const r = await runner.run(args, cwd);
|
|
546
|
+
if (r.code !== 0) return null;
|
|
547
|
+
try {
|
|
548
|
+
const j = JSON.parse(r.stdout);
|
|
549
|
+
if (typeof j[fields[0]] === "string" && typeof j[fields[1]] === "string") return {
|
|
550
|
+
base: j[fields[0]],
|
|
551
|
+
head: j[fields[1]]
|
|
552
|
+
};
|
|
553
|
+
return null;
|
|
554
|
+
} catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** gh pr checks: 返回 PR 检查状态(SUCCESS/FAILURE/PENDING/...); 查不到返回 null(自动跳过) */
|
|
559
|
+
async function ghPrChecks(runner, cwd, pr) {
|
|
560
|
+
if (pr == null) return null;
|
|
561
|
+
const r = await runner.run([
|
|
562
|
+
"pr",
|
|
563
|
+
"checks",
|
|
564
|
+
pr,
|
|
565
|
+
"--json",
|
|
566
|
+
"state"
|
|
567
|
+
], cwd);
|
|
568
|
+
if (r.code !== 0) return null;
|
|
569
|
+
try {
|
|
570
|
+
const states = JSON.parse(r.stdout);
|
|
571
|
+
if (!Array.isArray(states) || states.length === 0) return null;
|
|
572
|
+
const distinct = new Set(states.map((s) => String(s.state ?? "")));
|
|
573
|
+
if (distinct.has("FAILURE")) return "FAILURE";
|
|
574
|
+
if (distinct.has("PENDING") || distinct.has("IN_PROGRESS") || distinct.has("QUEUED")) return "PENDING";
|
|
575
|
+
return "SUCCESS";
|
|
576
|
+
} catch {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** 把 PR/MR 的 base(target) 分支映射为角色; 无法解析返回 null */
|
|
581
|
+
function resolvePrTarget(info, config) {
|
|
582
|
+
if (!info) return null;
|
|
583
|
+
return {
|
|
584
|
+
target: info.base,
|
|
585
|
+
role: roleOfBranch(info.base, config),
|
|
586
|
+
head: info.head
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/index.ts
|
|
591
|
+
const name = "gitflow-guard";
|
|
592
|
+
function stateDir(repoRoot) {
|
|
593
|
+
return join(repoRoot, ".git", "gitflow-guard");
|
|
594
|
+
}
|
|
595
|
+
/** 审计留痕; 失败不阻断门禁 */
|
|
596
|
+
async function appendAudit(repoRoot, entry) {
|
|
597
|
+
try {
|
|
598
|
+
await mkdir(stateDir(repoRoot), { recursive: true });
|
|
599
|
+
await appendFile(join(stateDir(repoRoot), "audit.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
|
|
600
|
+
} catch {}
|
|
601
|
+
}
|
|
602
|
+
/** 解析一条命令: 分类 → git 事实 → 门禁 → allow/deny */
|
|
603
|
+
async function evaluateCommand(command, opts) {
|
|
604
|
+
const runner = opts.runner ?? gitRunner;
|
|
605
|
+
const gh = opts.ghRunner ?? ghRunner;
|
|
606
|
+
const glab = opts.glabRunner ?? glabRunner;
|
|
607
|
+
const { config } = await loadConfig(opts.repoRoot);
|
|
608
|
+
if (!config?.enabled) return {
|
|
609
|
+
outcome: "skipped",
|
|
610
|
+
segmentCount: 0
|
|
611
|
+
};
|
|
612
|
+
const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
|
|
613
|
+
const env = {
|
|
614
|
+
repoRoot: opts.repoRoot,
|
|
615
|
+
config,
|
|
616
|
+
branch,
|
|
617
|
+
runner,
|
|
618
|
+
gh,
|
|
619
|
+
glab
|
|
620
|
+
};
|
|
621
|
+
const segments = classify(command, { currentBranch: branch });
|
|
622
|
+
let simulatedBranch = branch;
|
|
623
|
+
for (const seg of segments) {
|
|
624
|
+
const { facts } = await factsFor(seg, {
|
|
625
|
+
...env,
|
|
626
|
+
branch: simulatedBranch
|
|
627
|
+
});
|
|
628
|
+
const decision = decide(seg, facts, config);
|
|
629
|
+
if (decision.kind === "deny") {
|
|
630
|
+
await appendAudit(env.repoRoot, {
|
|
631
|
+
time: Date.now(),
|
|
632
|
+
event: "deny",
|
|
633
|
+
command,
|
|
634
|
+
reason: decision.reason
|
|
635
|
+
});
|
|
636
|
+
return {
|
|
637
|
+
outcome: "deny",
|
|
638
|
+
reason: {
|
|
639
|
+
why: decision.reason,
|
|
640
|
+
next: decision.next
|
|
641
|
+
},
|
|
642
|
+
segmentCount: segments.length
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
await logCiReference(seg, env);
|
|
646
|
+
if (seg.kind === "checkout" && seg.branch != null) simulatedBranch = seg.branch;
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
outcome: "allow",
|
|
650
|
+
segmentCount: segments.length
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/** CI 参考(可选适配器): gh pr checks 状态记入审计日志, 查不到自动跳过 */
|
|
654
|
+
async function logCiReference(seg, env) {
|
|
655
|
+
if (!env.config.ci.enabled) return;
|
|
656
|
+
if (seg.kind !== "pr-merge") return;
|
|
657
|
+
const state = await ghPrChecks(env.gh, env.repoRoot, seg.pr);
|
|
658
|
+
if (state == null) return;
|
|
659
|
+
await appendAudit(env.repoRoot, {
|
|
660
|
+
time: Date.now(),
|
|
661
|
+
event: "ci",
|
|
662
|
+
command: seg.pr ?? void 0,
|
|
663
|
+
role: state
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/** 按段预取 git 事实(异步 I/O 全部前置, 门禁保持纯函数) */
|
|
667
|
+
async function factsFor(seg, env) {
|
|
668
|
+
const { config, repoRoot, branch } = env;
|
|
669
|
+
let head = null;
|
|
670
|
+
let prRes = null;
|
|
671
|
+
if (seg.kind === "pr-merge") {
|
|
672
|
+
prRes = resolvePrTarget(await ghPrInfo(env.gh, repoRoot, seg.pr), config);
|
|
673
|
+
if (!prRes) prRes = resolvePrTarget(await glabMrInfo(env.glab, repoRoot, seg.pr), config);
|
|
674
|
+
head = prRes?.head ?? branch;
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
head,
|
|
678
|
+
facts: {
|
|
679
|
+
currentBranch: branch,
|
|
680
|
+
...prRes ? { resolvePrTarget: () => prRes } : {}
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function commandText(exec) {
|
|
685
|
+
const args = exec.arguments;
|
|
686
|
+
return typeof args?.command === "string" ? args.command : "";
|
|
687
|
+
}
|
|
688
|
+
function formatDeny(why, next) {
|
|
689
|
+
return `[gitflow-guard] 已拦截: ${why}\n下一步: ${next}`;
|
|
690
|
+
}
|
|
691
|
+
function apply(ctx, pluginConfig = {}) {
|
|
692
|
+
const toolNames = new Set(pluginConfig.toolNames ?? ["pwsh", "bash"]);
|
|
693
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
694
|
+
try {
|
|
695
|
+
const command = commandText(exec);
|
|
696
|
+
if (!command || !toolNames.has(exec.name)) return next();
|
|
697
|
+
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
698
|
+
const repoRoot = await findRepoRoot(gitRunner, cwd);
|
|
699
|
+
if (!repoRoot) return next();
|
|
700
|
+
const result = await evaluateCommand(command, {
|
|
701
|
+
repoRoot,
|
|
702
|
+
runner: gitRunner
|
|
703
|
+
});
|
|
704
|
+
if (result.outcome === "deny" && result.reason) return {
|
|
705
|
+
kind: "deny",
|
|
706
|
+
reason: formatDeny(result.reason.why, result.reason.next)
|
|
707
|
+
};
|
|
708
|
+
return next();
|
|
709
|
+
} catch (e) {
|
|
710
|
+
ctx.logger?.warn?.(`gitflow-guard: 门禁内部错误, 已放行: ${e.message}`);
|
|
711
|
+
return next();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
export { name as a, findRepoRoot as c, roleMatches as d, classify as f, formatDeny as i, gitRunner as l, apply as n, stateDir as o, evaluateCommand as r, currentBranch as s, appendAudit as t, loadConfig as u };
|