@hupan56/wlkj 2.2.4 → 2.2.5

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.
@@ -1,439 +1,439 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- QODER Team Sync - 无感 git 同步引擎
5
-
6
- 产品经理完全不需要懂 git。所有 /wl- 命令在产出文件后自动调用本脚本,
7
- 把"我的产出"同步到团队仓库,并拉取别人的最新产出。
8
-
9
- Usage:
10
- python team_sync.py pull # 拉取团队最新 (会话开始/init 时)
11
- python team_sync.py push [-m "msg"] # 提交并推送我的产出 (发布动作后)
12
- python team_sync.py status # 查看同步状态 (落后/领先多少)
13
-
14
- 设计原则 (避免冲突 > 解决冲突):
15
- - 只 stage 协作产出区: workspace/ + data/docs/ (+ data/index/ 周五更新时)
16
- - 个人产出天然隔离: 每人只写 workspace/members/{自己}/
17
- - REQ 编号唯一 -> specs/prd/ 文件名不冲突
18
- - push 前 pull --rebase --autostash, 失败自动重试 3 次
19
- - rebase 冲突时: 中止 rebase 保留本地提交, 输出 SYNC_CONFLICT 标记
20
- (AI 看到标记后负责解决冲突, 人类用户永远不需要碰 git)
21
-
22
- Exit codes: 0 = ok, 1 = error, 3 = conflict needs AI resolution
23
- """
24
-
25
- import os
26
- import sys
27
- import subprocess
28
- from datetime import datetime
29
-
30
- if sys.platform == 'win32':
31
- try:
32
- sys.stdout.reconfigure(encoding='utf-8')
33
- except (AttributeError, IOError):
34
- pass
35
-
36
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
- from common.paths import get_repo_root, get_developer
38
- from common.filelock import FileLock, LockTimeoutError
39
- from syncgate import run_gates, SAFE_EXTENSIONS
40
-
41
- BASE = str(get_repo_root())
42
-
43
- # 协作产出区: 自动同步只碰这些路径, 永远不动 .qoder/ 引擎和源码区
44
- SYNC_SCOPES = ['workspace', 'data/docs', 'data/index']
45
-
46
- PULL_MARKER = os.path.join(BASE, '.qoder', '.runtime', 'last-pull')
47
- PUSH_LOCK = os.path.join(BASE, '.qoder', '.runtime', 'team-sync.lock')
48
-
49
- MAX_PUSH_RETRY = 3
50
-
51
-
52
- def git(*args, check=False):
53
- """git 命令包装。git 未安装时返回 rc=127 的伪结果, 不崩溃。"""
54
- try:
55
- r = subprocess.run(['git'] + list(args), cwd=BASE, capture_output=True,
56
- text=True, encoding='utf-8', errors='replace')
57
- except FileNotFoundError:
58
- # git 未安装 — 返回伪 CompletedProcess, 让上层把"无 git"当"无远端/无仓库"处理
59
- import types
60
- r = types.SimpleNamespace(returncode=127, stdout='', stderr='git not installed',
61
- encoding='utf-8')
62
- if check and r.returncode != 0:
63
- print('git {} failed: {}'.format(' '.join(args), r.stderr.strip()[:300]))
64
- return r
65
-
66
-
67
- def current_branch():
68
- r = git('rev-parse', '--abbrev-ref', 'HEAD')
69
- return r.stdout.strip() if r.returncode == 0 else None
70
-
71
-
72
- def has_remote():
73
- r = git('remote')
74
- return 'origin' in r.stdout.split()
75
-
76
-
77
- def rebase_in_progress():
78
- return os.path.isdir(os.path.join(BASE, '.git', 'rebase-merge')) or \
79
- os.path.isdir(os.path.join(BASE, '.git', 'rebase-apply'))
80
-
81
-
82
- def touch_pull_marker():
83
- os.makedirs(os.path.dirname(PULL_MARKER), exist_ok=True)
84
- with open(PULL_MARKER, 'w', encoding='utf-8') as f:
85
- f.write(datetime.now().isoformat())
86
-
87
-
88
- def _notify_prd_publications(staged_files):
89
- """D2: 检测 staged 里的 PRD 发布, 推飞书通知。
90
-
91
- PRD 路径模式: data/docs/prd/REQ-*.md 或 workspace/specs/prd/REQ-*.md
92
- 只对"新增"的 PRD 推送 (修改已有 PRD 不通知, 避免噪音)。
93
- """
94
- import re as _re
95
- try:
96
- from common.feishu import notify_prd_published, is_enabled
97
- except ImportError:
98
- return
99
- if not is_enabled('prd_published'):
100
- return
101
-
102
- req_pattern = _re.compile(r'REQ-(\d{4})-(\d{3,4})', _re.IGNORECASE)
103
- published = []
104
- for f in staged_files:
105
- norm = f.replace('\\', '/').lower()
106
- if '/prd/' not in norm:
107
- continue
108
- if not f.lower().endswith('.md'):
109
- continue
110
- base = os.path.basename(f)
111
- m = req_pattern.search(base)
112
- if not m:
113
- continue
114
- req_id = 'REQ-{}-{:03d}'.format(m.group(1), int(m.group(2)))
115
- # 提取标题 (从文件第一行)
116
- title = base
117
- try:
118
- full = os.path.join(BASE, f) if not os.path.isabs(f) else f
119
- if os.path.isfile(full):
120
- with open(full, encoding='utf-8', errors='replace') as fh:
121
- for line in fh:
122
- line = line.strip().lstrip('#').strip()
123
- if line:
124
- title = line[:60]
125
- break
126
- except Exception:
127
- pass
128
- published.append((req_id, title))
129
-
130
- for req_id, title in published:
131
- try:
132
- notify_prd_published(req_id, title, eval_pct=None)
133
- except Exception:
134
- pass
135
-
136
-
137
- def report_conflict(stderr):
138
- """rebase 冲突: 中止并输出结构化标记, 由会话中的 AI 接手解决"""
139
- if rebase_in_progress():
140
- r = git('rebase', '--abort')
141
- if r.returncode != 0:
142
- print('WARNING: rebase --abort 失败 (rc={}): {}'.format(r.returncode, (r.stderr or '').strip()[:120]))
143
- print('仓库可能处于半 rebase 状态。AI 请手动跑: git rebase --abort')
144
- print('SYNC_CONFLICT: 自动同步遇到冲突, 已安全回退 (本地产出未丢失)。')
145
- branch = current_branch()
146
- print('AI 请按以下步骤解决冲突:')
147
- print(' 1. git status # 看哪些文件冲突')
148
- print(' 2. git pull --rebase origin {} # 重新拉取'.format(branch))
149
- print(' 3. 编辑冲突文件 (<<<<<<< 标记处), 保留双方改动')
150
- print(' 4. git add <解决后的文件> # 标记已解决')
151
- print(' 5. git rebase --continue # 继续 rebase')
152
- print(' 6. git push # 推送')
153
- print('冲突详情: ' + stderr.strip()[:300])
154
- return 3
155
-
156
-
157
- def do_pull(quiet=False):
158
- """拉取团队最新。安全: autostash 保护未提交改动。"""
159
- if rebase_in_progress():
160
- print('SYNC_CONFLICT: 仓库处于未完成的 rebase 状态, AI 请先处理 (git rebase --abort 或 --continue)。')
161
- return 3
162
- if not has_remote():
163
- if not quiet:
164
- print('No remote configured - local-only mode, skip pull.')
165
- touch_pull_marker()
166
- return 0
167
-
168
- branch = current_branch()
169
- if not branch or branch == 'HEAD':
170
- print('ERROR: detached HEAD or no branch - AI please check.')
171
- return 1
172
-
173
- r = git('pull', '--rebase', '--autostash', 'origin', branch)
174
- if r.returncode != 0:
175
- if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
176
- return report_conflict(r.stderr or r.stdout)
177
- print('Pull failed (network/remote?): ' + (r.stderr or r.stdout).strip()[:200])
178
- print('继续离线工作, 产出不会丢失, 下次同步会自动补推。')
179
- return 1
180
-
181
- touch_pull_marker()
182
- out = (r.stdout or '').strip()
183
- if not quiet:
184
- if 'up to date' in out.lower() or 'up-to-date' in out.lower():
185
- print('Already up to date.')
186
- else:
187
- print('Pulled latest from team.')
188
- return 0
189
-
190
-
191
- def summarize_changes():
192
- """生成人类可读的提交摘要 (基于 staged 文件)"""
193
- r = git('diff', '--cached', '--name-only')
194
- files = [f for f in r.stdout.strip().splitlines() if f.strip()]
195
- if not files:
196
- return None, 0
197
- cats = {'prd': 0, 'prototype': 0, 'task': 0, 'journal': 0, 'index': 0, 'other': 0}
198
- for f in files:
199
- fl = f.lower()
200
- if 'prototype' in fl and fl.endswith('.html'):
201
- cats['prototype'] += 1
202
- elif '/prd/' in fl or fl.split('/')[-1].startswith(('req-', 'prd-')):
203
- cats['prd'] += 1
204
- elif '/tasks/' in fl:
205
- cats['task'] += 1
206
- elif '/journal/' in fl:
207
- cats['journal'] += 1
208
- elif 'data/index/' in fl:
209
- cats['index'] += 1
210
- else:
211
- cats['other'] += 1
212
- parts = []
213
- label = {'prd': 'PRD', 'prototype': '原型', 'task': '任务', 'journal': '日志',
214
- 'index': '索引', 'other': '其他'}
215
- for k, v in cats.items():
216
- if v:
217
- parts.append('{}x{}'.format(label[k], v))
218
- return ' '.join(parts), len(files)
219
-
220
-
221
- def _stage_scopes_safely(skip_secret=False):
222
- """用 allowlist 替代危险的 git add -A。
223
-
224
- 只 stage SYNC_SCOPES 下、扩展名在 SAFE_EXTENSIONS 里的文件。
225
- 其他文件 (如误放的 .env/.zip/.docx) 被跳过并打印警告。
226
-
227
- Returns:
228
- (staged_count, skipped_files)
229
- """
230
- staged = 0
231
- skipped = []
232
- for scope in SYNC_SCOPES:
233
- scope_path = os.path.join(BASE, scope)
234
- if not os.path.isdir(scope_path):
235
- continue
236
- # 用 git ls-files 拿已跟踪的 + git status 拿未跟踪的, 逐个判断扩展名
237
- # 已跟踪文件: 直接 add (它们已经在版本控制, 不可能是新秘密)
238
- r = git('ls-files', '--', scope)
239
- tracked = [f for f in r.stdout.strip().splitlines() if f.strip()]
240
- for f in tracked:
241
- git('add', '--', f)
242
- staged += 1
243
- # 未跟踪的新文件: 按扩展名过滤
244
- r = git('status', '--porcelain', '--', scope)
245
- for line in r.stdout.strip().splitlines():
246
- if not line.strip():
247
- continue
248
- # porcelain 格式: "XY filepath" (XY 是 2 字符状态)
249
- status = line[:2]
250
- fpath = line[3:]
251
- if status[0] == '?' or status[1] == '?': # 未跟踪
252
- ext = os.path.splitext(fpath)[1].lower()
253
- if ext in SAFE_EXTENSIONS:
254
- git('add', '--', fpath)
255
- staged += 1
256
- else:
257
- skipped.append(fpath)
258
- elif 'D' not in status:
259
- # 修改/重命名等 (非删除) —— 已跟踪, 直接 add
260
- # 重命名格式 "R old -> new", 取 new
261
- if '->' in fpath:
262
- fpath = fpath.split('->')[-1].strip().strip('"')
263
- git('add', '--', fpath)
264
- staged += 1
265
- if skipped:
266
- print('[gate] 跳过 {} 个非白名单文件 (可能含二进制/秘密, 手动处理):'.format(len(skipped)))
267
- for s in skipped[:10]:
268
- print(' ' + s)
269
- return staged, skipped
270
-
271
-
272
- def do_push(message=None, skip_eval=False, skip_secret=False):
273
- """提交我的产出并推送。零信任门禁 + 文件锁 + rebase 重试。
274
-
275
- 门禁 (任一失败则拒绝 commit/push):
276
- 1. 身份强制 (已注册 + 有本地密钥)
277
- 2. git 作者与注册身份一致
278
- 3. 秘密扫描 (AWS/GitHub/私钥/密码)
279
- 4. EVA PRD 质量门禁 (>=80%)
280
- """
281
- if rebase_in_progress():
282
- print('SYNC_CONFLICT: 仓库处于未完成的 rebase 状态, AI 请先处理。')
283
- return 3
284
-
285
- dev = get_developer()
286
-
287
- # === 文件锁: 串行化 push, 避免并发 stage/commit 交叉 ===
288
- os.makedirs(os.path.dirname(PUSH_LOCK), exist_ok=True)
289
- try:
290
- lock_ctx = FileLock(PUSH_LOCK, timeout=60, stale_seconds=300)
291
- lock_ctx.acquire()
292
- except LockTimeoutError as e:
293
- print('SYNC_BUSY: 另一个同步正在进行, 等待超时。稍后重试。')
294
- print(' ' + str(e))
295
- return 2 # lock contention
296
-
297
- try:
298
- return _do_push_locked(message, dev, skip_eval, skip_secret)
299
- finally:
300
- lock_ctx.release()
301
-
302
-
303
- def _do_push_locked(message, dev, skip_eval, skip_secret):
304
- """锁内的实际 push 逻辑。"""
305
- # 1. allowlist staging
306
- staged_count, skipped = _stage_scopes_safely(skip_secret)
307
-
308
- # 2. 取 staged 文件清单 (给门禁用)
309
- r = git('diff', '--cached', '--name-only')
310
- staged_files = [f for f in r.stdout.strip().splitlines() if f.strip()]
311
-
312
- summary, count = summarize_changes()
313
-
314
- # 3. 零信任门禁 (只在有东西要提交时才跑, 避免空提交报错)
315
- if staged_files:
316
- passed, reason = run_gates(
317
- staged_files, dev, BASE,
318
- skip_eval=skip_eval, skip_secret=skip_secret,
319
- )
320
- if not passed:
321
- print(reason)
322
- # 撤销 staging (用户修完再来)
323
- rr = git('reset', 'HEAD', '--')
324
- if rr.returncode != 0:
325
- print('WARNING: 门禁失败后 git reset 失败, staged 文件可能仍暂存!')
326
- print(' 请手动跑: git reset HEAD --')
327
- return 1
328
-
329
- # 4. commit
330
- if summary:
331
- msg = message or '[wl-sync] {}: {}'.format(dev or 'unknown', summary)
332
- r = git('commit', '-m', msg)
333
- if r.returncode != 0:
334
- print('Commit failed: ' + (r.stderr or r.stdout).strip()[:200])
335
- return 1
336
- print('Committed {} files: {}'.format(count, msg))
337
- else:
338
- print('Nothing new to commit.')
339
-
340
- if not has_remote():
341
- print('No remote configured - committed locally only.')
342
- return 0
343
-
344
- branch = current_branch()
345
- if not branch or branch == 'HEAD':
346
- print('ERROR: detached HEAD - AI please check.')
347
- return 1
348
-
349
- # 5. 检查是否有待推送的提交
350
- git('fetch', 'origin', branch)
351
- r = git('rev-list', '--count', 'origin/{}..HEAD'.format(branch))
352
- ahead = int(r.stdout.strip() or 0) if r.returncode == 0 else 1
353
- if ahead == 0:
354
- print('Already in sync with team.')
355
- touch_pull_marker()
356
- return 0
357
-
358
- # 6. pull --rebase + push, 失败重试
359
- for attempt in range(1, MAX_PUSH_RETRY + 1):
360
- r = git('pull', '--rebase', '--autostash', 'origin', branch)
361
- if r.returncode != 0:
362
- if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
363
- return report_conflict(r.stderr or r.stdout)
364
- # 非冲突的 pull 失败 (网络/auth/fetch 拒绝): 不往下 push (仓库可能处于
365
- # 半 rebase 状态), 重试本循环
366
- print('Pull failed (attempt {}): {}'.format(attempt, (r.stderr or r.stdout).strip()[:150]))
367
- if rebase_in_progress():
368
- # 兜底: 万一 pull 留下了未完成的 rebase, abort 掉防仓库损坏
369
- git('rebase', '--abort')
370
- continue
371
-
372
- r = git('push', 'origin', branch)
373
- if r.returncode == 0:
374
- touch_pull_marker()
375
- print('Synced to team. (attempt {})'.format(attempt))
376
- # D2: 检测 PRD 发布并推飞书通知
377
- _notify_prd_publications(staged_files)
378
- return 0
379
- if attempt < MAX_PUSH_RETRY:
380
- print('Push rejected (someone pushed first?), retrying...')
381
-
382
- print('Push failed after {} attempts: 产出已本地提交不会丢失, 下次同步自动补推。'.format(MAX_PUSH_RETRY))
383
- return 1
384
-
385
-
386
- def do_status():
387
- branch = current_branch()
388
- print('Branch: {}'.format(branch))
389
- print('Developer: {}'.format(get_developer() or 'NOT SET'))
390
-
391
- if not has_remote():
392
- print('Remote: none (local-only mode)')
393
- return 0
394
-
395
- git('fetch', 'origin', branch)
396
- r = git('rev-list', '--left-right', '--count', 'origin/{}...HEAD'.format(branch))
397
- if r.returncode == 0 and r.stdout.strip():
398
- behind, ahead = r.stdout.split()
399
- print('Ahead (待推送): {}, Behind (待拉取): {}'.format(ahead, behind))
400
-
401
- r = git('status', '--short', '--', *SYNC_SCOPES)
402
- dirty = [l for l in r.stdout.strip().splitlines() if l.strip()]
403
- print('未同步的本地产出: {} 个文件'.format(len(dirty)))
404
- for l in dirty[:10]:
405
- print(' ' + l)
406
-
407
- if os.path.isfile(PULL_MARKER):
408
- with open(PULL_MARKER, encoding='utf-8') as f:
409
- print('Last pull: ' + f.read().strip())
410
- return 0
411
-
412
-
413
- def main():
414
- args = sys.argv[1:]
415
- cmd = args[0] if args else 'status'
416
- message = None
417
- if '-m' in args:
418
- i = args.index('-m')
419
- if i + 1 < len(args):
420
- message = args[i + 1]
421
- skip_eval = '--skip-eval' in args
422
- skip_secret = '--skip-secret' in args
423
-
424
- if not os.path.isdir(os.path.join(BASE, '.git')):
425
- print('Not a git repository - skip sync.')
426
- return 0
427
-
428
- if cmd == 'pull':
429
- return do_pull()
430
- if cmd == 'push':
431
- return do_push(message, skip_eval=skip_eval, skip_secret=skip_secret)
432
- if cmd == 'status':
433
- return do_status()
434
- print('Usage: team_sync.py pull|push|status [-m "message"] [--skip-eval] [--skip-secret]')
435
- return 1
436
-
437
-
438
- if __name__ == '__main__':
439
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ QODER Team Sync - 无感 git 同步引擎
5
+
6
+ 产品经理完全不需要懂 git。所有 /wl- 命令在产出文件后自动调用本脚本,
7
+ 把"我的产出"同步到团队仓库,并拉取别人的最新产出。
8
+
9
+ Usage:
10
+ python team_sync.py pull # 拉取团队最新 (会话开始/init 时)
11
+ python team_sync.py push [-m "msg"] # 提交并推送我的产出 (发布动作后)
12
+ python team_sync.py status # 查看同步状态 (落后/领先多少)
13
+
14
+ 设计原则 (避免冲突 > 解决冲突):
15
+ - 只 stage 协作产出区: workspace/ + data/docs/ (+ data/index/ 周五更新时)
16
+ - 个人产出天然隔离: 每人只写 workspace/members/{自己}/
17
+ - REQ 编号唯一 -> specs/prd/ 文件名不冲突
18
+ - push 前 pull --rebase --autostash, 失败自动重试 3 次
19
+ - rebase 冲突时: 中止 rebase 保留本地提交, 输出 SYNC_CONFLICT 标记
20
+ (AI 看到标记后负责解决冲突, 人类用户永远不需要碰 git)
21
+
22
+ Exit codes: 0 = ok, 1 = error, 3 = conflict needs AI resolution
23
+ """
24
+
25
+ import os
26
+ import sys
27
+ import subprocess
28
+ from datetime import datetime
29
+
30
+ if sys.platform == 'win32':
31
+ try:
32
+ sys.stdout.reconfigure(encoding='utf-8')
33
+ except (AttributeError, IOError):
34
+ pass
35
+
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ from common.paths import get_repo_root, get_developer
38
+ from common.filelock import FileLock, LockTimeoutError
39
+ from syncgate import run_gates, SAFE_EXTENSIONS
40
+
41
+ BASE = str(get_repo_root())
42
+
43
+ # 协作产出区: 自动同步只碰这些路径, 永远不动 .qoder/ 引擎和源码区
44
+ SYNC_SCOPES = ['workspace', 'data/docs', 'data/index']
45
+
46
+ PULL_MARKER = os.path.join(BASE, '.qoder', '.runtime', 'last-pull')
47
+ PUSH_LOCK = os.path.join(BASE, '.qoder', '.runtime', 'team-sync.lock')
48
+
49
+ MAX_PUSH_RETRY = 3
50
+
51
+
52
+ def git(*args, check=False):
53
+ """git 命令包装。git 未安装时返回 rc=127 的伪结果, 不崩溃。"""
54
+ try:
55
+ r = subprocess.run(['git'] + list(args), cwd=BASE, capture_output=True,
56
+ text=True, encoding='utf-8', errors='replace')
57
+ except FileNotFoundError:
58
+ # git 未安装 — 返回伪 CompletedProcess, 让上层把"无 git"当"无远端/无仓库"处理
59
+ import types
60
+ r = types.SimpleNamespace(returncode=127, stdout='', stderr='git not installed',
61
+ encoding='utf-8')
62
+ if check and r.returncode != 0:
63
+ print('git {} failed: {}'.format(' '.join(args), r.stderr.strip()[:300]))
64
+ return r
65
+
66
+
67
+ def current_branch():
68
+ r = git('rev-parse', '--abbrev-ref', 'HEAD')
69
+ return r.stdout.strip() if r.returncode == 0 else None
70
+
71
+
72
+ def has_remote():
73
+ r = git('remote')
74
+ return 'origin' in r.stdout.split()
75
+
76
+
77
+ def rebase_in_progress():
78
+ return os.path.isdir(os.path.join(BASE, '.git', 'rebase-merge')) or \
79
+ os.path.isdir(os.path.join(BASE, '.git', 'rebase-apply'))
80
+
81
+
82
+ def touch_pull_marker():
83
+ os.makedirs(os.path.dirname(PULL_MARKER), exist_ok=True)
84
+ with open(PULL_MARKER, 'w', encoding='utf-8') as f:
85
+ f.write(datetime.now().isoformat())
86
+
87
+
88
+ def _notify_prd_publications(staged_files):
89
+ """D2: 检测 staged 里的 PRD 发布, 推飞书通知。
90
+
91
+ PRD 路径模式: data/docs/prd/REQ-*.md 或 workspace/specs/prd/REQ-*.md
92
+ 只对"新增"的 PRD 推送 (修改已有 PRD 不通知, 避免噪音)。
93
+ """
94
+ import re as _re
95
+ try:
96
+ from common.feishu import notify_prd_published, is_enabled
97
+ except ImportError:
98
+ return
99
+ if not is_enabled('prd_published'):
100
+ return
101
+
102
+ req_pattern = _re.compile(r'REQ-(\d{4})-(\d{3,4})', _re.IGNORECASE)
103
+ published = []
104
+ for f in staged_files:
105
+ norm = f.replace('\\', '/').lower()
106
+ if '/prd/' not in norm:
107
+ continue
108
+ if not f.lower().endswith('.md'):
109
+ continue
110
+ base = os.path.basename(f)
111
+ m = req_pattern.search(base)
112
+ if not m:
113
+ continue
114
+ req_id = 'REQ-{}-{:03d}'.format(m.group(1), int(m.group(2)))
115
+ # 提取标题 (从文件第一行)
116
+ title = base
117
+ try:
118
+ full = os.path.join(BASE, f) if not os.path.isabs(f) else f
119
+ if os.path.isfile(full):
120
+ with open(full, encoding='utf-8', errors='replace') as fh:
121
+ for line in fh:
122
+ line = line.strip().lstrip('#').strip()
123
+ if line:
124
+ title = line[:60]
125
+ break
126
+ except Exception:
127
+ pass
128
+ published.append((req_id, title))
129
+
130
+ for req_id, title in published:
131
+ try:
132
+ notify_prd_published(req_id, title, eval_pct=None)
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ def report_conflict(stderr):
138
+ """rebase 冲突: 中止并输出结构化标记, 由会话中的 AI 接手解决"""
139
+ if rebase_in_progress():
140
+ r = git('rebase', '--abort')
141
+ if r.returncode != 0:
142
+ print('WARNING: rebase --abort 失败 (rc={}): {}'.format(r.returncode, (r.stderr or '').strip()[:120]))
143
+ print('仓库可能处于半 rebase 状态。AI 请手动跑: git rebase --abort')
144
+ print('SYNC_CONFLICT: 自动同步遇到冲突, 已安全回退 (本地产出未丢失)。')
145
+ branch = current_branch()
146
+ print('AI 请按以下步骤解决冲突:')
147
+ print(' 1. git status # 看哪些文件冲突')
148
+ print(' 2. git pull --rebase origin {} # 重新拉取'.format(branch))
149
+ print(' 3. 编辑冲突文件 (<<<<<<< 标记处), 保留双方改动')
150
+ print(' 4. git add <解决后的文件> # 标记已解决')
151
+ print(' 5. git rebase --continue # 继续 rebase')
152
+ print(' 6. git push # 推送')
153
+ print('冲突详情: ' + stderr.strip()[:300])
154
+ return 3
155
+
156
+
157
+ def do_pull(quiet=False):
158
+ """拉取团队最新。安全: autostash 保护未提交改动。"""
159
+ if rebase_in_progress():
160
+ print('SYNC_CONFLICT: 仓库处于未完成的 rebase 状态, AI 请先处理 (git rebase --abort 或 --continue)。')
161
+ return 3
162
+ if not has_remote():
163
+ if not quiet:
164
+ print('No remote configured - local-only mode, skip pull.')
165
+ touch_pull_marker()
166
+ return 0
167
+
168
+ branch = current_branch()
169
+ if not branch or branch == 'HEAD':
170
+ print('ERROR: detached HEAD or no branch - AI please check.')
171
+ return 1
172
+
173
+ r = git('pull', '--rebase', '--autostash', 'origin', branch)
174
+ if r.returncode != 0:
175
+ if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
176
+ return report_conflict(r.stderr or r.stdout)
177
+ print('Pull failed (network/remote?): ' + (r.stderr or r.stdout).strip()[:200])
178
+ print('继续离线工作, 产出不会丢失, 下次同步会自动补推。')
179
+ return 1
180
+
181
+ touch_pull_marker()
182
+ out = (r.stdout or '').strip()
183
+ if not quiet:
184
+ if 'up to date' in out.lower() or 'up-to-date' in out.lower():
185
+ print('Already up to date.')
186
+ else:
187
+ print('Pulled latest from team.')
188
+ return 0
189
+
190
+
191
+ def summarize_changes():
192
+ """生成人类可读的提交摘要 (基于 staged 文件)"""
193
+ r = git('diff', '--cached', '--name-only')
194
+ files = [f for f in r.stdout.strip().splitlines() if f.strip()]
195
+ if not files:
196
+ return None, 0
197
+ cats = {'prd': 0, 'prototype': 0, 'task': 0, 'journal': 0, 'index': 0, 'other': 0}
198
+ for f in files:
199
+ fl = f.lower()
200
+ if 'prototype' in fl and fl.endswith('.html'):
201
+ cats['prototype'] += 1
202
+ elif '/prd/' in fl or fl.split('/')[-1].startswith(('req-', 'prd-')):
203
+ cats['prd'] += 1
204
+ elif '/tasks/' in fl:
205
+ cats['task'] += 1
206
+ elif '/journal/' in fl:
207
+ cats['journal'] += 1
208
+ elif 'data/index/' in fl:
209
+ cats['index'] += 1
210
+ else:
211
+ cats['other'] += 1
212
+ parts = []
213
+ label = {'prd': 'PRD', 'prototype': '原型', 'task': '任务', 'journal': '日志',
214
+ 'index': '索引', 'other': '其他'}
215
+ for k, v in cats.items():
216
+ if v:
217
+ parts.append('{}x{}'.format(label[k], v))
218
+ return ' '.join(parts), len(files)
219
+
220
+
221
+ def _stage_scopes_safely(skip_secret=False):
222
+ """用 allowlist 替代危险的 git add -A。
223
+
224
+ 只 stage SYNC_SCOPES 下、扩展名在 SAFE_EXTENSIONS 里的文件。
225
+ 其他文件 (如误放的 .env/.zip/.docx) 被跳过并打印警告。
226
+
227
+ Returns:
228
+ (staged_count, skipped_files)
229
+ """
230
+ staged = 0
231
+ skipped = []
232
+ for scope in SYNC_SCOPES:
233
+ scope_path = os.path.join(BASE, scope)
234
+ if not os.path.isdir(scope_path):
235
+ continue
236
+ # 用 git ls-files 拿已跟踪的 + git status 拿未跟踪的, 逐个判断扩展名
237
+ # 已跟踪文件: 直接 add (它们已经在版本控制, 不可能是新秘密)
238
+ r = git('ls-files', '--', scope)
239
+ tracked = [f for f in r.stdout.strip().splitlines() if f.strip()]
240
+ for f in tracked:
241
+ git('add', '--', f)
242
+ staged += 1
243
+ # 未跟踪的新文件: 按扩展名过滤
244
+ r = git('status', '--porcelain', '--', scope)
245
+ for line in r.stdout.strip().splitlines():
246
+ if not line.strip():
247
+ continue
248
+ # porcelain 格式: "XY filepath" (XY 是 2 字符状态)
249
+ status = line[:2]
250
+ fpath = line[3:]
251
+ if status[0] == '?' or status[1] == '?': # 未跟踪
252
+ ext = os.path.splitext(fpath)[1].lower()
253
+ if ext in SAFE_EXTENSIONS:
254
+ git('add', '--', fpath)
255
+ staged += 1
256
+ else:
257
+ skipped.append(fpath)
258
+ elif 'D' not in status:
259
+ # 修改/重命名等 (非删除) —— 已跟踪, 直接 add
260
+ # 重命名格式 "R old -> new", 取 new
261
+ if '->' in fpath:
262
+ fpath = fpath.split('->')[-1].strip().strip('"')
263
+ git('add', '--', fpath)
264
+ staged += 1
265
+ if skipped:
266
+ print('[gate] 跳过 {} 个非白名单文件 (可能含二进制/秘密, 手动处理):'.format(len(skipped)))
267
+ for s in skipped[:10]:
268
+ print(' ' + s)
269
+ return staged, skipped
270
+
271
+
272
+ def do_push(message=None, skip_eval=False, skip_secret=False):
273
+ """提交我的产出并推送。零信任门禁 + 文件锁 + rebase 重试。
274
+
275
+ 门禁 (任一失败则拒绝 commit/push):
276
+ 1. 身份强制 (已注册 + 有本地密钥)
277
+ 2. git 作者与注册身份一致
278
+ 3. 秘密扫描 (AWS/GitHub/私钥/密码)
279
+ 4. EVA PRD 质量门禁 (>=80%)
280
+ """
281
+ if rebase_in_progress():
282
+ print('SYNC_CONFLICT: 仓库处于未完成的 rebase 状态, AI 请先处理。')
283
+ return 3
284
+
285
+ dev = get_developer()
286
+
287
+ # === 文件锁: 串行化 push, 避免并发 stage/commit 交叉 ===
288
+ os.makedirs(os.path.dirname(PUSH_LOCK), exist_ok=True)
289
+ try:
290
+ lock_ctx = FileLock(PUSH_LOCK, timeout=60, stale_seconds=300)
291
+ lock_ctx.acquire()
292
+ except LockTimeoutError as e:
293
+ print('SYNC_BUSY: 另一个同步正在进行, 等待超时。稍后重试。')
294
+ print(' ' + str(e))
295
+ return 2 # lock contention
296
+
297
+ try:
298
+ return _do_push_locked(message, dev, skip_eval, skip_secret)
299
+ finally:
300
+ lock_ctx.release()
301
+
302
+
303
+ def _do_push_locked(message, dev, skip_eval, skip_secret):
304
+ """锁内的实际 push 逻辑。"""
305
+ # 1. allowlist staging
306
+ staged_count, skipped = _stage_scopes_safely(skip_secret)
307
+
308
+ # 2. 取 staged 文件清单 (给门禁用)
309
+ r = git('diff', '--cached', '--name-only')
310
+ staged_files = [f for f in r.stdout.strip().splitlines() if f.strip()]
311
+
312
+ summary, count = summarize_changes()
313
+
314
+ # 3. 零信任门禁 (只在有东西要提交时才跑, 避免空提交报错)
315
+ if staged_files:
316
+ passed, reason = run_gates(
317
+ staged_files, dev, BASE,
318
+ skip_eval=skip_eval, skip_secret=skip_secret,
319
+ )
320
+ if not passed:
321
+ print(reason)
322
+ # 撤销 staging (用户修完再来)
323
+ rr = git('reset', 'HEAD', '--')
324
+ if rr.returncode != 0:
325
+ print('WARNING: 门禁失败后 git reset 失败, staged 文件可能仍暂存!')
326
+ print(' 请手动跑: git reset HEAD --')
327
+ return 1
328
+
329
+ # 4. commit
330
+ if summary:
331
+ msg = message or '[wl-sync] {}: {}'.format(dev or 'unknown', summary)
332
+ r = git('commit', '-m', msg)
333
+ if r.returncode != 0:
334
+ print('Commit failed: ' + (r.stderr or r.stdout).strip()[:200])
335
+ return 1
336
+ print('Committed {} files: {}'.format(count, msg))
337
+ else:
338
+ print('Nothing new to commit.')
339
+
340
+ if not has_remote():
341
+ print('No remote configured - committed locally only.')
342
+ return 0
343
+
344
+ branch = current_branch()
345
+ if not branch or branch == 'HEAD':
346
+ print('ERROR: detached HEAD - AI please check.')
347
+ return 1
348
+
349
+ # 5. 检查是否有待推送的提交
350
+ git('fetch', 'origin', branch)
351
+ r = git('rev-list', '--count', 'origin/{}..HEAD'.format(branch))
352
+ ahead = int(r.stdout.strip() or 0) if r.returncode == 0 else 1
353
+ if ahead == 0:
354
+ print('Already in sync with team.')
355
+ touch_pull_marker()
356
+ return 0
357
+
358
+ # 6. pull --rebase + push, 失败重试
359
+ for attempt in range(1, MAX_PUSH_RETRY + 1):
360
+ r = git('pull', '--rebase', '--autostash', 'origin', branch)
361
+ if r.returncode != 0:
362
+ if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
363
+ return report_conflict(r.stderr or r.stdout)
364
+ # 非冲突的 pull 失败 (网络/auth/fetch 拒绝): 不往下 push (仓库可能处于
365
+ # 半 rebase 状态), 重试本循环
366
+ print('Pull failed (attempt {}): {}'.format(attempt, (r.stderr or r.stdout).strip()[:150]))
367
+ if rebase_in_progress():
368
+ # 兜底: 万一 pull 留下了未完成的 rebase, abort 掉防仓库损坏
369
+ git('rebase', '--abort')
370
+ continue
371
+
372
+ r = git('push', 'origin', branch)
373
+ if r.returncode == 0:
374
+ touch_pull_marker()
375
+ print('Synced to team. (attempt {})'.format(attempt))
376
+ # D2: 检测 PRD 发布并推飞书通知
377
+ _notify_prd_publications(staged_files)
378
+ return 0
379
+ if attempt < MAX_PUSH_RETRY:
380
+ print('Push rejected (someone pushed first?), retrying...')
381
+
382
+ print('Push failed after {} attempts: 产出已本地提交不会丢失, 下次同步自动补推。'.format(MAX_PUSH_RETRY))
383
+ return 1
384
+
385
+
386
+ def do_status():
387
+ branch = current_branch()
388
+ print('Branch: {}'.format(branch))
389
+ print('Developer: {}'.format(get_developer() or 'NOT SET'))
390
+
391
+ if not has_remote():
392
+ print('Remote: none (local-only mode)')
393
+ return 0
394
+
395
+ git('fetch', 'origin', branch)
396
+ r = git('rev-list', '--left-right', '--count', 'origin/{}...HEAD'.format(branch))
397
+ if r.returncode == 0 and r.stdout.strip():
398
+ behind, ahead = r.stdout.split()
399
+ print('Ahead (待推送): {}, Behind (待拉取): {}'.format(ahead, behind))
400
+
401
+ r = git('status', '--short', '--', *SYNC_SCOPES)
402
+ dirty = [l for l in r.stdout.strip().splitlines() if l.strip()]
403
+ print('未同步的本地产出: {} 个文件'.format(len(dirty)))
404
+ for l in dirty[:10]:
405
+ print(' ' + l)
406
+
407
+ if os.path.isfile(PULL_MARKER):
408
+ with open(PULL_MARKER, encoding='utf-8') as f:
409
+ print('Last pull: ' + f.read().strip())
410
+ return 0
411
+
412
+
413
+ def main():
414
+ args = sys.argv[1:]
415
+ cmd = args[0] if args else 'status'
416
+ message = None
417
+ if '-m' in args:
418
+ i = args.index('-m')
419
+ if i + 1 < len(args):
420
+ message = args[i + 1]
421
+ skip_eval = '--skip-eval' in args
422
+ skip_secret = '--skip-secret' in args
423
+
424
+ if not os.path.isdir(os.path.join(BASE, '.git')):
425
+ print('Not a git repository - skip sync.')
426
+ return 0
427
+
428
+ if cmd == 'pull':
429
+ return do_pull()
430
+ if cmd == 'push':
431
+ return do_push(message, skip_eval=skip_eval, skip_secret=skip_secret)
432
+ if cmd == 'status':
433
+ return do_status()
434
+ print('Usage: team_sync.py pull|push|status [-m "message"] [--skip-eval] [--skip-secret]')
435
+ return 1
436
+
437
+
438
+ if __name__ == '__main__':
439
+ sys.exit(main())