harveyz-skill 0.2.1 → 0.3.0
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 +94 -15
- package/bin/cli.js +12 -2
- package/lib/bundles.js +16 -0
- package/package.json +2 -1
- package/skills/harness/git-workflow-init/SKILL.md +321 -0
- package/skills/harness/git-workflow-init/evals/evals.json +104 -0
- package/skills/harness/git-workflow-init/references/git-workflow-template.md +94 -0
- package/skills/harness/git-workflow-init/references/hook-templates.md +232 -0
- package/skills/harness/git-workflow-init/references/lock-file-format.md +34 -0
- package/skills/harness/git-workflow-init/references/render_docs.py +261 -0
- package/skills/harness/git-workflow-init/references/workflow-config.yml +99 -0
- package/skills-index.json +2 -1
- package/tools/p-launch/p-launch.sh +32 -7
- package/tools/p-launch/tests/unit.bats +78 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Git 工作流规范
|
|
2
|
+
|
|
3
|
+
本文档定义此项目的分支管理规则与开发协作约定。
|
|
4
|
+
|
|
5
|
+
> 本文档由 `git-workflow-init` 根据 `workflow-config.yml` 自动生成,请勿手动编辑。
|
|
6
|
+
> 如需修改规则,请更新 `workflow-config.yml` 后重新运行 `/git-workflow-init`。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 分支模型
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
{{BRANCH_TOPOLOGY_ASCII}}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
{{BRANCH_TABLE}}
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 分支保护规则
|
|
21
|
+
|
|
22
|
+
以下规则由 `.githooks/pre-commit` 自动强制执行:
|
|
23
|
+
|
|
24
|
+
{{PROTECTION_RULES}}
|
|
25
|
+
|
|
26
|
+
提交被拒绝时,请检查当前所在分支,改用正确的合并流程操作。
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 标准开发流程
|
|
31
|
+
|
|
32
|
+
### 开始新工作
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 始终从集成分支拉取
|
|
36
|
+
{{WORKFLOW_CHECKOUT_EXAMPLE}}
|
|
37
|
+
git pull
|
|
38
|
+
git checkout -b feature/my-feature
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 合并到集成分支
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
{{WORKFLOW_MERGE_EXAMPLE}}
|
|
45
|
+
git push
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 发布到主分支
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
{{WORKFLOW_RELEASE_EXAMPLE}}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 分支命名规范
|
|
57
|
+
|
|
58
|
+
{{NAMING_TABLE}}
|
|
59
|
+
|
|
60
|
+
使用 kebab-case,名称简短且有描述性。豁免分支({{NAMING_EXEMPT}})不受命名规范约束。
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 提交信息格式
|
|
65
|
+
|
|
66
|
+
{{COMMIT_FORMAT_SECTION}}
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Hooks 安装说明
|
|
71
|
+
|
|
72
|
+
git hooks 通过 `.githooks/` 目录管理(已纳入版本控制),并设置 `core.hooksPath = .githooks`。
|
|
73
|
+
|
|
74
|
+
新克隆仓库后需手动激活:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git config core.hooksPath .githooks
|
|
78
|
+
git config merge.ff false # 禁止 fast-forward,确保 merge commit 可被钩子审查
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
或通过 Claude Code 重新运行安装 skill:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
/git-workflow-init
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 常见问题
|
|
90
|
+
|
|
91
|
+
{{FAQ_SECTION}}
|
|
92
|
+
|
|
93
|
+
**Q:能绕过 hooks 吗?**
|
|
94
|
+
A:可以用 `git commit --no-verify`,但请记录原因并告知团队。hooks 存在有其意义,请谨慎绕过。
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Hook 代码模板
|
|
2
|
+
|
|
3
|
+
Step 6 生成 hooks 时参考此文件。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 通用结构
|
|
8
|
+
|
|
9
|
+
每个 hook 文件由三部分组成:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
固定头部(非 MANAGED,每次重新生成时原样保留)
|
|
13
|
+
│
|
|
14
|
+
├── # --- BEGIN MANAGED: <block-id> (hash:<8hex>) ---
|
|
15
|
+
│ <配置驱动的生成内容>
|
|
16
|
+
│ # --- END MANAGED: <block-id> ---
|
|
17
|
+
│
|
|
18
|
+
│ (可有多个 MANAGED 块)
|
|
19
|
+
│
|
|
20
|
+
用户自定义区(MANAGED 块之外,永不覆盖)
|
|
21
|
+
│
|
|
22
|
+
固定尾部:exit 0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### MANAGED 块 ID 规范
|
|
26
|
+
|
|
27
|
+
| hook 文件 | 块 ID |
|
|
28
|
+
|-----------|-------|
|
|
29
|
+
| pre-commit | `branches.protected/<branch-name>` |
|
|
30
|
+
| commit-msg | `commit-msg/format`、`commit-msg/length` |
|
|
31
|
+
| pre-push | `pre-push/tags`、`pre-push/force-push` |
|
|
32
|
+
| post-checkout | `post-checkout/branch-naming` |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## pre-commit
|
|
37
|
+
|
|
38
|
+
触发条件:`branches.protected` 存在时生成。
|
|
39
|
+
|
|
40
|
+
**固定头部:**
|
|
41
|
+
```sh
|
|
42
|
+
#!/bin/sh
|
|
43
|
+
# Generated by git-workflow-init v4.0.0
|
|
44
|
+
# Manual edits outside MANAGED blocks are preserved across runs.
|
|
45
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
|
46
|
+
[ -z "$BRANCH" ] && exit 0
|
|
47
|
+
IS_MERGE=0
|
|
48
|
+
[ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ] && IS_MERGE=1
|
|
49
|
+
|
|
50
|
+
merge_source_branch() {
|
|
51
|
+
msg_file="$(git rev-parse --git-dir)/MERGE_MSG"
|
|
52
|
+
[ -f "$msg_file" ] || { printf ''; return; }
|
|
53
|
+
head -1 "$msg_file" | sed "s/Merge branch '//;s/'.*//"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**每个保护分支生成一个 MANAGED 块**(以 main 为例,merge_from: staging, release/*):
|
|
58
|
+
```sh
|
|
59
|
+
# --- BEGIN MANAGED: branches.protected/main (hash:<hash>) ---
|
|
60
|
+
if [ "$BRANCH" = "main" ]; then
|
|
61
|
+
if [ "$IS_MERGE" -eq 0 ]; then
|
|
62
|
+
echo "❌ 禁止直接在 main 上提交。请在 staging 或 release/* 分支开发后合并。"
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
SRC=$(merge_source_branch)
|
|
66
|
+
case "$SRC" in
|
|
67
|
+
staging|release/*) exit 0 ;;
|
|
68
|
+
*) echo "❌ main 只接受来自 staging 或 release/* 的合并,当前来源:'${SRC:-unknown}'"; exit 1 ;;
|
|
69
|
+
esac
|
|
70
|
+
fi
|
|
71
|
+
# --- END MANAGED: branches.protected/main ---
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`case` 模式从配置的 `merge_from` 列表动态生成,`|` 分隔。
|
|
75
|
+
`allow_direct_commit: true` 时将 `exit 1` 改为 `echo "⚠️ 警告"`(不阻断)。
|
|
76
|
+
|
|
77
|
+
**固定尾部:**`exit 0`
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## commit-msg
|
|
82
|
+
|
|
83
|
+
触发条件:`commit_message.enforce: true` 时生成。
|
|
84
|
+
|
|
85
|
+
**固定头部:**
|
|
86
|
+
```sh
|
|
87
|
+
#!/bin/sh
|
|
88
|
+
# Generated by git-workflow-init v4.0.0
|
|
89
|
+
MSG=$(cat "$1")
|
|
90
|
+
SUBJECT=$(printf '%s\n' "$MSG" | head -1)
|
|
91
|
+
SUBJECT=$(printf '%s\n' "$SUBJECT" | sed '/^#/d' | sed 's/^[[:space:]]*//')
|
|
92
|
+
[ -z "$SUBJECT" ] && exit 0
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**格式检查 MANAGED 块(`commit-msg/format`):**
|
|
96
|
+
- `format: conventional`:将 `types` 列表拼接为正则 `^(feat|fix|...)(\(.+\))?: .+`;`require_scope: false` 时 scope 部分为可选 `(\(.+\))?`
|
|
97
|
+
- `format: regex`:使用配置的 `pattern` 字段直接作为 grep 表达式
|
|
98
|
+
|
|
99
|
+
**长度检查 MANAGED 块(`commit-msg/length`):**
|
|
100
|
+
```sh
|
|
101
|
+
# --- BEGIN MANAGED: commit-msg/length (hash:<hash>) ---
|
|
102
|
+
LEN=$(printf '%s' "$SUBJECT" | wc -c | tr -d ' ')
|
|
103
|
+
if [ "$LEN" -gt <max_subject_length> ]; then
|
|
104
|
+
echo "❌ 提交信息首行超过 <max_subject_length> 个字符(当前 ${LEN} 个字符)"
|
|
105
|
+
exit 1
|
|
106
|
+
fi
|
|
107
|
+
# --- END MANAGED: commit-msg/length ---
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**固定尾部:**`exit 0`
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## pre-push
|
|
115
|
+
|
|
116
|
+
触发条件:`tags.enforce: true` 或 `push_rules.enforce: true` 时生成。
|
|
117
|
+
|
|
118
|
+
**固定头部:**
|
|
119
|
+
```sh
|
|
120
|
+
#!/bin/sh
|
|
121
|
+
# Generated by git-workflow-init v4.0.0
|
|
122
|
+
while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
|
|
123
|
+
case "$remote_ref" in
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**tag 检查 MANAGED 块(`pre-push/tags`,仅当 `tags.enforce: true`):**
|
|
127
|
+
```sh
|
|
128
|
+
# --- BEGIN MANAGED: pre-push/tags (hash:<hash>) ---
|
|
129
|
+
refs/tags/*)
|
|
130
|
+
TAG=$(printf '%s' "$remote_ref" | sed 's|refs/tags/||')
|
|
131
|
+
MATCHED=0
|
|
132
|
+
for PATTERN in <allowed_patterns>; do
|
|
133
|
+
printf '%s\n' "$TAG" | grep -qE "$PATTERN" && MATCHED=1 && break
|
|
134
|
+
done
|
|
135
|
+
if [ "$MATCHED" -eq 0 ]; then
|
|
136
|
+
echo "❌ Tag 命名不符合规范:$TAG"
|
|
137
|
+
exit 1
|
|
138
|
+
fi
|
|
139
|
+
;;
|
|
140
|
+
# --- END MANAGED: pre-push/tags ---
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**force push 检查 MANAGED 块(`pre-push/force-push`,仅当 `push_rules.enforce: true`):**
|
|
144
|
+
```sh
|
|
145
|
+
# --- BEGIN MANAGED: pre-push/force-push (hash:<hash>) ---
|
|
146
|
+
refs/heads/main|refs/heads/staging)
|
|
147
|
+
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
|
148
|
+
echo "❌ 禁止 force push 到受保护分支:$remote_ref"
|
|
149
|
+
exit 1
|
|
150
|
+
fi
|
|
151
|
+
;;
|
|
152
|
+
# --- END MANAGED: pre-push/force-push ---
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`refs/heads/main|refs/heads/staging` 从 `push_rules.block_force_push` 列表动态生成。
|
|
156
|
+
|
|
157
|
+
**固定尾部:**
|
|
158
|
+
```sh
|
|
159
|
+
esac
|
|
160
|
+
done
|
|
161
|
+
exit 0
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## post-checkout
|
|
167
|
+
|
|
168
|
+
触发条件:`branch_naming.enforce: true` 时生成。
|
|
169
|
+
|
|
170
|
+
**固定头部:**
|
|
171
|
+
```sh
|
|
172
|
+
#!/bin/sh
|
|
173
|
+
# Generated by git-workflow-init v4.0.0
|
|
174
|
+
[ "$3" = "1" ] || exit 0
|
|
175
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
|
176
|
+
[ -z "$BRANCH" ] && exit 0
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**命名检查 MANAGED 块(`post-checkout/branch-naming`):**
|
|
180
|
+
```sh
|
|
181
|
+
# --- BEGIN MANAGED: post-checkout/branch-naming (hash:<hash>) ---
|
|
182
|
+
case "$BRANCH" in
|
|
183
|
+
<exempt-branch-1>|<exempt-branch-2>) exit 0 ;;
|
|
184
|
+
esac
|
|
185
|
+
MATCHED=0
|
|
186
|
+
for PATTERN in <allowed_patterns>; do
|
|
187
|
+
printf '%s\n' "$BRANCH" | grep -qE "$PATTERN" && MATCHED=1 && break
|
|
188
|
+
done
|
|
189
|
+
if [ "$MATCHED" -eq 0 ]; then
|
|
190
|
+
echo "⚠️ 分支命名不符合规范:$BRANCH"
|
|
191
|
+
echo " 允许的格式:<allowed_patterns 列表>"
|
|
192
|
+
fi
|
|
193
|
+
# --- END MANAGED: post-checkout/branch-naming ---
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`exempt` 列表生成 case 模式(`|` 分隔);`allowed_patterns` 逐条 grep。post-checkout 仅警告不阻断(`exit 0`,不 `exit 1`)。
|
|
197
|
+
|
|
198
|
+
**固定尾部:**`exit 0`
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## MANAGED 块 hash 计算
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
actual_hash=$(printf '%s' "<block_content>" | git hash-object --stdin | cut -c1-8)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`<block_content>` = BEGIN/END 标记之间的内容,去首尾空白。
|
|
209
|
+
|
|
210
|
+
## 多行内容写入方式
|
|
211
|
+
|
|
212
|
+
禁止用 `awk -v` 传多行 shell 脚本(含换行符时 awk 报错)。正确方式:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
TMPBLOCK=$(mktemp)
|
|
216
|
+
cat > "$TMPBLOCK" << 'BLOCKEOF'
|
|
217
|
+
# --- BEGIN MANAGED: branches.protected/develop (hash:PLACEHOLDER) ---
|
|
218
|
+
if [ "$BRANCH" = "develop" ]; then
|
|
219
|
+
...
|
|
220
|
+
fi
|
|
221
|
+
# --- END MANAGED: branches.protected/develop ---
|
|
222
|
+
BLOCKEOF
|
|
223
|
+
|
|
224
|
+
python3 - "$HOOKFILE" "$TMPBLOCK" << 'PYEOF'
|
|
225
|
+
import sys
|
|
226
|
+
hook = open(sys.argv[1]).read()
|
|
227
|
+
block = open(sys.argv[2]).read()
|
|
228
|
+
hook = hook.replace('\nexit 0\n', '\n' + block + '\nexit 0\n', 1)
|
|
229
|
+
open(sys.argv[1], 'w').write(hook)
|
|
230
|
+
PYEOF
|
|
231
|
+
rm "$TMPBLOCK"
|
|
232
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Lock 文件格式
|
|
2
|
+
|
|
3
|
+
路径:`.githooks/.workflow-config.lock.yml`
|
|
4
|
+
|
|
5
|
+
每次运行 git-workflow-init 成功后写入,记录本次生效的配置快照,供下次运行做 diff。
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
# Generated by git-workflow-init, do not edit manually
|
|
9
|
+
generated_at: "<ISO 8601 timestamp>"
|
|
10
|
+
skill_version: "4.0.0"
|
|
11
|
+
branches_protected:
|
|
12
|
+
- name: main
|
|
13
|
+
allow_direct_commit: false
|
|
14
|
+
merge_from: [staging, "release/*"]
|
|
15
|
+
- name: staging
|
|
16
|
+
allow_direct_commit: false
|
|
17
|
+
merge_from: ["feature/*", "fix/*", "chore/*", "doc/*"]
|
|
18
|
+
commit_message:
|
|
19
|
+
format: conventional
|
|
20
|
+
types: [feat, fix, chore, docs, refactor, test, style, perf]
|
|
21
|
+
max_subject_length: 80
|
|
22
|
+
branch_naming:
|
|
23
|
+
allowed_patterns: ["^feature/.+", "^fix/.+", "^chore/.+", "^doc/.+", "^release/.+"]
|
|
24
|
+
exempt: [main, staging]
|
|
25
|
+
tags:
|
|
26
|
+
enforce: true
|
|
27
|
+
allowed_patterns: ["^v[0-9]+\\.[0-9]+\\.[0-9]+$"]
|
|
28
|
+
require_annotated: true
|
|
29
|
+
push_rules:
|
|
30
|
+
enforce: true
|
|
31
|
+
block_force_push: [main, staging]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
只记录影响 hook 生成的字段,不需要照搬 `workflow-config.yml` 的完整结构。
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
git-workflow-init Step 7 渲染器
|
|
4
|
+
从 workflow-config.yml 动态生成 docs/reference/git-workflow.md
|
|
5
|
+
"""
|
|
6
|
+
import sys, re, os
|
|
7
|
+
|
|
8
|
+
def load_config(path):
|
|
9
|
+
"""简单 YAML 解析(只处理此 skill 使用的字段,无需完整 YAML 库)"""
|
|
10
|
+
content = open(path).read()
|
|
11
|
+
|
|
12
|
+
def extract_list_under(key, text):
|
|
13
|
+
"""提取某个 key 下的列表项(- value 或 - "value")"""
|
|
14
|
+
pattern = rf'{re.escape(key)}:\s*\n((?:[ \t]+- .+\n?)+)'
|
|
15
|
+
m = re.search(pattern, text)
|
|
16
|
+
if not m:
|
|
17
|
+
# 尝试行内列表 [a, b, c]
|
|
18
|
+
m2 = re.search(rf'{re.escape(key)}:\s*\[([^\]]+)\]', text)
|
|
19
|
+
if m2:
|
|
20
|
+
return [v.strip().strip('"') for v in m2.group(1).split(',')]
|
|
21
|
+
return []
|
|
22
|
+
return [re.sub(r'^[ \t]+- ', '', l).strip().strip('"')
|
|
23
|
+
for l in m.group(1).splitlines() if l.strip().startswith('- ')]
|
|
24
|
+
|
|
25
|
+
# 提取 branches.protected
|
|
26
|
+
protected = []
|
|
27
|
+
in_protected = False
|
|
28
|
+
current = None
|
|
29
|
+
for line in content.splitlines():
|
|
30
|
+
if re.match(r'\s+protected:', line):
|
|
31
|
+
in_protected = True
|
|
32
|
+
continue
|
|
33
|
+
if in_protected:
|
|
34
|
+
m = re.match(r'\s{4}- name:\s+(\S+)', line)
|
|
35
|
+
if m:
|
|
36
|
+
if current:
|
|
37
|
+
protected.append(current)
|
|
38
|
+
current = {'name': m.group(1), 'allow_direct_commit': False, 'merge_from': []}
|
|
39
|
+
elif current and re.match(r'\s+allow_direct_commit:\s*(true|false)', line):
|
|
40
|
+
current['allow_direct_commit'] = 'true' in line
|
|
41
|
+
elif current and re.match(r'\s+merge_from:', line):
|
|
42
|
+
pass # next lines are the list
|
|
43
|
+
elif current and re.match(r'\s{8}- ', line):
|
|
44
|
+
val = line.strip().lstrip('- ').strip('"')
|
|
45
|
+
current['merge_from'].append(val)
|
|
46
|
+
elif line.strip() and not line.startswith(' '):
|
|
47
|
+
in_protected = False
|
|
48
|
+
if current:
|
|
49
|
+
protected.append(current)
|
|
50
|
+
|
|
51
|
+
# branch_naming
|
|
52
|
+
naming_patterns = extract_list_under('allowed_patterns', content)
|
|
53
|
+
naming_exempt = extract_list_under('exempt', content)
|
|
54
|
+
|
|
55
|
+
# commit_message
|
|
56
|
+
fmt_m = re.search(r'format:\s*(\S+)', content)
|
|
57
|
+
fmt = fmt_m.group(1) if fmt_m else 'none'
|
|
58
|
+
types = extract_list_under('types', content)
|
|
59
|
+
pattern_m = re.search(r'pattern:\s*"([^"]+)"', content)
|
|
60
|
+
regex_pattern = pattern_m.group(1) if pattern_m else ''
|
|
61
|
+
max_len_m = re.search(r'max_subject_length:\s*(\d+)', content)
|
|
62
|
+
max_len = int(max_len_m.group(1)) if max_len_m else 72
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
'protected': protected,
|
|
66
|
+
'naming_patterns': naming_patterns,
|
|
67
|
+
'naming_exempt': naming_exempt,
|
|
68
|
+
'commit_format': fmt,
|
|
69
|
+
'commit_types': types,
|
|
70
|
+
'commit_regex': regex_pattern,
|
|
71
|
+
'max_subject_length': max_len,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def branch_purpose(name):
|
|
76
|
+
mapping = {
|
|
77
|
+
'main': '生产就绪代码', 'master': '生产就绪代码',
|
|
78
|
+
'staging': '集成 / 预发布', 'develop': '开发集成',
|
|
79
|
+
}
|
|
80
|
+
if name in mapping:
|
|
81
|
+
return mapping[name]
|
|
82
|
+
if name.startswith('release'):
|
|
83
|
+
return '发版准备'
|
|
84
|
+
return '—'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def pattern_to_prefix(p):
|
|
88
|
+
"""^feature/.+ → feature/"""
|
|
89
|
+
m = re.match(r'^\^?([a-z]+)/', p)
|
|
90
|
+
return m.group(1) + '/' if m else p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pattern_purpose(prefix):
|
|
94
|
+
mapping = {
|
|
95
|
+
'feature/': ('新功能', 'feature/user-auth、feature/dark-mode'),
|
|
96
|
+
'fix/': ('Bug 修复', 'fix/login-crash、fix/null-pointer'),
|
|
97
|
+
'chore/': ('维护、依赖升级等', 'chore/upgrade-deps、chore/ci-timeout'),
|
|
98
|
+
'doc/': ('文档更新', 'doc/api-reference、doc/onboarding'),
|
|
99
|
+
'docs/': ('文档更新', 'docs/api-reference、docs/onboarding'),
|
|
100
|
+
'release/': ('发版切点', 'release/1.2.0'),
|
|
101
|
+
'hotfix/': ('紧急热修复', 'hotfix/login-crash'),
|
|
102
|
+
}
|
|
103
|
+
return mapping.get(prefix, ('—', f'{prefix}example'))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render(config, template):
|
|
107
|
+
c = config
|
|
108
|
+
protected = c['protected']
|
|
109
|
+
|
|
110
|
+
# ── BRANCH_TOPOLOGY_ASCII ──────────────────────────────────────
|
|
111
|
+
# 找主分支(没有其他受保护分支 merge_from 到它的最顶层)
|
|
112
|
+
all_names = {b['name'] for b in protected}
|
|
113
|
+
merge_targets = set()
|
|
114
|
+
for b in protected:
|
|
115
|
+
for mf in b['merge_from']:
|
|
116
|
+
# mf 可能是 glob,提取固定前缀
|
|
117
|
+
base = mf.rstrip('/*')
|
|
118
|
+
if base in all_names:
|
|
119
|
+
merge_targets.add(base)
|
|
120
|
+
|
|
121
|
+
# 拓扑:逐级展开
|
|
122
|
+
def build_tree(root, depth=0):
|
|
123
|
+
b = next((x for x in protected if x['name'] == root), None)
|
|
124
|
+
if not b:
|
|
125
|
+
return ''
|
|
126
|
+
indent = ' ' * depth
|
|
127
|
+
lines = [indent + root]
|
|
128
|
+
for src in b['merge_from']:
|
|
129
|
+
src_base = src.rstrip('/*')
|
|
130
|
+
if src_base in all_names:
|
|
131
|
+
lines.append(build_tree(src_base, depth + 1))
|
|
132
|
+
else:
|
|
133
|
+
lines.append(' ' * (depth + 1) + f'<- {src}')
|
|
134
|
+
return '\n'.join(l for l in lines if l)
|
|
135
|
+
|
|
136
|
+
roots = [b['name'] for b in protected if b['name'] not in merge_targets]
|
|
137
|
+
ascii_lines = []
|
|
138
|
+
for r in roots:
|
|
139
|
+
for src in next(x for x in protected if x['name'] == r)['merge_from']:
|
|
140
|
+
src_base = src.rstrip('/*')
|
|
141
|
+
if src_base in all_names:
|
|
142
|
+
sub = next((x for x in protected if x['name'] == src_base), None)
|
|
143
|
+
ascii_lines.append(f'{r}')
|
|
144
|
+
ascii_lines.append(f' <- {src_base}')
|
|
145
|
+
for subsrc in (sub['merge_from'] if sub else []):
|
|
146
|
+
ascii_lines.append(f' <- {subsrc}')
|
|
147
|
+
else:
|
|
148
|
+
ascii_lines.append(f'{r}')
|
|
149
|
+
ascii_lines.append(f' <- {src}')
|
|
150
|
+
topology_ascii = '\n'.join(ascii_lines) if ascii_lines else ' <- '.join(b['name'] for b in protected)
|
|
151
|
+
|
|
152
|
+
# ── BRANCH_TABLE ───────────────────────────────────────────────
|
|
153
|
+
rows = ['| 分支 | 用途 | 合并目标 |', '|------|------|---------|']
|
|
154
|
+
for b in protected:
|
|
155
|
+
target = roots[0] if b['name'] != roots[0] else '—'
|
|
156
|
+
rows.append(f'| `{b["name"]}` | {branch_purpose(b["name"])} | `{target}` |')
|
|
157
|
+
for p in c['naming_patterns']:
|
|
158
|
+
prefix = pattern_to_prefix(p)
|
|
159
|
+
purpose, _ = pattern_purpose(prefix)
|
|
160
|
+
target_branch = next((b['name'] for b in protected if b['name'] not in roots), roots[0] if roots else '—')
|
|
161
|
+
rows.append(f'| `{prefix}<名称>` | {purpose} | `{target_branch}` |')
|
|
162
|
+
branch_table = '\n'.join(rows)
|
|
163
|
+
|
|
164
|
+
# ── PROTECTION_RULES ──────────────────────────────────────────
|
|
165
|
+
rule_lines = []
|
|
166
|
+
for b in protected:
|
|
167
|
+
sources = '、'.join(f'`{s}`' for s in b['merge_from'])
|
|
168
|
+
if b['allow_direct_commit']:
|
|
169
|
+
rule_lines.append(f'- **`{b["name"]}`** — 直接提交时发出警告(不阻断)。合并来源建议为 {sources}。')
|
|
170
|
+
else:
|
|
171
|
+
rule_lines.append(f'- **`{b["name"]}`** — 禁止直接提交。只接受来自 {sources} 的合并。')
|
|
172
|
+
protection_rules = '\n'.join(rule_lines)
|
|
173
|
+
|
|
174
|
+
# ── WORKFLOW EXAMPLES ─────────────────────────────────────────
|
|
175
|
+
integration = next((b['name'] for b in protected if b['name'] not in roots), roots[0] if roots else 'staging')
|
|
176
|
+
main_branch = roots[0] if roots else 'main'
|
|
177
|
+
checkout_ex = f'git checkout {integration}'
|
|
178
|
+
merge_ex = f'git checkout {integration}\ngit merge feature/my-feature'
|
|
179
|
+
release_ex = f'git checkout {main_branch}\ngit merge {integration} # 或:git merge release/x.y.z\ngit push'
|
|
180
|
+
|
|
181
|
+
# ── NAMING_TABLE ──────────────────────────────────────────────
|
|
182
|
+
naming_rows = ['| 前缀 | 适用场景 | 示例 |', '|------|---------|------|']
|
|
183
|
+
for p in c['naming_patterns']:
|
|
184
|
+
prefix = pattern_to_prefix(p)
|
|
185
|
+
purpose, example = pattern_purpose(prefix)
|
|
186
|
+
naming_rows.append(f'| `{prefix}` | {purpose} | `{example}` |')
|
|
187
|
+
naming_table = '\n'.join(naming_rows)
|
|
188
|
+
|
|
189
|
+
# ── NAMING_EXEMPT ─────────────────────────────────────────────
|
|
190
|
+
naming_exempt = '、'.join(f'`{e}`' for e in c['naming_exempt']) or '无'
|
|
191
|
+
|
|
192
|
+
# ── COMMIT_FORMAT_SECTION ─────────────────────────────────────
|
|
193
|
+
if c['commit_format'] == 'conventional':
|
|
194
|
+
types_line = ' | '.join(f'`{t}`' for t in c['commit_types'])
|
|
195
|
+
commit_section = f"""遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
<类型>(<范围>): <简短描述>
|
|
199
|
+
|
|
200
|
+
[可选正文]
|
|
201
|
+
|
|
202
|
+
[可选 footer]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**类型:** {types_line}
|
|
206
|
+
|
|
207
|
+
**首行长度限制:** {c['max_subject_length']} 个字符"""
|
|
208
|
+
elif c['commit_format'] == 'regex':
|
|
209
|
+
commit_section = f"""提交信息首行必须匹配正则:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
{c['commit_regex']}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**首行长度限制:** {c['max_subject_length']} 个字符"""
|
|
216
|
+
else:
|
|
217
|
+
commit_section = '本项目无提交信息格式要求。'
|
|
218
|
+
|
|
219
|
+
# ── FAQ_SECTION ───────────────────────────────────────────────
|
|
220
|
+
faq = []
|
|
221
|
+
if integration != main_branch:
|
|
222
|
+
faq.append(f'**Q:在 {integration} 上提交被拒绝怎么办?**\n'
|
|
223
|
+
f'A:你正在直接向 {integration} 提交。请新建分支,在新分支提交后再合并回 {integration}。')
|
|
224
|
+
release_p = [p for p in c['naming_patterns'] if 'release' in p]
|
|
225
|
+
if release_p:
|
|
226
|
+
faq.append(f'**Q:需要紧急热修复直接上 {main_branch} 怎么办?**\n'
|
|
227
|
+
f'A:从 {main_branch} 切一个 `release/*` 分支,修复后合并到 {main_branch},再反向合并到 {integration}。')
|
|
228
|
+
faq_section = '\n\n'.join(faq)
|
|
229
|
+
|
|
230
|
+
# ── 替换占位符 ─────────────────────────────────────────────────
|
|
231
|
+
result = template
|
|
232
|
+
result = result.replace('{{BRANCH_TOPOLOGY_ASCII}}', topology_ascii)
|
|
233
|
+
result = result.replace('{{BRANCH_TABLE}}', branch_table)
|
|
234
|
+
result = result.replace('{{PROTECTION_RULES}}', protection_rules)
|
|
235
|
+
result = result.replace('{{WORKFLOW_CHECKOUT_EXAMPLE}}', checkout_ex)
|
|
236
|
+
result = result.replace('{{WORKFLOW_MERGE_EXAMPLE}}', merge_ex)
|
|
237
|
+
result = result.replace('{{WORKFLOW_RELEASE_EXAMPLE}}', release_ex)
|
|
238
|
+
result = result.replace('{{NAMING_TABLE}}', naming_table)
|
|
239
|
+
result = result.replace('{{NAMING_EXEMPT}}', naming_exempt)
|
|
240
|
+
result = result.replace('{{COMMIT_FORMAT_SECTION}}', commit_section)
|
|
241
|
+
result = result.replace('{{FAQ_SECTION}}', faq_section + '\n\n' if faq_section else '')
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == '__main__':
|
|
246
|
+
config_path = sys.argv[1]
|
|
247
|
+
template_path = sys.argv[2]
|
|
248
|
+
output_path = sys.argv[3]
|
|
249
|
+
|
|
250
|
+
config = load_config(config_path)
|
|
251
|
+
template = open(template_path).read()
|
|
252
|
+
rendered = render(config, template)
|
|
253
|
+
|
|
254
|
+
# 差量写入
|
|
255
|
+
existing = open(output_path).read() if os.path.exists(output_path) else None
|
|
256
|
+
if existing == rendered:
|
|
257
|
+
print('UNCHANGED')
|
|
258
|
+
else:
|
|
259
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
260
|
+
open(output_path, 'w').write(rendered)
|
|
261
|
+
print('UPDATED' if existing else 'NEW')
|