task-ledger 1.2.9 → 1.3.1

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.
Files changed (55) hide show
  1. package/README.md +29 -245
  2. package/SKILL.md +173 -88
  3. package/package.json +22 -22
  4. package/references/log-note.md +3 -1
  5. package/references/state-model.md +29 -27
  6. package/references/troubleshooting.md +98 -25
  7. package/scripts/ledgerkit/__init__.py +0 -0
  8. package/scripts/ledgerkit/actions/__init__.py +0 -0
  9. package/scripts/ledgerkit/actions/atom.py +154 -0
  10. package/scripts/ledgerkit/actions/bootstrap.py +27 -0
  11. package/scripts/ledgerkit/actions/claim.py +82 -0
  12. package/scripts/ledgerkit/actions/close_task.py +180 -0
  13. package/scripts/ledgerkit/actions/guard.py +140 -0
  14. package/scripts/ledgerkit/actions/handoff.py +74 -0
  15. package/scripts/ledgerkit/actions/list_tasks.py +53 -0
  16. package/scripts/ledgerkit/actions/log_progress.py +60 -0
  17. package/scripts/ledgerkit/actions/new_task.py +120 -0
  18. package/scripts/ledgerkit/actions/query.py +33 -0
  19. package/scripts/ledgerkit/actions/query_history.py +173 -0
  20. package/scripts/ledgerkit/actions/registry.py +82 -0
  21. package/scripts/ledgerkit/actions/release.py +51 -0
  22. package/scripts/ledgerkit/actions/result.py +88 -0
  23. package/scripts/ledgerkit/actions/set_task.py +43 -0
  24. package/scripts/ledgerkit/actions/show_task.py +27 -0
  25. package/scripts/ledgerkit/actions/validate.py +224 -0
  26. package/scripts/ledgerkit/core/__init__.py +0 -0
  27. package/scripts/ledgerkit/core/history_md.py +350 -0
  28. package/scripts/ledgerkit/core/identity.py +43 -0
  29. package/scripts/ledgerkit/core/ids.py +58 -0
  30. package/scripts/ledgerkit/core/labels.py +79 -0
  31. package/scripts/ledgerkit/core/paths.py +93 -0
  32. package/scripts/ledgerkit/core/time.py +102 -0
  33. package/scripts/ledgerkit/core/worklog_md.py +232 -0
  34. package/scripts/ledgerkit/env.py +93 -0
  35. package/scripts/ledgerkit/errors.py +15 -0
  36. package/scripts/ledgerkit/notices.py +28 -0
  37. package/scripts/ledgerkit/shell/__init__.py +0 -0
  38. package/scripts/ledgerkit/shell/cli.py +259 -0
  39. package/scripts/ledgerkit/shell/gitops.py +655 -0
  40. package/scripts/ledgerkit/shell/merge.py +266 -0
  41. package/scripts/ledgerkit/shell/presync.py +23 -0
  42. package/scripts/ledgerkit/shell/render.py +165 -0
  43. package/scripts/ledgerkit/shell/runner.py +225 -0
  44. package/scripts/ledgerkit/store/__init__.py +0 -0
  45. package/scripts/ledgerkit/store/atomic.py +130 -0
  46. package/scripts/ledgerkit/store/dirty.py +108 -0
  47. package/scripts/ledgerkit/store/history_store.py +189 -0
  48. package/scripts/ledgerkit/store/json_store.py +166 -0
  49. package/scripts/ledgerkit/store/locks.py +64 -0
  50. package/scripts/ledgerkit/store/owners_index.py +135 -0
  51. package/scripts/ledgerkit/store/task_store.py +31 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3021
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,655 @@
1
+ """全仓唯一跑 git 的地方。业务层、数据层、core 都不许碰 git(读身份归 `env.py`)。
2
+
3
+ 四个动作之外没有别的入口:写前硬拦、取最新、提交、推送(被拒时 rebase 重试)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from collections.abc import Iterator
13
+ from pathlib import Path
14
+
15
+ from .. import notices
16
+ from ..actions.atom import Scope
17
+ from ..core.paths import to_repo_rel
18
+ from ..errors import Fail
19
+
20
+ try: # pragma: no cover
21
+ import fcntl
22
+ except ImportError: # pragma: no cover
23
+ fcntl = None # type: ignore[assignment]
24
+ try: # pragma: no cover
25
+ import msvcrt
26
+ except ImportError: # pragma: no cover
27
+ msvcrt = None # type: ignore[assignment]
28
+
29
+ GIT_TIMEOUT = 30
30
+
31
+
32
+ def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedProcess:
33
+ """跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。"""
34
+ try:
35
+ return subprocess.run(
36
+ list(args), capture_output=True, text=True, timeout=timeout, check=False
37
+ )
38
+ except FileNotFoundError:
39
+ return subprocess.CompletedProcess(
40
+ list(args), 127, stdout="", stderr="git: command not found"
41
+ )
42
+
43
+
44
+ @contextlib.contextmanager
45
+ def repo_sync_lock(git_dir: Path, timeout: float = 60.0) -> Iterator[None]:
46
+ """整条命令的进程级互斥:写前同步 → 命令写入 → 提交推送,同一 clone 只允许一个。
47
+
48
+ `progress_lock` 只管单文件的读改写,盖不住「检查本地干净 → 覆盖文件」这两步之间的缝。
49
+ 锁文件放 git 目录里(不进仓库、不被 git 状态看见)。
50
+ """
51
+ if fcntl is None and msvcrt is None:
52
+ yield
53
+ return
54
+ lock_path = Path(git_dir) / "dsh-task-ledger.sync.lock"
55
+ try:
56
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
57
+ except OSError as exc:
58
+ raise Fail(
59
+ f"拿不到同步锁 {lock_path}({exc}),本次未做任何写入。\n"
60
+ " 处理:确认 git 目录可写;只需写本地文件可加 --no-sync,之后手动提交。"
61
+ ) from exc
62
+ acquired = False
63
+ try:
64
+ deadline = time.monotonic() + timeout
65
+ while True:
66
+ try:
67
+ if fcntl is not None:
68
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
69
+ elif msvcrt is not None:
70
+ os.lseek(fd, 0, os.SEEK_SET)
71
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # type: ignore[attr-defined]
72
+ acquired = True
73
+ break
74
+ except OSError:
75
+ if time.monotonic() >= deadline:
76
+ raise Fail(
77
+ f"另一个会话正在同步同一份台账(已等 {int(timeout)} 秒),请稍后重试。"
78
+ ) from None
79
+ time.sleep(0.1)
80
+ yield
81
+ finally:
82
+ if acquired:
83
+ try:
84
+ if fcntl is not None:
85
+ fcntl.flock(fd, fcntl.LOCK_UN)
86
+ elif msvcrt is not None:
87
+ os.lseek(fd, 0, os.SEEK_SET)
88
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
89
+ except OSError:
90
+ pass
91
+ os.close(fd)
92
+
93
+
94
+ class GitRepo:
95
+ """一个仓库的 git 操作面。构造不跑 git,也不收 `no_sync`(调不调由 runner 定)。"""
96
+
97
+ def __init__(self, repo: Path):
98
+ self.repo = Path(repo)
99
+ self._upstream: str | None = None
100
+ self._is_repo: bool | None = None
101
+
102
+ # ---- 查 ----
103
+
104
+ def is_git_repo(self) -> bool:
105
+ """所在目录是不是一个 git 仓库(工作树内)。
106
+
107
+ 不是 ⇒ 同步整体跳过、台账照写(2026-10-24 用户拍板:没有 git 也必须能用)。
108
+ """
109
+ if self._is_repo is None:
110
+ proc = _git(
111
+ "git", "-C", str(self.repo), "rev-parse", "--is-inside-work-tree"
112
+ )
113
+ self._is_repo = proc.returncode == 0 and proc.stdout.strip() == "true"
114
+ return bool(self._is_repo)
115
+
116
+ def upstream(self) -> str | None:
117
+ """上游分支(`@{u}`);没有就 None(此时同步整体静默跳过)。"""
118
+ if self._upstream is None:
119
+ proc = _git(
120
+ "git",
121
+ "-C",
122
+ str(self.repo),
123
+ "rev-parse",
124
+ "--abbrev-ref",
125
+ "--symbolic-full-name",
126
+ "@{u}",
127
+ )
128
+ self._upstream = (
129
+ proc.stdout.strip()
130
+ if proc.returncode == 0 and proc.stdout.strip()
131
+ else ""
132
+ )
133
+ return self._upstream or None
134
+
135
+ def _state(self) -> str | None:
136
+ """rebase / merge 中间态(返回说明文字),正常返回 None。"""
137
+ root = _git("git", "-C", str(self.repo), "rev-parse", "--show-toplevel")
138
+ if root.returncode != 0:
139
+ return None
140
+ git_dir = root.stdout.strip() + "/.git"
141
+ for name, hint in (
142
+ ("rebase-merge", "rebase 交互待办"),
143
+ ("rebase-apply", "rebase 正在应用"),
144
+ ("MERGE_HEAD", "merge 未结束"),
145
+ ):
146
+ if os.path.exists(os.path.join(git_dir, name)):
147
+ return hint
148
+ return None
149
+
150
+ def _dirty(self, rels: list[str]) -> list[str]:
151
+ proc = _git("git", "-C", str(self.repo), "status", "--porcelain", "--", *rels)
152
+ if proc.returncode != 0:
153
+ return []
154
+ return [line for line in proc.stdout.splitlines() if line.strip()]
155
+
156
+ def _unpushed(self, rels: list[str]) -> list[str]:
157
+ upstream = self.upstream()
158
+ if not upstream:
159
+ return []
160
+ proc = _git(
161
+ "git",
162
+ "-C",
163
+ str(self.repo),
164
+ "log",
165
+ f"{upstream}..HEAD",
166
+ "--oneline",
167
+ "--",
168
+ *rels,
169
+ )
170
+ if proc.returncode != 0:
171
+ return []
172
+ return [line for line in proc.stdout.splitlines() if line.strip()]
173
+
174
+ def _untracked(self, rels: list[str]) -> list[str]:
175
+ proc = _git(
176
+ "git",
177
+ "-C",
178
+ str(self.repo),
179
+ "ls-files",
180
+ "--others",
181
+ "--exclude-standard",
182
+ "--",
183
+ *rels,
184
+ )
185
+ if proc.returncode != 0:
186
+ return []
187
+ return [line for line in proc.stdout.splitlines() if line.strip()]
188
+
189
+ # ---- 写前硬拦 ----
190
+
191
+ def check_repo_state(self) -> None:
192
+ """rebase / merge 中间态 ⇒ Fail。这是**全局前提**,必须早于任何读台账的动作。
193
+
194
+ 冲突标记下的台账不是合法 JSON,`presync_scope` / `_params` 一读就抛,
195
+ 命令会以「未预期错误」(退出码 3)崩掉,而不是给人话。
196
+ """
197
+ state = self._state()
198
+ if state:
199
+ raise Fail(
200
+ f"仓库处于中间态({state}):先跑 git status 看提示,"
201
+ "完成或中止它(git rebase --abort / git merge --abort)再跑本命令。"
202
+ )
203
+
204
+ def guard_before_write(self, scope: Scope) -> None:
205
+ """本地有未提交的台账改动 / 有未推送的台账提交 / 有未入库的同名文件 /
206
+ 处于 rebase 中间态 ⇒ Fail 并说明怎么处理。在 `sync_down` 之前调。
207
+ """
208
+ rels = _rels(self.repo, scope)
209
+ if not rels:
210
+ return
211
+ if not self.upstream():
212
+ # 没有上游:不存在"远端内容会盖掉本地"这回事,未提交 / 未入库都不拦
213
+ return
214
+ self.check_repo_state()
215
+ # 手写的过程文档不算「本地脏」:它走 sync_down 的 stash → 快进 → 叠回,不硬拦
216
+ dirty = [
217
+ line for line in self._dirty(rels) if not _is_worklog(_porcelain_path(line))
218
+ ]
219
+ if dirty:
220
+ raise Fail(
221
+ "本地有未提交的台账改动:\n "
222
+ + "\n ".join(dirty[:10])
223
+ + "\n 处理:先提交它们(git add <文件> && git commit),再跑本命令。"
224
+ )
225
+ unpushed = self._unpushed(rels)
226
+ if unpushed:
227
+ raise Fail(
228
+ "本地有未推送的台账提交:\n "
229
+ + "\n ".join(unpushed[:10])
230
+ + "\n 处理:先 git push;或者加 --no-sync 只写本地(之后要记得推)。"
231
+ )
232
+ untracked = self._untracked(rels)
233
+ if untracked:
234
+ raise Fail(
235
+ "有还没入库的同名台账文件(提交会被它们挡住):\n "
236
+ + "\n ".join(untracked[:10])
237
+ + "\n 处理:先 git add 它们再跑本命令。"
238
+ )
239
+
240
+ # ---- 取最新 ----
241
+
242
+ def sync_down(self, scope: Scope) -> list[tuple[Path, bytes | None]]:
243
+ """取最新:先整体快进,再按这个动作要碰的文件逐个刷(连"刷之前"的字节一起返回)。
244
+
245
+ 快进是必须的:只刷工作树的话,「远端新增的内容」会被本次提交原样重放一遍,
246
+ 推送必被拒、rebase 必冲突(2026-10-21 实测)。快进不了(工作树有未提交改动)
247
+ 才退回逐个刷。
248
+ """
249
+ upstream = self.upstream()
250
+ if not upstream:
251
+ return []
252
+ fetch = _git("git", "-C", str(self.repo), "fetch", "--quiet")
253
+ if fetch.returncode != 0:
254
+ # 远端连不上(离线 / 没网):不中止 —— 台账照样写,只是这次拿不到远端最新。
255
+ # 提示必须给,否则用户以为同步过了(2026-10-21 红队结论)。
256
+ notices.note(
257
+ "提示:写前拉取失败(远端连不上),本次按本地最新内容写;"
258
+ "推送也可能失败,留意稍后的提示。"
259
+ )
260
+ return []
261
+ ahead, behind = (
262
+ self._rev_count(f"{upstream}..HEAD"),
263
+ self._rev_count(f"HEAD..{upstream}"),
264
+ )
265
+ if ahead == 0 and behind > 0:
266
+ # 写前 guard 已经保证本地没有未推送的台账提交 ⇒ 落后就只是"慢了一步",快进安全
267
+ ff = _git("git", "-C", str(self.repo), "merge", "--ff-only", "-q", upstream)
268
+ if ff.returncode == 0:
269
+ notices.note(
270
+ f"提示:写前把本地快进到 {upstream}({behind} 个提交,仅 fast-forward)。"
271
+ )
272
+ else:
273
+ # 工作树有未提交改动(手写的 worklog / 用户自己的代码):ff 必然失败
274
+ notices.note(
275
+ f"提示:写前快进到 {upstream} 没成功(工作树有未提交改动),"
276
+ "改为只刷新本次要写的台账文件。"
277
+ )
278
+ out: list[tuple[Path, bytes | None]] = []
279
+ stashed: dict[str, tuple[Path, bytes | None]] = {}
280
+ for path in _paths(self.repo, scope):
281
+ rel = _rel(self.repo, path)
282
+ exists = _git(
283
+ "git", "-C", str(self.repo), "cat-file", "-e", f"{upstream}:{rel}"
284
+ )
285
+ if exists.returncode != 0:
286
+ continue # 上游还没有这个文件:本地留着,不覆盖也不报错
287
+ # 「刷之前」的字节先记:手写的那版也算(叠回失败 / 命令失败时要能原样放回)
288
+ before = path.read_bytes() if path.exists() else None
289
+ entry = (path, before)
290
+ # 手写的过程文档:stash → 取远端最新 → 叠回(合不上就留冲突标记,绝不静默丢弃)
291
+ if _is_worklog(rel) and self._is_dirty(rel):
292
+ _git("git", "-C", str(self.repo), "stash", "push", "--quiet", "--", rel)
293
+ stashed[rel] = entry
294
+ proc = _git("git", "-C", str(self.repo), "checkout", upstream, "--", rel)
295
+ if proc.returncode != 0:
296
+ continue
297
+ if rel in stashed:
298
+ # 索引必须与工作树一致(都是上游版本),否则紧接着的 stash pop
299
+ # 会以「本地改动会被覆盖」直接失败,冲突标记根本留不下来
300
+ continue
301
+ # checkout 会把上游版本一并写进索引;失败回滚只按字节还原工作树,
302
+ # 索引里那份脏会让下一条写命令被写前 guard 拦死 ⇒ 取完立刻把索引还原到 HEAD
303
+ # (只动这一个路径,用户自己暂存的别的东西不受影响)。
304
+ _git("git", "-C", str(self.repo), "reset", "--quiet", "--", rel)
305
+ if _changed(path, before):
306
+ out.append(entry)
307
+ for rel, entry in stashed.items():
308
+ pop = _git("git", "-C", str(self.repo), "stash", "pop", "--quiet")
309
+ if pop.returncode != 0:
310
+ # 冲突标记留在工作树里让人合;手写那段同时在 stash 里,一个都没丢
311
+ raise Fail(
312
+ f"{rel} 的手写改动与 {upstream} 的最新版本冲突,冲突标记已留在工作树里。\n"
313
+ " 处理:逐个编辑解决冲突 → git add <文件> → git stash drop,然后重跑这条命令。\n"
314
+ " 顺序不能反:先 drop 再解决,手写的那段就没了。"
315
+ )
316
+ # 叠回去了:索引里那份上游版本也该收干净(工作树已是「上游 + 手写」)
317
+ _git("git", "-C", str(self.repo), "reset", "--quiet", "--", rel)
318
+ if _changed(entry[0], entry[1]):
319
+ out.append(entry)
320
+ return out
321
+
322
+ def _rev_count(self, spec: str) -> int:
323
+ """数提交个数;数不出来返回 -1(按"可能有"处理,宁可不动)。"""
324
+ proc = _git("git", "-C", str(self.repo), "rev-list", "--count", spec)
325
+ try:
326
+ return int(proc.stdout.strip())
327
+ except ValueError:
328
+ return -1
329
+
330
+ def _is_dirty(self, rel: str) -> bool:
331
+ proc = _git("git", "-C", str(self.repo), "status", "--porcelain", "--", rel)
332
+ return bool(proc.stdout.strip())
333
+
334
+ # ---- 提交 / 推送 ----
335
+
336
+ def commit(self, files: list[Path], message: str) -> None:
337
+ rels = [_rel(self.repo, p) for p in files]
338
+ if not rels:
339
+ return
340
+ add = _git("git", "-C", str(self.repo), "add", "--", *rels)
341
+ if add.returncode != 0:
342
+ raise Fail(
343
+ f"git add 失败:{(add.stderr or '').strip()}\n"
344
+ " 处理:文件还在磁盘上(本次改动没丢),按提示处理后重跑。"
345
+ )
346
+ # 一个字节都没变(重跑幂等:写下去的与 HEAD 相同)⇒ 没有可提交的内容,这不是错误。
347
+ # 不跳的话 git commit 会以 "nothing added to commit" 失败,把一条本该成功的重跑判红。
348
+ staged = _git(
349
+ "git", "-C", str(self.repo), "diff", "--cached", "--quiet", "--", *rels
350
+ )
351
+ if staged.returncode == 0:
352
+ return
353
+ # 只提交台账那几个文件:不带 pathspec 会把用户自己暂存的别的东西一起卷进来
354
+ commit = _git(
355
+ "git",
356
+ "-C",
357
+ str(self.repo),
358
+ "commit",
359
+ "--no-verify",
360
+ "-m",
361
+ message,
362
+ "--",
363
+ *rels,
364
+ )
365
+ if commit.returncode != 0:
366
+ raise Fail(
367
+ f"git commit 失败:{(commit.stderr or commit.stdout or '').strip()}\n"
368
+ " 处理:文件已暂存、本次改动没丢;按提示处理(常见:commit 检查钩子拒了消息)后重跑。"
369
+ )
370
+
371
+ def push(self, max_attempts: int = 8, deadline: float | None = None) -> None:
372
+ """推送;被拒(远端有新提交)就 rebase 后重试,循环到成功或遇到真冲突。
373
+
374
+ 跨 clone 并发推同一 origin 时,单次「rebase + 再 push」会被别的 clone 在空档里
375
+ 又推一次击穿——留下未推送提交、后续写命令被 guard 拦掉、雪崩式拖垮同 clone 写入。
376
+ 所以改成有上限的 rebase+push 循环:每轮都 fetch 最新 origin 再 rebase,把别人的新
377
+ 推送吸收掉,直到真正推上去;只有遇到「无上游 / 本地混了非台账提交 / 真冲突(已留标记)
378
+ / 次数耗尽或超时」才停,绝不静默丢数据。
379
+
380
+ **任何失败都只提示、不抛**:提交已经落下去了,抛 Fail 会让 runner 走失败收尾,
381
+ 把已提交的内容从工作树里抹掉,那才是真丢数据。git 的原话必须带出去。
382
+ """
383
+ if deadline is None:
384
+ deadline = time.monotonic() + 45.0
385
+ last_reason = ""
386
+ for _ in range(max_attempts):
387
+ if time.monotonic() >= deadline:
388
+ break
389
+ attempt = _git("git", "-C", str(self.repo), "push")
390
+ last_reason = (attempt.stderr or attempt.stdout or "").strip()
391
+ if attempt.returncode == 0:
392
+ return
393
+ if not self.upstream():
394
+ notices.note(
395
+ f"提示:没有上游分支,本地提交已保留、未推送:{last_reason}\n"
396
+ " 处理:给分支设上游(git push -u origin <分支>)后重跑即可推走。"
397
+ )
398
+ return
399
+ if self._has_foreign_commits():
400
+ titles = self._foreign_titles()
401
+ detail = f":{';'.join(titles[:5])}" if titles else ""
402
+ notices.note(
403
+ f"提示:推送被拒(远端有新提交):{last_reason}\n"
404
+ f"且本地还有你自己的提交{detail} ⇒ 不自动 rebase(那会改写提交号)。"
405
+ "手动跑 git pull --rebase && git push 即可;重推时它们会被一并推上去,请自行确认。\n"
406
+ " 本次台账改动已提交在本地,不会丢。"
407
+ )
408
+ return
409
+ if not self._rebase_retry():
410
+ # 真冲突已留标记(或 fetch 失败):交给人工,不再重试
411
+ return
412
+ # rebase 成功 ⇒ 循环再 push(已基于最新 origin)
413
+ notices.note(
414
+ f"提示:推送多次被拒,本次台账改动已提交在本地、未推送:{last_reason}\n"
415
+ " 处理:手动 git pull --rebase && git push(冲突自己解;"
416
+ "脚本的「按任务号合并」只在它自动重试时发生)。"
417
+ )
418
+
419
+ def _rebase_retry(self) -> bool:
420
+ """重放本机的台账提交;撞冲突时按语义接管(不同任务自动合、同任务留标记)。
421
+
422
+ `--autostash`:把工作树里用户未提交的改动临时收走,rebase 完自动放回 ——
423
+ 不这样做,只要仓库里有一处未提交改动,自动合并就永久失效。
424
+ """
425
+ fetch = _git("git", "-C", str(self.repo), "fetch", "--quiet")
426
+ if fetch.returncode != 0:
427
+ return False # 离线 / 凭据过期:不自动合并,由 push 的提示说明
428
+ rebase = _git(
429
+ "git", "-C", str(self.repo), "rebase", "--autostash", "@{upstream}"
430
+ )
431
+ if rebase.returncode == 0:
432
+ # rebase 本身成功,冲突只可能来自 autostash 放回:文件带着 `<<<<<<<`
433
+ # 标记,不点名就会有人把冲突标记当成品提交上去。
434
+ conflicted = self._conflicts()
435
+ if conflicted:
436
+ notices.note(
437
+ "提示:rebase 成功,但把你的未提交改动放回工作树时冲突了 —— "
438
+ "这些文件现在带着 `<<<<<<<` 标记,别当成品提交:\n "
439
+ + "\n ".join(conflicted)
440
+ + "\n 改动同时还在 stash 里(git stash list);解完冲突、"
441
+ "确认内容没错再 git stash drop。"
442
+ )
443
+ return True
444
+ return self.resolve_conflicts()
445
+
446
+ def resolve_conflicts(self) -> bool:
447
+ """接管 rebase 冲突。返回 False 时要么已 abort(干净失败),要么留着标记等人。"""
448
+ from . import merge
449
+
450
+ conflicts = self._conflicts()
451
+ if not conflicts:
452
+ self._abort_rebase()
453
+ return False
454
+ stages: dict[str, tuple[str, str, str]] = {}
455
+ for rel in conflicts:
456
+ triple = tuple(self._stage(rel, n) for n in (1, 2, 3)) # type: ignore[assignment]
457
+ if triple[1] is None or triple[2] is None:
458
+ self._abort_rebase() # 拿不到三路内容:交给人工
459
+ return False
460
+ stages[rel] = (triple[0] or "", triple[1], triple[2])
461
+ if not all(merge.owns(rel) for rel in stages):
462
+ # 混进业务文件:整体退回原路径,不由脚本代劳
463
+ self._abort_rebase()
464
+ return False
465
+
466
+ notes: list[str] = []
467
+ hard: list[str] = []
468
+ for rel, (base, ours, theirs) in stages.items():
469
+ if merge.is_append_only(rel):
470
+ text = merge.union_merge(base, ours, theirs)
471
+ if merge.resolve_ledger_conflict(self.repo / rel, text) is None:
472
+ self._abort_rebase()
473
+ return False
474
+ notes.append(merge.render_conflict_text(rel, base, ours, theirs))
475
+ continue
476
+ out = merge.conflict_marked(rel, base, ours, theirs)
477
+ if out is None:
478
+ self._abort_rebase()
479
+ return False
480
+ text, marked = out
481
+ if merge.resolve_ledger_conflict(self.repo / rel, text) is None:
482
+ self._abort_rebase()
483
+ return False
484
+ notes.append(merge.render_conflict_text(rel, base, ours, theirs))
485
+ hard.extend(marked)
486
+
487
+ if hard:
488
+ # 同一个任务被两边改:不替用户选,标记留着让人定,rebase 留在原地好继续
489
+ notices.note(
490
+ "提示:推送被拒后 rebase 撞上冲突,台账里有任务被两边同时改过,"
491
+ "已按任务逐个标出冲突(标记里左边是远端、右边是本机),没有替你选任何一个。\n "
492
+ + "\n ".join(hard)
493
+ + "\n 处理:编辑该文件保留你要的版本 → git add → git rebase --continue;"
494
+ "不想合就 git rebase --abort 回到写之前。\n"
495
+ " 你的台账改动已提交在本地,不会丢。\n " + "\n ".join(notes)
496
+ )
497
+ return False
498
+
499
+ add = _git("git", "-C", str(self.repo), "add", "--", *conflicts)
500
+ cont = _git(
501
+ "git",
502
+ "-C",
503
+ str(self.repo),
504
+ "-c",
505
+ "core.editor=true",
506
+ "rebase",
507
+ "--continue",
508
+ )
509
+ if add.returncode != 0 or cont.returncode != 0:
510
+ self._abort_rebase()
511
+ return False
512
+ notices.note(
513
+ "提示:推送被拒后 rebase 撞上冲突,已按任务号对账合并"
514
+ "(不同任务互不影响,追加式正文一行没丢):\n " + "\n ".join(notes)
515
+ )
516
+ return True
517
+
518
+ def _abort_rebase(self) -> None:
519
+ """干净失败:退回 rebase 之前,绝不把仓库留在中间态。"""
520
+ abort = _git("git", "-C", str(self.repo), "rebase", "--abort")
521
+ if abort.returncode != 0:
522
+ # abort 也失败:仓库真的停在中间态,必须把真实状态原样交出去。
523
+ # 谎称「已回滚」最坏 —— 那时台账文件可能带着冲突标记,继续写会全盘读坏。
524
+ status = _git("git", "-C", str(self.repo), "status", "--short", "--branch")
525
+ notices.note(
526
+ "错误:自动 rebase 后回滚也失败了,仓库可能停在 rebase 中间态。"
527
+ "在收拾干净之前不要再跑台账命令。\n"
528
+ f" 当前状态:{(status.stdout or '').strip()}\n"
529
+ " 处理:在仓库根执行 git rebase --abort;仍失败就先 git status "
530
+ "看清残留文件(必要时删掉 .git/rebase-merge 或 .git/rebase-apply)再重试。"
531
+ )
532
+
533
+ def _has_foreign_commits(self) -> bool:
534
+ """上游之上是否还有**非台账**的本地提交(有就不自动 rebase)。"""
535
+ upstream = self.upstream()
536
+ if not upstream:
537
+ return True
538
+ allproc = _git(
539
+ "git", "-C", str(self.repo), "log", f"{upstream}..HEAD", "--oneline"
540
+ )
541
+ ledger = _git(
542
+ "git",
543
+ "-C",
544
+ str(self.repo),
545
+ "log",
546
+ f"{upstream}..HEAD",
547
+ "--oneline",
548
+ "--",
549
+ "ledger",
550
+ )
551
+ total = len([x for x in allproc.stdout.splitlines() if x.strip()])
552
+ ours = len([x for x in ledger.stdout.splitlines() if x.strip()])
553
+ return total > ours
554
+
555
+ def _foreign_titles(self) -> list[str]:
556
+ """上游之上、且不碰 `ledger/` 的本地提交标题(提示里点名用)。"""
557
+ upstream = self.upstream()
558
+ if not upstream:
559
+ return []
560
+ proc = _git(
561
+ "git",
562
+ "-C",
563
+ str(self.repo),
564
+ "log",
565
+ f"{upstream}..HEAD",
566
+ "--format=%s",
567
+ "--",
568
+ ".",
569
+ ":!ledger",
570
+ )
571
+ if proc.returncode != 0:
572
+ return []
573
+ return [line for line in proc.stdout.splitlines() if line.strip()]
574
+
575
+ def _stage(self, rel: str, stage: int) -> str | None:
576
+ """取 rebase 冲突的三路内容(`:1` base / `:2` ours / `:3` theirs)。"""
577
+ proc = _git("git", "-C", str(self.repo), "show", f":{stage}:{rel}")
578
+ return proc.stdout if proc.returncode == 0 else None
579
+
580
+ def _conflicts(self) -> list[str]:
581
+ # `-z`:非 ASCII 路径不做引号 / 八进制转义。中文任务名的过程文档一抓一个准,
582
+ # 不加 -z 时拿到的路径带引号,`git show :1:<path>` 取不到内容、owns() 也判否,
583
+ # 冲突接管会整个放弃(2026-10-23 场景测试抓到的真 bug)。
584
+ proc = _git(
585
+ "git",
586
+ "-C",
587
+ str(self.repo),
588
+ "diff",
589
+ "--name-only",
590
+ "--diff-filter=U",
591
+ "-z",
592
+ )
593
+ if proc.returncode != 0:
594
+ return []
595
+ return [line for line in proc.stdout.split("\0") if line]
596
+
597
+
598
+ def _porcelain_path(line: str) -> str:
599
+ """`git status --porcelain` 的一行 → 路径(去掉状态位、剥引号、还原非 ASCII 转义)。"""
600
+ raw = line[3:].strip()
601
+ if len(raw) >= 2 and raw.startswith('"') and raw.endswith('"'):
602
+ raw = raw[1:-1]
603
+ if "\\" in raw:
604
+ try:
605
+ raw = raw.encode("latin-1", "backslashreplace").decode("unicode_escape")
606
+ raw = raw.encode("latin-1", "backslashreplace").decode("utf-8", "replace")
607
+ except (UnicodeError, LookupError):
608
+ pass
609
+ return raw
610
+
611
+
612
+ def _is_worklog(rel: str) -> bool:
613
+ """是不是手写的过程文档(与台账文件区分:台账硬拦、过程文档放行叠回)。"""
614
+ return rel.replace("\\", "/").startswith("ledger/worklog/")
615
+
616
+
617
+ def _changed(path: Path, before: bytes | None) -> bool:
618
+ """取最新之后这个文件的内容是否真变了(没变就不该进记账本:重跑幂等时提交空改动必失败)。"""
619
+ try:
620
+ after = path.read_bytes()
621
+ except OSError:
622
+ after = None
623
+ return after != before
624
+
625
+
626
+ def _rel(repo: Path, path: Path) -> str:
627
+ try:
628
+ return str(Path(path).resolve().relative_to(Path(repo).resolve()))
629
+ except ValueError:
630
+ return str(path)
631
+
632
+
633
+ def _paths(repo: Path, scope: Scope) -> list[Path]:
634
+ """`Scope.files` + `Scope.dirs` 展开后的已存在文件(目录只刷、不加锁)。
635
+
636
+ `Scope` 里的路径相对 `ledger/`,换算只在 `core/paths.to_repo_rel` 一处做。
637
+ """
638
+ out: list[Path] = []
639
+ for rel in scope.files:
640
+ out.append(repo / to_repo_rel(rel))
641
+ for directory in scope.dirs:
642
+ base = repo / to_repo_rel(directory)
643
+ if base.is_dir():
644
+ out.extend(sorted(p for p in base.rglob("*") if p.is_file()))
645
+ seen: set[Path] = set()
646
+ uniq: list[Path] = []
647
+ for path in out:
648
+ if path not in seen:
649
+ seen.add(path)
650
+ uniq.append(path)
651
+ return uniq
652
+
653
+
654
+ def _rels(repo: Path, scope: Scope) -> list[str]:
655
+ return [_rel(repo, p) for p in _paths(repo, scope)]