@hupan56/wlkj 2.2.2 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -131,33 +131,46 @@ def validate_req_ids(prd_paths: List[str], repo_root: Union[str, Path]) -> None:
|
|
|
131
131
|
(b) NNN 不能超过当前计数器 (防跳号/未来号; 但允许 NNN == 已分配的号,
|
|
132
132
|
因为可能是修改已有 PRD)
|
|
133
133
|
(c) 同一批无重复 ID
|
|
134
|
+
(d) 与全仓库已存在的 REQ 文件无撞号 (跨人离线分叉检测)
|
|
134
135
|
|
|
135
136
|
注意: 允许 NNN <= 计数器, 因为可能是更新已有 PRD (合法)。
|
|
136
|
-
|
|
137
|
+
撞号在"同一批出现两个相同 ID"、"NNN 超过计数器"、或"与全库已有同号文件冲突"时报错。
|
|
138
|
+
例外: 同号但文件名完全相同 (修改自己已 push 的 PRD) 视为合法更新。
|
|
137
139
|
"""
|
|
138
140
|
seen = {}
|
|
139
141
|
errors = []
|
|
142
|
+
# 全库已有 REQ-ID (用于跨人撞号检测)
|
|
143
|
+
existing = scan_existing_req_ids(repo_root)
|
|
144
|
+
|
|
140
145
|
for p in prd_paths:
|
|
141
146
|
parsed = parse_req_id(p)
|
|
142
147
|
if not parsed:
|
|
143
148
|
errors.append("%s: 文件名无 REQ-{YYYY}-{NNN}" % os.path.basename(p))
|
|
144
149
|
continue
|
|
145
150
|
year, n = parsed
|
|
146
|
-
# 同批重复检测
|
|
147
151
|
key = (year, n)
|
|
152
|
+
basename = os.path.basename(p)
|
|
153
|
+
# 同批重复检测
|
|
148
154
|
if key in seen:
|
|
149
155
|
errors.append(
|
|
150
156
|
"撞号: %s 和 %s 都是 REQ-%d-%03d"
|
|
151
|
-
% (os.path.basename(seen[key]),
|
|
157
|
+
% (os.path.basename(seen[key]), basename, year, n)
|
|
152
158
|
)
|
|
153
159
|
else:
|
|
154
160
|
seen[key] = p
|
|
161
|
+
# 跨人/全库撞号检测: 该号已存在于仓库, 且不是同一个文件 (允许修改自己的)
|
|
162
|
+
if key in existing and os.path.basename(existing[key]) != basename:
|
|
163
|
+
errors.append(
|
|
164
|
+
"撞号: %s 与仓库已有的 %s 都是 REQ-%d-%03d "
|
|
165
|
+
"(两人离线各建了同号 PRD? 请改其中一个的编号)"
|
|
166
|
+
% (basename, os.path.basename(existing[key]), year, n)
|
|
167
|
+
)
|
|
155
168
|
# 超界检测 (NNN 不能超过计数器, 防止跳号)
|
|
156
169
|
max_n = current_counter(str(year), repo_root)
|
|
157
170
|
if max_n > 0 and n > max_n:
|
|
158
171
|
errors.append(
|
|
159
172
|
"%s: REQ-%d-%03d 超过计数器最大值 %d (跳号?)"
|
|
160
|
-
% (
|
|
173
|
+
% (basename, year, n, max_n)
|
|
161
174
|
)
|
|
162
175
|
if errors:
|
|
163
176
|
raise ReqIdError("[gate] 拒绝: REQ-ID 校验失败:\n " + "\n ".join(errors))
|
|
@@ -216,3 +229,41 @@ def migrate_from_existing(repo_root: Optional[Union[str, Path]] = None) -> dict:
|
|
|
216
229
|
return counter
|
|
217
230
|
finally:
|
|
218
231
|
lock.release()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def scan_existing_req_ids(repo_root: Optional[Union[str, Path]] = None) -> dict:
|
|
235
|
+
"""扫描全仓库已存在的 REQ-{YYYY}-{NNN} 文件, 返回 {(year, n): filepath} 字典。
|
|
236
|
+
|
|
237
|
+
用于 push 门禁时的跨人撞号检测: 两个成员离线各建了 REQ-005,
|
|
238
|
+
单看各自批次不重复, 但全库已有该号 → 撞号。
|
|
239
|
+
"""
|
|
240
|
+
if repo_root is None:
|
|
241
|
+
repo_root = Path(get_repo_root())
|
|
242
|
+
else:
|
|
243
|
+
repo_root = Path(repo_root)
|
|
244
|
+
|
|
245
|
+
scan_dirs = [
|
|
246
|
+
repo_root / "data" / "docs" / "prd",
|
|
247
|
+
repo_root / "workspace" / "specs" / "prd",
|
|
248
|
+
]
|
|
249
|
+
members_dir = repo_root / "workspace" / "members"
|
|
250
|
+
if members_dir.is_dir():
|
|
251
|
+
for m in members_dir.iterdir():
|
|
252
|
+
d = m / "drafts"
|
|
253
|
+
if d.is_dir():
|
|
254
|
+
scan_dirs.append(d)
|
|
255
|
+
|
|
256
|
+
existing = {}
|
|
257
|
+
for d in scan_dirs:
|
|
258
|
+
if not d.is_dir():
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
for f in d.iterdir():
|
|
262
|
+
if not f.is_file() or not f.name.lower().endswith(".md"):
|
|
263
|
+
continue
|
|
264
|
+
parsed = parse_req_id(f.name)
|
|
265
|
+
if parsed:
|
|
266
|
+
existing[parsed] = str(f)
|
|
267
|
+
except OSError:
|
|
268
|
+
continue
|
|
269
|
+
return existing
|
|
@@ -147,6 +147,52 @@ def write_task_json(task_dir: Path, data: dict) -> bool:
|
|
|
147
147
|
return False
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
|
|
151
|
+
"""在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
|
|
152
|
+
|
|
153
|
+
解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
|
|
154
|
+
锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
task_dir: 任务目录。
|
|
158
|
+
mutator: 接收 data dict, 就地修改它 (返回值忽略)。
|
|
159
|
+
timeout: 获取锁的等待秒数。
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
成功 (修改并写入) 返回 True; 加载失败返回 False。
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
from .filelock import FileLock, LockTimeoutError
|
|
166
|
+
except ImportError:
|
|
167
|
+
# filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
|
|
168
|
+
data = load_task_json(task_dir)
|
|
169
|
+
if data is None:
|
|
170
|
+
return False
|
|
171
|
+
mutator(data)
|
|
172
|
+
return write_task_json(task_dir, data)
|
|
173
|
+
|
|
174
|
+
lock_path = task_dir / ".task.lock"
|
|
175
|
+
lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
|
|
176
|
+
try:
|
|
177
|
+
lock.acquire()
|
|
178
|
+
except LockTimeoutError as e:
|
|
179
|
+
print(f"Warning: task lock busy ({task_dir.name}): {e}", file=sys.stderr)
|
|
180
|
+
# 降级: 不阻塞, 直接写 (好过完全失败)
|
|
181
|
+
data = load_task_json(task_dir)
|
|
182
|
+
if data is None:
|
|
183
|
+
return False
|
|
184
|
+
mutator(data)
|
|
185
|
+
return write_task_json(task_dir, data)
|
|
186
|
+
try:
|
|
187
|
+
data = load_task_json(task_dir)
|
|
188
|
+
if data is None:
|
|
189
|
+
return False
|
|
190
|
+
mutator(data)
|
|
191
|
+
return write_task_json(task_dir, data)
|
|
192
|
+
finally:
|
|
193
|
+
lock.release()
|
|
194
|
+
|
|
195
|
+
|
|
150
196
|
# =============================================================================
|
|
151
197
|
# ACL: 任务操作权限校验 (零信任)
|
|
152
198
|
# =============================================================================
|
|
@@ -324,7 +324,10 @@ def run_gates(
|
|
|
324
324
|
except GateFailure as e:
|
|
325
325
|
return False, str(e)
|
|
326
326
|
except Exception as e:
|
|
327
|
-
# ReqIdError 等也包装成 GateFailure
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
# ReqIdError / AtomicIOError 等也包装成 GateFailure 风格, 不让原始
|
|
328
|
+
# traceback 冒泡到产品经理眼前
|
|
329
|
+
msg = str(e)
|
|
330
|
+
if 'REQ-ID' in msg or '撞号' in msg or 'REQ' in msg:
|
|
331
|
+
return False, msg
|
|
332
|
+
# 其他意外异常: 返回友好错误而非崩溃栈
|
|
333
|
+
return False, '[gate] 门禁检查遇到意外错误 (非门禁失败, 请重试或检查环境): ' + msg[:200]
|
|
@@ -57,6 +57,7 @@ from common.task_utils import (
|
|
|
57
57
|
run_task_hooks,
|
|
58
58
|
load_task_json,
|
|
59
59
|
write_task_json,
|
|
60
|
+
modify_task_json,
|
|
60
61
|
assert_can_modify_task,
|
|
61
62
|
)
|
|
62
63
|
|
|
@@ -222,18 +223,17 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
222
223
|
print(f"Current task set to: {task_dir}")
|
|
223
224
|
print(f"Source: {active.source}")
|
|
224
225
|
|
|
225
|
-
# 更新 task.json 状态
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
print("Status: planning -> in_progress")
|
|
226
|
+
# 更新 task.json 状态 (锁内 load-modify-write, 防并发丢更新)
|
|
227
|
+
def _start_mutator(data):
|
|
228
|
+
if data.get("status") == "planning":
|
|
229
|
+
now_iso = datetime.now().isoformat()
|
|
230
|
+
data["status"] = "in_progress"
|
|
231
|
+
data["updated_at"] = now_iso
|
|
232
|
+
stage_ts = data.get("stage_ts") or {}
|
|
233
|
+
stage_ts["started"] = now_iso
|
|
234
|
+
data["stage_ts"] = stage_ts
|
|
235
|
+
print("Status: planning -> in_progress")
|
|
236
|
+
modify_task_json(full_path, _start_mutator)
|
|
237
237
|
|
|
238
238
|
# 运行 after_start 钩子
|
|
239
239
|
run_task_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
|
|
@@ -293,33 +293,37 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
293
293
|
# 已清了 active 指针, 但不改 status —— 让用户重新 start 再由正确的人 finish
|
|
294
294
|
return 4 # authz_denied
|
|
295
295
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
296
|
+
# 锁内 load-modify-write, 防并发丢更新; 副作用 (飞书) 在锁外做
|
|
297
|
+
_finish_ctx = {}
|
|
298
|
+
def _finish_mutator(data):
|
|
299
|
+
if data.get("status") != "completed":
|
|
300
|
+
now_iso = datetime.now().isoformat()
|
|
301
|
+
data["status"] = "completed"
|
|
302
|
+
data["updated_at"] = now_iso
|
|
303
|
+
stage_ts = data.get("stage_ts") or {}
|
|
304
|
+
stage_ts["completed"] = now_iso
|
|
305
|
+
data["stage_ts"] = stage_ts
|
|
306
|
+
print("Status: -> completed")
|
|
307
|
+
_finish_ctx["now_iso"] = now_iso
|
|
308
|
+
_finish_ctx["stage_ts"] = stage_ts
|
|
309
|
+
_finish_ctx["assignee"] = data.get("assignee", "?")
|
|
310
|
+
modify_task_json(task_dir, _finish_mutator)
|
|
311
|
+
|
|
312
|
+
# D2: 飞书通知 (任务完成) —— 在锁外, 用 mutator 捕获的值
|
|
313
|
+
if _finish_ctx:
|
|
309
314
|
try:
|
|
310
315
|
from common.feishu import notify_task_finished
|
|
311
|
-
# 算用时
|
|
312
316
|
hours = None
|
|
313
|
-
started = stage_ts.get("started")
|
|
317
|
+
started = _finish_ctx["stage_ts"].get("started")
|
|
314
318
|
if started:
|
|
315
319
|
try:
|
|
316
320
|
from datetime import datetime as _dt
|
|
317
321
|
t_start = _dt.fromisoformat(started)
|
|
318
|
-
t_end = _dt.fromisoformat(now_iso)
|
|
322
|
+
t_end = _dt.fromisoformat(_finish_ctx["now_iso"])
|
|
319
323
|
hours = (t_end - t_start).total_seconds() / 3600
|
|
320
324
|
except (ValueError, TypeError):
|
|
321
325
|
pass
|
|
322
|
-
notify_task_finished(task_dir.name,
|
|
326
|
+
notify_task_finished(task_dir.name, _finish_ctx["assignee"], hours)
|
|
323
327
|
except Exception:
|
|
324
328
|
pass # 飞书失败不阻塞任务完成
|
|
325
329
|
|
|
@@ -330,6 +334,44 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
330
334
|
return 0
|
|
331
335
|
|
|
332
336
|
|
|
337
|
+
def _cleanup_orphaned_refs(archived_task_name: str, repo_root) -> int:
|
|
338
|
+
"""归档任务后, 清理其它任务中对它的引用 (blocked_by/blocks/children/parent)。
|
|
339
|
+
|
|
340
|
+
不清理 → 僵尸任务永远 blocked, resolve_task_dir 找不到引用对象。
|
|
341
|
+
返回清理的引用数。
|
|
342
|
+
"""
|
|
343
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
344
|
+
if not tasks_dir.is_dir():
|
|
345
|
+
return 0
|
|
346
|
+
cleaned = 0
|
|
347
|
+
for task_subdir in tasks_dir.iterdir():
|
|
348
|
+
if not task_subdir.is_dir() or task_subdir.name == archived_task_name:
|
|
349
|
+
continue
|
|
350
|
+
task_json = task_subdir / FILE_TASK_JSON
|
|
351
|
+
if not task_json.is_file():
|
|
352
|
+
continue
|
|
353
|
+
changed = False
|
|
354
|
+
|
|
355
|
+
def _purge_refs(data):
|
|
356
|
+
nonlocal changed
|
|
357
|
+
for field in ("blocked_by", "blocks", "children"):
|
|
358
|
+
lst = data.get(field)
|
|
359
|
+
if isinstance(lst, list) and archived_task_name in lst:
|
|
360
|
+
data[field] = [x for x in lst if x != archived_task_name]
|
|
361
|
+
changed = True
|
|
362
|
+
# parent 是单值字段
|
|
363
|
+
if data.get("parent") == archived_task_name:
|
|
364
|
+
data["parent"] = None
|
|
365
|
+
changed = True
|
|
366
|
+
if changed:
|
|
367
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
368
|
+
|
|
369
|
+
modify_task_json(task_subdir, _purge_refs)
|
|
370
|
+
if changed:
|
|
371
|
+
cleaned += 1
|
|
372
|
+
return cleaned
|
|
373
|
+
|
|
374
|
+
|
|
333
375
|
# =============================================================================
|
|
334
376
|
# 命令: archive
|
|
335
377
|
# =============================================================================
|
|
@@ -379,12 +421,18 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
|
|
379
421
|
# 运行 after_archive 钩子
|
|
380
422
|
run_task_hooks("after_archive", dest / FILE_TASK_JSON, repo_root)
|
|
381
423
|
|
|
424
|
+
# 清理孤儿依赖: 其它任务可能引用了本任务 (blocked_by/blocks/children/parent),
|
|
425
|
+
# 归档后这些引用变成死链 → 僵尸任务永远 blocked。扫描并移除。
|
|
426
|
+
cleaned = _cleanup_orphaned_refs(full_path.name, repo_root)
|
|
427
|
+
|
|
382
428
|
# 清除可能指向此任务的会话
|
|
383
429
|
active = resolve_active_task(repo_root)
|
|
384
430
|
if active.task_path and task_input in active.task_path:
|
|
385
431
|
clear_active_task(repo_root)
|
|
386
432
|
|
|
387
433
|
print(f"Task archived: {full_path.name} -> archive/{year_month}/{full_path.name}")
|
|
434
|
+
if cleaned:
|
|
435
|
+
print(f" (已清理 {cleaned} 个其它任务中对它的依赖引用)")
|
|
388
436
|
return 0
|
|
389
437
|
|
|
390
438
|
|
|
@@ -634,13 +682,12 @@ def cmd_set_due(args: argparse.Namespace) -> int:
|
|
|
634
682
|
except PermissionError as e:
|
|
635
683
|
print(str(e), file=sys.stderr)
|
|
636
684
|
return 4
|
|
637
|
-
|
|
638
|
-
|
|
685
|
+
def _set_due_mutator(data):
|
|
686
|
+
data["due_date"] = args.due_date
|
|
687
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
688
|
+
if not modify_task_json(full_path, _set_due_mutator):
|
|
639
689
|
print("Error: Missing task.json", file=sys.stderr)
|
|
640
690
|
return 1
|
|
641
|
-
task_data["due_date"] = args.due_date
|
|
642
|
-
task_data["updated_at"] = datetime.now().isoformat()
|
|
643
|
-
write_task_json(full_path, task_data)
|
|
644
691
|
print(f"Set due_date: {args.due_date} ({full_path.name})")
|
|
645
692
|
return 0
|
|
646
693
|
|
|
@@ -672,29 +719,32 @@ def cmd_block(args: argparse.Namespace) -> int:
|
|
|
672
719
|
file=sys.stderr)
|
|
673
720
|
return 1
|
|
674
721
|
|
|
675
|
-
#
|
|
722
|
+
# 检查是否已存在依赖关系 (先读, 幂等)
|
|
676
723
|
task_data = load_task_json(task_dir)
|
|
677
724
|
if not task_data:
|
|
678
725
|
print("Error: Missing task.json", file=sys.stderr)
|
|
679
726
|
return 1
|
|
680
|
-
|
|
681
|
-
if blocker_dir.name in blocked_by:
|
|
727
|
+
if blocker_dir.name in (task_data.get("blocked_by") or []):
|
|
682
728
|
print(f"Already blocked by {blocker_dir.name}")
|
|
683
729
|
return 0
|
|
684
|
-
blocked_by.append(blocker_dir.name)
|
|
685
|
-
task_data["blocked_by"] = blocked_by
|
|
686
|
-
task_data["updated_at"] = datetime.now().isoformat()
|
|
687
|
-
write_task_json(task_dir, task_data)
|
|
688
730
|
|
|
689
|
-
#
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
731
|
+
# 锁内更新 task.blocked_by
|
|
732
|
+
def _block_mutator(data):
|
|
733
|
+
blocked_by = data.get("blocked_by") or []
|
|
734
|
+
if blocker_dir.name not in blocked_by:
|
|
735
|
+
blocked_by.append(blocker_dir.name)
|
|
736
|
+
data["blocked_by"] = blocked_by
|
|
737
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
738
|
+
modify_task_json(task_dir, _block_mutator)
|
|
739
|
+
|
|
740
|
+
# 锁内反向更新 blocker.blocks
|
|
741
|
+
def _block_reverse_mutator(data):
|
|
742
|
+
blocks = data.get("blocks") or []
|
|
693
743
|
if task_dir.name not in blocks:
|
|
694
744
|
blocks.append(task_dir.name)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
745
|
+
data["blocks"] = blocks
|
|
746
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
747
|
+
modify_task_json(blocker_dir, _block_reverse_mutator)
|
|
698
748
|
|
|
699
749
|
print(f"{task_dir.name} now blocked by {blocker_dir.name}")
|
|
700
750
|
return 0
|
|
@@ -713,21 +763,25 @@ def cmd_unblock(args: argparse.Namespace) -> int:
|
|
|
713
763
|
return 4
|
|
714
764
|
|
|
715
765
|
removed = False
|
|
716
|
-
#
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
766
|
+
# 锁内从 task.blocked_by 删
|
|
767
|
+
if task_dir.is_dir():
|
|
768
|
+
def _unblock_mutator(data):
|
|
769
|
+
nonlocal removed
|
|
770
|
+
if blocker_dir.name in (data.get("blocked_by") or []):
|
|
771
|
+
data["blocked_by"] = [x for x in (data.get("blocked_by") or []) if x != blocker_dir.name]
|
|
772
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
773
|
+
removed = True
|
|
774
|
+
modify_task_json(task_dir, _unblock_mutator)
|
|
775
|
+
|
|
776
|
+
# 锁内从 blocker.blocks 删
|
|
777
|
+
if blocker_dir.is_dir():
|
|
778
|
+
def _unblock_reverse_mutator(data):
|
|
779
|
+
nonlocal removed
|
|
780
|
+
if task_dir.name in (data.get("blocks") or []):
|
|
781
|
+
data["blocks"] = [x for x in (data.get("blocks") or []) if x != task_dir.name]
|
|
782
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
783
|
+
removed = True
|
|
784
|
+
modify_task_json(blocker_dir, _unblock_reverse_mutator)
|
|
731
785
|
|
|
732
786
|
if removed:
|
|
733
787
|
print(f"Removed block: {task_dir.name} no longer blocked by {blocker_dir.name}")
|
|
@@ -136,9 +136,20 @@ def _notify_prd_publications(staged_files):
|
|
|
136
136
|
|
|
137
137
|
def report_conflict(stderr):
|
|
138
138
|
"""rebase 冲突: 中止并输出结构化标记, 由会话中的 AI 接手解决"""
|
|
139
|
-
|
|
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')
|
|
140
144
|
print('SYNC_CONFLICT: 自动同步遇到冲突, 已安全回退 (本地产出未丢失)。')
|
|
141
|
-
|
|
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 # 推送')
|
|
142
153
|
print('冲突详情: ' + stderr.strip()[:300])
|
|
143
154
|
return 3
|
|
144
155
|
|
|
@@ -309,7 +320,10 @@ def _do_push_locked(message, dev, skip_eval, skip_secret):
|
|
|
309
320
|
if not passed:
|
|
310
321
|
print(reason)
|
|
311
322
|
# 撤销 staging (用户修完再来)
|
|
312
|
-
git('reset', 'HEAD', '--')
|
|
323
|
+
rr = git('reset', 'HEAD', '--')
|
|
324
|
+
if rr.returncode != 0:
|
|
325
|
+
print('WARNING: 门禁失败后 git reset 失败, staged 文件可能仍暂存!')
|
|
326
|
+
print(' 请手动跑: git reset HEAD --')
|
|
313
327
|
return 1
|
|
314
328
|
|
|
315
329
|
# 4. commit
|
|
@@ -347,7 +361,13 @@ def _do_push_locked(message, dev, skip_eval, skip_secret):
|
|
|
347
361
|
if r.returncode != 0:
|
|
348
362
|
if 'CONFLICT' in (r.stdout + r.stderr) or rebase_in_progress():
|
|
349
363
|
return report_conflict(r.stderr or r.stdout)
|
|
364
|
+
# 非冲突的 pull 失败 (网络/auth/fetch 拒绝): 不往下 push (仓库可能处于
|
|
365
|
+
# 半 rebase 状态), 重试本循环
|
|
350
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
|
|
351
371
|
|
|
352
372
|
r = git('push', 'origin', branch)
|
|
353
373
|
if r.returncode == 0:
|