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.
@@ -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')