task-ledger 1.2.7 → 1.2.8
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 +5 -2
- package/SKILL.md +6 -2
- package/package.json +1 -1
- package/references/troubleshooting.md +13 -2
- package/scripts/progress.py +664 -59
package/README.md
CHANGED
|
@@ -120,8 +120,11 @@
|
|
|
120
120
|
- 冲突时**报错要求重跑**,不静默覆盖
|
|
121
121
|
- 写入成功后脚本会**自动同步**:把本次改动的台账文件 `git add <精确文件>` + `commit`(消息 `chore(ledger): <命令> <提交者>`,即 conventional commits 格式,多数仓库装了 commitlint 也认);当前分支有上游跟踪分支时再 `push`。全程确定性脚本,不依赖大模型
|
|
122
122
|
- 仓库若装了自定义提交检查并拒了这条消息,脚本会**再用 `--no-verify` 提交一次**(台账是数据文件,不过 lint);仍失败则把 git 的报错打到 stderr 并要求手动提交 —— **同步失败绝不静默**,但退出码保持 0(台账本身已写成功)
|
|
123
|
-
-
|
|
124
|
-
-
|
|
123
|
+
- 写前会**定向取最新**:只刷新 `ledger/progress.json` 与 `ledger/history/`(这两个只由脚本写);`ledger/worklog/` 里已有的正文一个字都不动,只在本地缺文件时补下远端的新文件(补下的文件随本次提交入库)。本地有未推送的台账提交、未提交改动、或未入库的同名文件时**中止**,不静默覆盖;命令随后失败时,写前刷下来的内容会回滚原样
|
|
124
|
+
- 本地**没有未推送提交**时,写前会先 fast-forward 到上游(只快进、不产生合并提交);有未推送提交就跳过,退回上面那条的定向刷新
|
|
125
|
+
- 同一个 clone 里的写命令**排队**执行(仓级文件锁,最多等 60 秒),避免两个 agent 同时写时,一个把另一个刚写完还没提交的改动静默覆盖
|
|
126
|
+
- 写后推送被拒时,工作树干净才自动 `rebase` 一次再推(各自改各自的任务时按行自动合并);冲突立刻 `rebase --abort`,绝不 `--force`、绝不留 rebase 中间态
|
|
127
|
+
- 无 git 仓库 / 无上游跟踪 / 被 `.gitignore` 排除 / 无实际变更 都会静默跳过;`--no-sync` 或环境变量 `TASK_LEDGER_NO_SYNC=1` 可同时关掉写前取最新与写后同步
|
|
125
128
|
|
|
126
129
|
### 结案是原子操作
|
|
127
130
|
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: task-ledger
|
|
3
3
|
description: 本地任务台账,跨会话记录在办任务的归属、进度与起止。当用户要登记一件新的事(新建任务 / 建个坑 / 这活我领了)、记录已有任务的进展(接着做 / 记一条进展 / 这轮先记下)、查看任务状态或历史(现在该做什么 / 这活谁在做 / 上周做过什么)时加载;对某个具体任务要求结案或退回时,只认用户的明确指令;只是改代码、讨论方案时不要用。
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.8
|
|
5
5
|
allowed-tools: Read,Write,Bash,Grep
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -115,7 +115,11 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
|
|
|
115
115
|
## 出错即停
|
|
116
116
|
|
|
117
117
|
非 0 退出就停手、原样报告,**不要绕过脚本手改 `progress.json` 或历史层**。
|
|
118
|
+
**退出码 0 不等于没事**:写命令之后的同步(commit / push)失败仍返回 0,原因只写在输出里。写命令跑完必看输出,出现 `提示:` / `错误:` / `警告:` 就先用一句话说清「发生了什么 + 你要做什么」,再附原文关键行,按 `references/troubleshooting.md` 对应条目给步骤;纯告知类(如已用 `--no-verify` 提交)一句话带过。只读命令只有 `错误:` 才需要转述。
|
|
118
119
|
并发冲突(文件被改动)重跑一次即可,不要用 `git pull` / `rebase` 去「解决」。
|
|
120
|
+
写前会**定向取最新**:只刷新 `ledger/progress.json` 与 `ledger/history/`,`worklog/` 里已有的正文一个字都不动;
|
|
121
|
+
本地有未推送的台账提交、未提交改动或未入库的同名文件时会中止 —— 按提示先提交 / 推送,或加 `--no-sync` 继续本地写。
|
|
122
|
+
同一仓库里写命令**排队**执行(最多等 60 秒),并发时不要重试轰炸,等前一个跑完再重跑。
|
|
119
123
|
|
|
120
124
|
细节见 `references/troubleshooting.md`。
|
|
121
125
|
|
|
@@ -127,4 +131,4 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
|
|
|
127
131
|
| 填正文前、补老任务文档、`log` 报错找不到文档 | `references/worklog.md` |
|
|
128
132
|
| 拿不准一条记录够不够格、`--note` 怎么写 | `references/log-note.md` |
|
|
129
133
|
| 写分类/标签、用户说标签太乱要归并 | `references/taxonomy.md` |
|
|
130
|
-
| 任何非 0
|
|
134
|
+
| 任何非 0 退出、退出码 0 但有 `提示:` / `错误:`、并发冲突、`validate` 报错 | `references/troubleshooting.md` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-ledger",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Task-ledger skill for coding agents: per-workspace progress tracking (ledger/progress.json, history, worklog) driven by a CLI script. Standalone install for CodeBuddy and other agents; also bundled inside the dsh-task-ledger DSH plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"skill",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 排错
|
|
2
2
|
|
|
3
|
-
> TL;DR:非 0
|
|
3
|
+
> TL;DR:非 0 退出就停手、原样报告;**退出码 0 但输出里有 `提示:` / `错误:` 同样要读并转述**(同步失败仍返回 0)。别绕过脚本改 JSON。并发冲突重跑一次,不要用 `git pull` / `rebase` 去「解决」。
|
|
4
4
|
|
|
5
5
|
## 退出码
|
|
6
6
|
|
|
7
7
|
| 码 | 含义 | 处置 |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| 0 |
|
|
9
|
+
| 0 | 成功(**同步可能失败**:输出里有 `提示:` 就按上面那条读) | — |
|
|
10
10
|
| 1 | 业务错误(参数值 / 状态 / 权限不对) | 按提示纠正后重试;改不了的报告用户 |
|
|
11
11
|
| 2 | 命令行参数错误 | 看 `<子命令> --help` 重新拼参数 |
|
|
12
12
|
| 3 | 未预期的内部失败 | 上报,不要重试 |
|
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
- **「状态不一致」**:对已在 `doing` 的任务又跑了 `claim`。直接改正文 + `log` 即可。
|
|
20
20
|
- **`log` 报「没写进过程文档」**:worklog 路径丢失 / 不可读。用 `show --id` 查路径;`progress.json` 的 `updated` 已刷新,不要手写「执行记录」。
|
|
21
21
|
- **「文件在操作期间被改动」**(并发指纹冲突):重新执行一次即可。
|
|
22
|
+
- **「本地台账有未提交改动,写前取最新会覆盖它」**:写前只取 `progress.json` / `history/` 的最新版,本地这两处脏就先停下。先提交(`git add ledger/ && git commit -m "chore(ledger): ..."`)再重跑;暂时离线可加 `--no-sync` 继续本地写。
|
|
23
|
+
- **「另一个会话正在同步同一份台账(已等 60 秒)」**:同一仓库里另一个 agent 正在写并推送,锁没抢到。等它跑完再重跑即可,不要并行重试(会把排队队列越堆越长)。
|
|
24
|
+
- **「本地有 N 个台账提交还没推到 …」**:上次推送没成功(离线 / 他人先推 / 认证失败)。先 `git push` —— 推台账时脚本自己会 fetch + rebase 重试一次,被拒就按它打出的提示手动合并,**不要自己 `git pull` / `git rebase` 台账目录**(会把写前同步的护栏绕过去)。推完重跑;离线场景用 `--no-sync`,联网后统一提交推送。
|
|
25
|
+
- **「本地已提交,但推送被拒」**:改动已安全落盘,只是远端没收,**不要重跑台账命令**(会二次写入)。看提示里 `git:` 那行的原文:`! [rejected] ... (fetch first)` = 他人先推,脚本已自动合并过一次仍失败就手动合并台账文件;`Permission denied (publickey)` / `could not read Username` = 凭据问题,找用户修,不要反复重试。
|
|
26
|
+
- **「自动 rebase 未成功(已回滚到 rebase 前)」**:同一条任务被两边同时改了。脚本已 abort,仓库是干净的;按提示里的冲突原文手动合并后 `git push`。
|
|
27
|
+
- **「自动 rebase 冲突后回滚也失败了」**:仓库停在 rebase 中间态,**先别跑任何台账命令**。在仓库根 `git rebase --abort`;仍失败就 `git status` 看残留,必要时删 `.git/rebase-merge` / `.git/rebase-apply` 再重试。
|
|
28
|
+
- **「暂存了你的未提交改动」+ autostash 冲突**:你的未提交改动在 stash 里,没有丢。`git stash list` 确认后用 `git stash pop` 手动解冲突;只想要内容可 `git stash show -p stash@{0}` 取出。
|
|
29
|
+
- **「git add 失败」/「自动提交失败」**:台账已写进本地文件,只是没进 git。按错误原话处理后手动 `git add <文件> && git commit -m "chore(ledger): ..."`。
|
|
30
|
+
- **「没有上游跟踪分支」/「当前是 detached HEAD」**:台账照写、照提交到本地,但**不会推送**。让用户建上游(`git push -u origin <分支>`)或切回分支;离线时这样用,联网后统一推一次。
|
|
31
|
+
- **「写前快进 … 没成功」**:通常是工作区里有未提交改动(git 按文件拦,不看行),脚本改走「只刷新本次要写的台账文件」。想快进就先提交或 stash 那些文件。
|
|
32
|
+
- **「worklog 正文疑似被覆盖成模板骨架」**:`validate` 会对仍只有注释占位的在办任务打提示。从 git 恢复该文件(`git checkout HEAD -- <路径>`)后重写正文,再跑 `log`。
|
|
22
33
|
- **`validate` 报问题**:按行号读原文,先核对 `progress.json` 与 worklog 是否一致;能通过脚本修的走 `set` / `log`,**不要手改 JSON**。
|
|
23
34
|
|
|
24
35
|
## 硬约束
|
package/scripts/progress.py
CHANGED
|
@@ -20,9 +20,11 @@ import json
|
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
22
|
import secrets
|
|
23
|
+
import stat
|
|
23
24
|
import subprocess
|
|
24
25
|
import sys
|
|
25
26
|
import tempfile
|
|
27
|
+
import time
|
|
26
28
|
from collections.abc import Iterator
|
|
27
29
|
from datetime import datetime
|
|
28
30
|
from pathlib import Path
|
|
@@ -173,6 +175,11 @@ def find_repo(explicit: str | None) -> Path:
|
|
|
173
175
|
p = Path(explicit).expanduser().resolve()
|
|
174
176
|
if not p.is_dir():
|
|
175
177
|
raise Fail(f"--repo 不是目录:{p}")
|
|
178
|
+
# 传进来的常是工作区子目录;向上找真正的台账根,否则写前同步会对着
|
|
179
|
+
# 不存在的 <子目录>/ledger 空转。都找不到就仍用传入目录(首次 init 需要它)。
|
|
180
|
+
for cand in (p, *p.parents):
|
|
181
|
+
if (cand / PROGRESS_REL).exists():
|
|
182
|
+
return cand
|
|
176
183
|
return p
|
|
177
184
|
|
|
178
185
|
bases: list[Path] = []
|
|
@@ -248,17 +255,55 @@ def read_json(path: Path) -> dict[str, Any]:
|
|
|
248
255
|
# 本次进程内被台账写入的真实文件路径,供写成功后一次性自动同步(git commit/push)。
|
|
249
256
|
CHANGED_FILES: list[Path] = []
|
|
250
257
|
|
|
258
|
+
# 写前预同步从远端落盘的文件:不是本次命令的写入目标,但必须随本次提交入库
|
|
259
|
+
# (否则会一直以未跟踪状态留在工作树里,把自动 rebase 挡在门口)。
|
|
260
|
+
PRESYNC_FILES: list[Path] = []
|
|
261
|
+
|
|
262
|
+
# 写前预同步覆盖前的内容(None = 覆盖前不存在)。命令失败时整体回滚,
|
|
263
|
+
# 免得把「刚才从远端刷下来的版本」留在工作树里,下次又被 guard 当成用户未提交改动。
|
|
264
|
+
PRESYNC_BACKUPS: dict[Path, bytes | None] = {}
|
|
251
265
|
|
|
252
|
-
|
|
266
|
+
|
|
267
|
+
def presync_rollback() -> None:
|
|
268
|
+
"""把写前预同步覆盖过的文件恢复原样。只在命令失败的路径上调用。"""
|
|
269
|
+
while PRESYNC_BACKUPS:
|
|
270
|
+
path, original = PRESYNC_BACKUPS.popitem()
|
|
271
|
+
try:
|
|
272
|
+
if original is None:
|
|
273
|
+
path.unlink(missing_ok=True)
|
|
274
|
+
else:
|
|
275
|
+
path.write_bytes(original)
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
print(
|
|
278
|
+
f"提示:写前刷新 {path} 的回滚未完成({exc}),请手动检查该文件。",
|
|
279
|
+
file=sys.stderr,
|
|
280
|
+
)
|
|
281
|
+
PRESYNC_FILES.clear()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def write_text_atomic(path: Path, text: str, *, track: bool = True) -> None:
|
|
253
285
|
"""原子写文本:同目录唯一临时名 + fsync + os.replace,写完清理临时文件。
|
|
254
286
|
|
|
255
287
|
整段都在 try 内 —— mkdir / mkstemp 的 OSError 同样要转成 Fail,
|
|
256
288
|
否则会漏成裸异常,上层的「失败就撤销」也就收不到。
|
|
289
|
+
|
|
290
|
+
track=False 用于「从远端刷下来」的内容:那不是本次命令的改动,
|
|
291
|
+
不能进 CHANGED_FILES,否则会被写后自动提交当成本次台账变更。
|
|
292
|
+
|
|
293
|
+
两个属性都得保住:mkstemp 建的是 0600,直接 replace 会把文件权限从 644 降成
|
|
294
|
+
600(组内用户与 CI 读不到),所以写前继承原文件的 mode;符号链接一律拒绝,
|
|
295
|
+
否则 replace 会把链接本身换成普通文件。
|
|
257
296
|
"""
|
|
258
297
|
fd = None
|
|
259
298
|
tmp = None
|
|
260
299
|
try:
|
|
261
300
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
if path.is_symlink():
|
|
302
|
+
raise Fail(f"拒绝写入 {path}:它是符号链接,写入会把它替换成普通文件")
|
|
303
|
+
try:
|
|
304
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
305
|
+
except OSError:
|
|
306
|
+
mode = 0o644 # 新文件:与 git 默认 644 对齐
|
|
262
307
|
fd, tmp_name = tempfile.mkstemp(
|
|
263
308
|
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
|
264
309
|
)
|
|
@@ -268,8 +313,10 @@ def write_text_atomic(path: Path, text: str) -> None:
|
|
|
268
313
|
fh.write(text)
|
|
269
314
|
fh.flush()
|
|
270
315
|
os.fsync(fh.fileno())
|
|
316
|
+
os.chmod(tmp, mode)
|
|
271
317
|
os.replace(tmp, path)
|
|
272
|
-
|
|
318
|
+
if track:
|
|
319
|
+
CHANGED_FILES.append(path) # 记下本次改动,供写后自动同步
|
|
273
320
|
tmp = None
|
|
274
321
|
except OSError as exc:
|
|
275
322
|
raise Fail(f"写 {path} 失败:{exc}")
|
|
@@ -287,11 +334,27 @@ def write_text_atomic(path: Path, text: str) -> None:
|
|
|
287
334
|
|
|
288
335
|
|
|
289
336
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
337
|
+
"""原子写状态表:一个任务一行,任务内 key 按字母序。
|
|
338
|
+
|
|
339
|
+
为什么压成一行一条:progress.json 是多 agent 共享的并发写点。任务块各占十几行时,
|
|
340
|
+
A 结案删掉一个任务、B 同时刷新相邻任务的 updated,两处改动间距不足 git 的 3 行
|
|
341
|
+
patch 上下文,rebase 必冲突(实测撞到过)。一行一条后,删任务只删一行、改任务只改
|
|
342
|
+
一行,只有相邻两个任务同时被改才可能撞上。
|
|
343
|
+
|
|
344
|
+
顶层不是 `{"tasks": {...}}` 形状时退回缩进格式:本函数只管状态表,不顺手改坏别的结构。
|
|
345
|
+
"""
|
|
346
|
+
tasks = data.get("tasks")
|
|
347
|
+
if not isinstance(tasks, dict) or set(data) != {"tasks"}:
|
|
348
|
+
write_text_atomic(
|
|
349
|
+
path, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
350
|
+
)
|
|
351
|
+
return
|
|
352
|
+
rows = ",\n".join(
|
|
353
|
+
f" {json.dumps(k, ensure_ascii=False)}: {json.dumps(v, ensure_ascii=False, sort_keys=True)}"
|
|
354
|
+
for k, v in sorted(tasks.items())
|
|
294
355
|
)
|
|
356
|
+
body = '{\n "tasks": {\n' + (rows + "\n" if rows else "") + " }\n}\n"
|
|
357
|
+
write_text_atomic(path, body)
|
|
295
358
|
|
|
296
359
|
|
|
297
360
|
def stat_key(path: Path) -> str | tuple[str, ...] | None:
|
|
@@ -340,6 +403,54 @@ def progress_lock(path: Path) -> Iterator[None]:
|
|
|
340
403
|
os.close(fd)
|
|
341
404
|
|
|
342
405
|
|
|
406
|
+
@contextlib.contextmanager
|
|
407
|
+
def repo_sync_lock(git_dir: Path, timeout: float = 60.0) -> Iterator[None]:
|
|
408
|
+
"""写命令的整段临界区:写前同步 → 命令写入 → 提交推送,同一 clone 只允许一个。
|
|
409
|
+
|
|
410
|
+
为什么必须整段:写前同步是「检查本地干净 → 覆盖 progress.json」两步,中间是敞开的。
|
|
411
|
+
另一个 agent 恰好在这几毫秒里写完,覆盖就会把它的改动静默抹掉,随后的提交还会把
|
|
412
|
+
错误内容推走 —— 事后只能靠人翻历史才发现。progress_lock 只管单文件的读改写,盖不住
|
|
413
|
+
这两步之间的缝。
|
|
414
|
+
|
|
415
|
+
锁文件放 git 目录里(不进仓库、不被 git 状态看见);拿锁最多等 timeout 秒。
|
|
416
|
+
"""
|
|
417
|
+
if fcntl is None and msvcrt is None:
|
|
418
|
+
yield
|
|
419
|
+
return
|
|
420
|
+
lock_path = git_dir / "dsh-task-ledger.sync.lock"
|
|
421
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
|
|
422
|
+
acquired = False
|
|
423
|
+
try:
|
|
424
|
+
deadline = time.monotonic() + timeout
|
|
425
|
+
while True:
|
|
426
|
+
try:
|
|
427
|
+
if fcntl is not None:
|
|
428
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
429
|
+
elif msvcrt is not None:
|
|
430
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
431
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
432
|
+
acquired = True
|
|
433
|
+
break
|
|
434
|
+
except OSError:
|
|
435
|
+
if time.monotonic() >= deadline:
|
|
436
|
+
raise Fail(
|
|
437
|
+
f"另一个会话正在同步同一份台账(已等 {int(timeout)} 秒),请稍后重试。"
|
|
438
|
+
)
|
|
439
|
+
time.sleep(0.1)
|
|
440
|
+
yield
|
|
441
|
+
finally:
|
|
442
|
+
if acquired:
|
|
443
|
+
try:
|
|
444
|
+
if fcntl is not None:
|
|
445
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
446
|
+
elif msvcrt is not None:
|
|
447
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
448
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
449
|
+
except OSError:
|
|
450
|
+
pass
|
|
451
|
+
os.close(fd)
|
|
452
|
+
|
|
453
|
+
|
|
343
454
|
def read_guarded(path: Path):
|
|
344
455
|
"""读状态表并记下指纹;配合 commit_guarded 做写前复查。"""
|
|
345
456
|
return stat_key(path), read_json(path)
|
|
@@ -373,6 +484,97 @@ def _git_actor(repo_root: str) -> str:
|
|
|
373
484
|
return out.stdout.strip() or "ledger-script"
|
|
374
485
|
|
|
375
486
|
|
|
487
|
+
def _why(raw: str | None, fallback: str) -> str:
|
|
488
|
+
"""把 git 的 stderr / stdout 压成一行:取最后几行非空内容,够定位原因就行。"""
|
|
489
|
+
lines = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()]
|
|
490
|
+
return " / ".join(lines[-3:]) if lines else fallback
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _foreign_commits(g: tuple[str, ...], upstream: str) -> tuple[int, list[str]]:
|
|
494
|
+
"""本地领先 upstream、但不在 `ledger/` 里的提交(用户自己的代码):返回 (数量, 前几条摘要)。
|
|
495
|
+
|
|
496
|
+
git 按路径过滤,不看提交消息 —— 用户把台账提交改写成别的 message 也不会被误判成「业务提交」。
|
|
497
|
+
"""
|
|
498
|
+
spec = f"{upstream}..HEAD"
|
|
499
|
+
raw = _git_bytes(*g, "rev-list", "--count", spec, "--", ".", ":(exclude)ledger")
|
|
500
|
+
try:
|
|
501
|
+
count = int((raw or b"-1").decode("ascii", "replace").strip())
|
|
502
|
+
except ValueError:
|
|
503
|
+
return 0, [] # 数不出来就当作没有:宁可少提示,不阻断台账写入
|
|
504
|
+
if count <= 0:
|
|
505
|
+
return 0, []
|
|
506
|
+
listed = _git_bytes(
|
|
507
|
+
*g, "log", "--format=%h %s", spec, "--", ".", ":(exclude)ledger"
|
|
508
|
+
)
|
|
509
|
+
return count, (listed or b"").decode("utf-8", "replace").strip().splitlines()[:3]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _rebase_retry(g: tuple[str, ...]) -> bool:
|
|
513
|
+
"""推送被拒后自动 rebase 一次;只在工作树干净时做,失败立刻 abort。
|
|
514
|
+
|
|
515
|
+
台账按任务分条存(progress.json 是按任务号 key 的对象、history 一行一条),
|
|
516
|
+
各自改各自的任务时 git 能按行自动合并。真冲突(同一条任务被两边改)就干净失败,
|
|
517
|
+
绝不把仓库留在 rebase 中间态,也绝不 --force。
|
|
518
|
+
"""
|
|
519
|
+
fetched = _git(*g, "fetch", timeout=30)
|
|
520
|
+
if fetched.returncode != 0:
|
|
521
|
+
# 不静默:凭据过期 / 离线时这里就是唯一能说明「为什么没自动合并」的地方
|
|
522
|
+
print(
|
|
523
|
+
f"提示:推送被拒后重新取最新失败"
|
|
524
|
+
f"({_why(fetched.stderr or fetched.stdout, f'退出码 {fetched.returncode}')}),未自动合并。",
|
|
525
|
+
file=sys.stderr,
|
|
526
|
+
)
|
|
527
|
+
return False
|
|
528
|
+
# 用户自己 commit 的代码不动:rebase 会重放整支分支,把他们的提交号全改写。
|
|
529
|
+
# 只在自己那几条台账提交上做,才是「只碰台账」这条边界的完整实现。
|
|
530
|
+
foreign, titles = _foreign_commits(g, "@{upstream}")
|
|
531
|
+
if foreign > 0:
|
|
532
|
+
detail = f":{';'.join(titles)}" if titles else ""
|
|
533
|
+
print(
|
|
534
|
+
f"提示:本地有 {foreign} 个你自己的提交还没推送{detail}。"
|
|
535
|
+
"脚本不替你 rebase(那会改写它们的提交号),本次没有自动合并。\n"
|
|
536
|
+
" 处理:手动 `git pull --rebase` 或 `git merge` 后再 `git push`;"
|
|
537
|
+
"台账改动已安全留在本地。",
|
|
538
|
+
file=sys.stderr,
|
|
539
|
+
)
|
|
540
|
+
return False
|
|
541
|
+
# --autostash:把工作树里未提交的改动(用户正在写的代码)临时收走、rebase 完自动放回。
|
|
542
|
+
# 不这样做的话,只要仓库里有一处未提交改动,自动合并就永久失效 —— 台账提交越堆越多,
|
|
543
|
+
# 直到下一次写被「有未推送提交」拦住,而提示跟用户的业务代码毫无关系。
|
|
544
|
+
rebase = _git(*g, "rebase", "--autostash", "@{upstream}", timeout=30)
|
|
545
|
+
if rebase.returncode == 0:
|
|
546
|
+
combined = f"{rebase.stdout or ''}{rebase.stderr or ''}"
|
|
547
|
+
if "autostash" in combined:
|
|
548
|
+
print(
|
|
549
|
+
"提示:自动合并前暂存了你的未提交改动,rebase 后已放回;"
|
|
550
|
+
"若 git 提示 autostash 冲突,跑 `git stash list` 手动处理。",
|
|
551
|
+
file=sys.stderr,
|
|
552
|
+
)
|
|
553
|
+
return True
|
|
554
|
+
conflict = (rebase.stderr or rebase.stdout or "").strip()
|
|
555
|
+
abort = _git(*g, "rebase", "--abort", timeout=30)
|
|
556
|
+
if abort.returncode != 0:
|
|
557
|
+
# abort 也失败:仓库现在真的停在 rebase 中间态,必须把真实状态原样交出去。
|
|
558
|
+
# 谎称「已回滚」最坏 —— 那时 progress.json 可能带冲突标记,继续写会全盘读坏。
|
|
559
|
+
status = _git(*g, "status", "--short", "--branch")
|
|
560
|
+
print(
|
|
561
|
+
"错误:自动 rebase 冲突后回滚也失败了,仓库可能停在 rebase 中间态。"
|
|
562
|
+
"在收拾干净之前不要再跑台账命令。\n"
|
|
563
|
+
f" 冲突:{conflict}\n"
|
|
564
|
+
f" 当前状态:{(status.stdout or '').strip()}\n"
|
|
565
|
+
" 处理:在仓库根执行 `git rebase --abort`;仍失败就先 `git status` 看清残留文件"
|
|
566
|
+
"(必要时删掉 .git/rebase-merge 或 .git/rebase-apply)再重试。",
|
|
567
|
+
file=sys.stderr,
|
|
568
|
+
)
|
|
569
|
+
return False
|
|
570
|
+
print(
|
|
571
|
+
"提示:自动 rebase 未成功(已回滚到 rebase 前),多半是同一条任务被两边同时改了,"
|
|
572
|
+
f"请手动合并:{conflict}",
|
|
573
|
+
file=sys.stderr,
|
|
574
|
+
)
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
376
578
|
def git_sync(action: str) -> None:
|
|
377
579
|
"""写成功后调用一次:把本次改动的台账文件提交;有上游跟踪分支则推送。
|
|
378
580
|
|
|
@@ -383,7 +585,10 @@ def git_sync(action: str) -> None:
|
|
|
383
585
|
if os.environ.get("TASK_LEDGER_NO_SYNC"):
|
|
384
586
|
return
|
|
385
587
|
try:
|
|
386
|
-
|
|
588
|
+
# 本次写入目标 + 写前补下的 worklog:后者不是本次改动,但也得随这次提交入库
|
|
589
|
+
files = [
|
|
590
|
+
p for p in dict.fromkeys([*CHANGED_FILES, *PRESYNC_FILES]) if p.exists()
|
|
591
|
+
]
|
|
387
592
|
if not files:
|
|
388
593
|
return
|
|
389
594
|
# 从任一文件反查仓库根,不依赖调用方透传 --repo
|
|
@@ -400,17 +605,27 @@ def git_sync(action: str) -> None:
|
|
|
400
605
|
]
|
|
401
606
|
if not to_add:
|
|
402
607
|
return # 全被 ignore → 尊重用户选择,不强行提交
|
|
403
|
-
_git(*g, "add", *to_add)
|
|
404
|
-
if
|
|
608
|
+
staged = _git(*g, "add", *to_add)
|
|
609
|
+
if staged.returncode != 0:
|
|
610
|
+
print(
|
|
611
|
+
"提示:台账已写入本地,但 git add 失败,请手动提交 ledger/:"
|
|
612
|
+
f"{(staged.stderr or staged.stdout or '').strip()}",
|
|
613
|
+
file=sys.stderr,
|
|
614
|
+
)
|
|
615
|
+
return
|
|
616
|
+
# 只看本次要提交的路径:别人暂存的文件不该让这里误判成「有变更」
|
|
617
|
+
if _git(*g, "diff", "--cached", "--quiet", "--", *to_add).returncode == 0:
|
|
405
618
|
return # 无实际变更
|
|
406
619
|
actor = _git_actor(root)
|
|
407
620
|
# 消息按 conventional commits 写:多数仓库装了 commitlint,只认白名单里的 type
|
|
408
621
|
msg = f"chore(ledger): {action} {actor}"
|
|
409
|
-
|
|
622
|
+
# 必须 --only + pathspec:git commit 默认提交整个 index,会把用户或别的 agent
|
|
623
|
+
# 已 git add 的业务文件一起卷进这条 chore(ledger) 提交并推走。
|
|
624
|
+
done = _git(*g, "commit", "--only", "-m", msg, "--", *to_add)
|
|
410
625
|
if done.returncode != 0:
|
|
411
626
|
# 各仓库提交规则各不相同(type 白名单、大小写、scope 枚举……),台账是数据文件,
|
|
412
627
|
# 过不了就让这一次跳过钩子提交,别把台账永久锁在本地暂存区。
|
|
413
|
-
done = _git(*g, "commit", "--no-verify", "-m", msg)
|
|
628
|
+
done = _git(*g, "commit", "--only", "--no-verify", "-m", msg, "--", *to_add)
|
|
414
629
|
if done.returncode == 0:
|
|
415
630
|
print(
|
|
416
631
|
f"提示:仓库提交检查拒绝了消息「{msg}」,已用 --no-verify 提交本次台账改动。",
|
|
@@ -427,19 +642,40 @@ def git_sync(action: str) -> None:
|
|
|
427
642
|
upstream = _git(*g, "rev-parse", "--abbrev-ref", "@{upstream}")
|
|
428
643
|
if upstream.returncode != 0:
|
|
429
644
|
return
|
|
645
|
+
# push 是分支级的:本地有用户自己 commit 但没推的代码,会一并送到远端。
|
|
646
|
+
# 脚本不替用户下这个决定,但必须说出来,别让它无声发生。
|
|
647
|
+
foreign, titles = _foreign_commits(g, upstream.stdout.strip())
|
|
648
|
+
if foreign > 0:
|
|
649
|
+
detail = f":{';'.join(titles)}" if titles else ""
|
|
650
|
+
print(
|
|
651
|
+
f"提示:本次推送会把本地 {foreign} 个你自己的提交一并推上去{detail}。",
|
|
652
|
+
file=sys.stderr,
|
|
653
|
+
)
|
|
430
654
|
try:
|
|
431
655
|
_git(*g, "push", timeout=30, check=True)
|
|
432
|
-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
433
|
-
#
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
)
|
|
656
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as push_exc:
|
|
657
|
+
# 非快进:只在台账工作树干净时自动 rebase 一次再推,冲突立刻 abort。
|
|
658
|
+
# 各自改各自的任务时 git 按行自动合并;同一条任务冲突就干净失败,绝不 --force。
|
|
659
|
+
if _rebase_retry(g):
|
|
660
|
+
try:
|
|
661
|
+
_git(*g, "push", timeout=30, check=True)
|
|
662
|
+
return
|
|
663
|
+
except (
|
|
664
|
+
subprocess.CalledProcessError,
|
|
665
|
+
subprocess.TimeoutExpired,
|
|
666
|
+
) as retry_exc:
|
|
667
|
+
print(
|
|
668
|
+
"提示:自动合并后再次推送仍被拒"
|
|
669
|
+
f"({_why(getattr(retry_exc, 'stderr', '') or str(retry_exc), '推送失败')})。",
|
|
670
|
+
file=sys.stderr,
|
|
671
|
+
)
|
|
672
|
+
# 必须带 git 原话:认证过期 / 分支保护 / 非快进只能从原文分辨,只给一句猜测等于没说
|
|
673
|
+
print(
|
|
674
|
+
"提示:本地已提交,但推送被拒,本次改动已安全留在本地。\n"
|
|
675
|
+
f" git:{_why(getattr(push_exc, 'stderr', '') or str(push_exc), '推送失败')}\n"
|
|
676
|
+
" 处理:`git push` 看原话;认证 / 权限问题找用户,不要反复重试。",
|
|
677
|
+
file=sys.stderr,
|
|
678
|
+
)
|
|
443
679
|
except Exception as exc: # noqa: BLE001 —— 同步失败不应污染台账主流程
|
|
444
680
|
print(
|
|
445
681
|
f"提示:写后自动同步未执行({type(exc).__name__}: {exc})。数据已写入本地。",
|
|
@@ -451,12 +687,206 @@ def git_sync(action: str) -> None:
|
|
|
451
687
|
READ_ONLY_COMMANDS = {"list", "show", "history", "validate"}
|
|
452
688
|
|
|
453
689
|
|
|
454
|
-
def
|
|
455
|
-
"""
|
|
690
|
+
def _git_bytes(*args: str, timeout: int | None = None) -> bytes | None:
|
|
691
|
+
"""跑 git 拿原始字节;失败返回 None。
|
|
692
|
+
|
|
693
|
+
不走 _git():那里固定 text=True + 默认编码,会按 locale 解码并做换行归一,
|
|
694
|
+
中文路径与非 UTF-8 locale 都会出事。取台账内容必须字节保真。
|
|
695
|
+
"""
|
|
696
|
+
try:
|
|
697
|
+
proc = subprocess.run(
|
|
698
|
+
list(args), capture_output=True, timeout=timeout, check=False
|
|
699
|
+
)
|
|
700
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
701
|
+
return None
|
|
702
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _split_z(out: bytes | None) -> list[str]:
|
|
706
|
+
"""按 NUL 切 -z 输出(路径原样,不被 core.quotepath 转义)。"""
|
|
707
|
+
if not out:
|
|
708
|
+
return []
|
|
709
|
+
return [s.decode("utf-8", "surrogateescape") for s in out.split(b"\0") if s]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _remote_exists(root: str, upstream: str, rel: str) -> bool:
|
|
713
|
+
"""远端是否有这个文件。"""
|
|
714
|
+
return (
|
|
715
|
+
subprocess.run(
|
|
716
|
+
["git", "-C", root, "cat-file", "-e", f"{upstream}:{rel}"],
|
|
717
|
+
capture_output=True,
|
|
718
|
+
check=False,
|
|
719
|
+
).returncode
|
|
720
|
+
== 0
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _remote_dir_files(root: str, upstream: str, rel_dir: str) -> list[str]:
|
|
725
|
+
"""列远端目录下的文件(相对仓库根,原样路径)。远端没有该目录时返回空。"""
|
|
726
|
+
return _split_z(
|
|
727
|
+
_git_bytes(
|
|
728
|
+
"git",
|
|
729
|
+
"-C",
|
|
730
|
+
root,
|
|
731
|
+
"ls-tree",
|
|
732
|
+
"-r",
|
|
733
|
+
"-z",
|
|
734
|
+
"--name-only",
|
|
735
|
+
upstream,
|
|
736
|
+
"--",
|
|
737
|
+
rel_dir + "/",
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _git_midstate(git_root: str) -> str | None:
|
|
743
|
+
"""仓库是否停在 merge / rebase 等冲突中间态;是就返回人话名字。
|
|
744
|
+
|
|
745
|
+
中间态下工作树里的台账可能是冲突版本(带 `<<<<<<<` 标记),
|
|
746
|
+
写前同步与写后提交都会往这个现场里写,必须先让用户收尾。
|
|
747
|
+
"""
|
|
748
|
+
out = _git("git", "-C", git_root, "rev-parse", "--absolute-git-dir")
|
|
749
|
+
if out.returncode != 0:
|
|
750
|
+
return None
|
|
751
|
+
git_dir = Path(out.stdout.strip())
|
|
752
|
+
for marker, label in (
|
|
753
|
+
("MERGE_HEAD", "merge"),
|
|
754
|
+
("rebase-merge", "rebase"),
|
|
755
|
+
("rebase-apply", "rebase/am"),
|
|
756
|
+
("CHERRY_PICK_HEAD", "cherry-pick"),
|
|
757
|
+
("REVERT_HEAD", "revert"),
|
|
758
|
+
):
|
|
759
|
+
if (git_dir / marker).exists():
|
|
760
|
+
return label
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _presync_guard(
|
|
765
|
+
git_root: str, upstream: str, managed: list[str], remote_files: set[str]
|
|
766
|
+
) -> None:
|
|
767
|
+
"""取最新之前确认本地没有会被覆盖掉的东西;命中就中止,绝不静默覆盖。
|
|
768
|
+
|
|
769
|
+
三种命中:未推送的台账提交(取最新会把它们的内容回退掉)、已跟踪文件的
|
|
770
|
+
未提交改动、本地还没入库但远端已有同名文件。
|
|
771
|
+
"""
|
|
772
|
+
ahead = _git_bytes(
|
|
773
|
+
"git",
|
|
774
|
+
"-C",
|
|
775
|
+
git_root,
|
|
776
|
+
"rev-list",
|
|
777
|
+
"--count",
|
|
778
|
+
f"{upstream}..HEAD",
|
|
779
|
+
"--",
|
|
780
|
+
*managed,
|
|
781
|
+
)
|
|
782
|
+
count = (ahead or b"").decode("ascii", "replace").strip()
|
|
783
|
+
if count and count != "0":
|
|
784
|
+
listing = _git_bytes(
|
|
785
|
+
"git",
|
|
786
|
+
"-C",
|
|
787
|
+
git_root,
|
|
788
|
+
"log",
|
|
789
|
+
"--oneline",
|
|
790
|
+
f"{upstream}..HEAD",
|
|
791
|
+
"--",
|
|
792
|
+
*managed,
|
|
793
|
+
)
|
|
794
|
+
recent = (listing or b"").decode("utf-8", "replace").strip().splitlines()[:3]
|
|
795
|
+
detail = ("\n 未推送:" + ";".join(recent)) if recent else ""
|
|
796
|
+
raise Fail(
|
|
797
|
+
f"本地有 {count} 个台账提交还没推到 {upstream},写前取最新会把它们的内容"
|
|
798
|
+
f"回退掉,已中止本次操作。{detail}\n"
|
|
799
|
+
" 处理:有网络时先 `git push`;暂时离线可加 --no-sync 继续在本地写,"
|
|
800
|
+
"联网后再提交推送。"
|
|
801
|
+
)
|
|
802
|
+
dirty = _split_z(
|
|
803
|
+
_git_bytes(
|
|
804
|
+
"git", "-C", git_root, "diff", "--name-only", "-z", "HEAD", "--", *managed
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
if dirty:
|
|
808
|
+
raise Fail(
|
|
809
|
+
"本地台账有未提交改动,写前取最新会覆盖它,已中止本次操作:\n "
|
|
810
|
+
+ "\n ".join(dirty)
|
|
811
|
+
+ '\n 处理:先提交这份改动(`git add ledger/ && git commit -m "chore(ledger): ..."`)'
|
|
812
|
+
"再重跑;确认不要它可加 --no-sync。"
|
|
813
|
+
)
|
|
814
|
+
untracked = set(
|
|
815
|
+
_split_z(
|
|
816
|
+
_git_bytes(
|
|
817
|
+
"git",
|
|
818
|
+
"-C",
|
|
819
|
+
git_root,
|
|
820
|
+
"ls-files",
|
|
821
|
+
"--others",
|
|
822
|
+
"--exclude-standard",
|
|
823
|
+
"-z",
|
|
824
|
+
"--",
|
|
825
|
+
*managed,
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
)
|
|
829
|
+
clash = sorted(untracked & remote_files)
|
|
830
|
+
if clash:
|
|
831
|
+
raise Fail(
|
|
832
|
+
"这些台账文件本地还没入库、远端已有同名文件,写前取最新会覆盖它们,"
|
|
833
|
+
"已中止本次操作:\n "
|
|
834
|
+
+ "\n ".join(clash)
|
|
835
|
+
+ "\n 处理:先提交(或删掉)这些文件再重跑;确认不要远端版本可加 --no-sync。"
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _presync_fetch_file(
|
|
840
|
+
git_root: str, upstream: str, rel: str, *, track: bool = False
|
|
841
|
+
) -> bool:
|
|
842
|
+
"""把远端某个文件的当前内容落到工作树;内容相同就不写。返回是否真的写了。
|
|
843
|
+
|
|
844
|
+
用 blob 内容自己落盘,而不是 checkout / restore:只碰工作树、不动 index,
|
|
845
|
+
不需要 git ≥2.23,也不会因为一个路径失败而整批失败。
|
|
456
846
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
847
|
+
track=False(默认)用于随后还要改写的脚本独占文件;track=True 用于补下来的
|
|
848
|
+
worklog 新文件——它不是本次改动,但必须随本次提交入库(记进 PRESYNC_FILES),
|
|
849
|
+
否则会一直以未跟踪状态留在工作树里,把「有没有未提交改动」的判断弄脏。
|
|
850
|
+
"""
|
|
851
|
+
data = _git_bytes("git", "-C", git_root, "cat-file", "blob", f"{upstream}:{rel}")
|
|
852
|
+
if data is None:
|
|
853
|
+
return False
|
|
854
|
+
dest = Path(git_root) / rel
|
|
855
|
+
try:
|
|
856
|
+
if dest.is_file() and dest.read_bytes() == data:
|
|
857
|
+
return False
|
|
858
|
+
except OSError:
|
|
859
|
+
pass
|
|
860
|
+
try:
|
|
861
|
+
text = data.decode("utf-8")
|
|
862
|
+
except UnicodeDecodeError:
|
|
863
|
+
print(f"提示:{rel} 不是 UTF-8,已跳过写前刷新。", file=sys.stderr)
|
|
864
|
+
return False
|
|
865
|
+
# 覆盖前留底:命令随后失败时回滚,别让远端版本留在工作树里冒充用户的未提交改动
|
|
866
|
+
if dest not in PRESYNC_BACKUPS:
|
|
867
|
+
try:
|
|
868
|
+
PRESYNC_BACKUPS[dest] = dest.read_bytes() if dest.is_file() else None
|
|
869
|
+
except OSError:
|
|
870
|
+
PRESYNC_BACKUPS[dest] = None
|
|
871
|
+
try:
|
|
872
|
+
write_text_atomic(dest, text, track=False)
|
|
873
|
+
except Fail as exc:
|
|
874
|
+
print(f"提示:写前刷新 {rel} 失败({exc}),继续用本地版本。", file=sys.stderr)
|
|
875
|
+
return False
|
|
876
|
+
if track:
|
|
877
|
+
PRESYNC_FILES.append(dest)
|
|
878
|
+
return True
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def git_presync(action: str, args: argparse.Namespace) -> None:
|
|
882
|
+
"""写命令执行前把「这条命令真的要写的那几个文件」刷成远端最新。
|
|
883
|
+
|
|
884
|
+
范围只到本次写入目标:`ledger/progress.json`(每条写命令都写),以及 `close`
|
|
885
|
+
时结案时间所在的那一个月历史文件(`--finished` 补录旧月份就刷那个月)——不是
|
|
886
|
+
整个 history 目录。`ledger/worklog/` 的正文由人写,这里只补「远端有、本地缺」
|
|
887
|
+
的新文件,已有文件一个字都不碰。本地有会被覆盖掉的东西(未推送提交 / 未提交
|
|
888
|
+
改动 / 未入库且远端同名)就中止,不静默覆盖。
|
|
889
|
+
无 git / 无上游 / 远端尚无该文件 静默跳过;拉取失败只提示不中止。
|
|
460
890
|
"""
|
|
461
891
|
if os.environ.get("TASK_LEDGER_NO_SYNC"):
|
|
462
892
|
return
|
|
@@ -475,13 +905,40 @@ def git_presync(action: str, args: argparse.Namespace) -> None:
|
|
|
475
905
|
return
|
|
476
906
|
git_root = Path(top.stdout.strip())
|
|
477
907
|
try:
|
|
478
|
-
ledger_rel =
|
|
908
|
+
ledger_rel = ledger_dir.resolve().relative_to(git_root.resolve()).as_posix()
|
|
479
909
|
except ValueError:
|
|
480
910
|
return # ledger 不在 git 仓内
|
|
911
|
+
midstate = _git_midstate(str(git_root))
|
|
912
|
+
if midstate:
|
|
913
|
+
raise Fail(
|
|
914
|
+
f"仓库正处于 {midstate} 中间态(有没收尾的冲突),此刻做写前同步和写后提交"
|
|
915
|
+
"都会把台账写进冲突现场,已中止本次操作。\n"
|
|
916
|
+
" 处理:先把这次 merge / rebase 收尾 —— `git merge --continue`、"
|
|
917
|
+
"`git rebase --continue`,要放弃就 `--abort`;然后重跑台账命令。"
|
|
918
|
+
)
|
|
481
919
|
ups = _git("git", "-C", str(git_root), "rev-parse", "--abbrev-ref", "@{upstream}")
|
|
482
920
|
if ups.returncode != 0:
|
|
483
|
-
|
|
921
|
+
head = _git("git", "-C", str(git_root), "rev-parse", "--abbrev-ref", "HEAD")
|
|
922
|
+
branch = (head.stdout or "").strip()
|
|
923
|
+
if branch == "HEAD":
|
|
924
|
+
print(
|
|
925
|
+
"提示:当前是 detached HEAD,台账只写本地文件并提交到当前 HEAD,不会推送。",
|
|
926
|
+
file=sys.stderr,
|
|
927
|
+
)
|
|
928
|
+
else:
|
|
929
|
+
print(
|
|
930
|
+
f"提示:分支 {branch or '(未知)'} 没有上游跟踪分支,"
|
|
931
|
+
"台账只提交到本地、不会推送。",
|
|
932
|
+
file=sys.stderr,
|
|
933
|
+
)
|
|
934
|
+
return
|
|
484
935
|
upstream = ups.stdout.strip()
|
|
936
|
+
if "/" not in upstream:
|
|
937
|
+
print(
|
|
938
|
+
f"提示:上游 {upstream} 是本地分支而不是远端分支,无法取最新,已跳过写前同步。",
|
|
939
|
+
file=sys.stderr,
|
|
940
|
+
)
|
|
941
|
+
return
|
|
485
942
|
remote = upstream.split("/", 1)[0]
|
|
486
943
|
try:
|
|
487
944
|
_git("git", "-C", str(git_root), "fetch", remote, timeout=30, check=True)
|
|
@@ -492,30 +949,84 @@ def git_presync(action: str, args: argparse.Namespace) -> None:
|
|
|
492
949
|
file=sys.stderr,
|
|
493
950
|
)
|
|
494
951
|
return
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
952
|
+
|
|
953
|
+
# 本地没有未推送提交时先快进到远端(等价 `pull --ff-only`)。
|
|
954
|
+
# 只覆盖工作树会留个后患:本次写入提交会把「远端新增的内容」当成自己的改动一起提交,
|
|
955
|
+
# 推送被拒后 rebase 与远端那次提交撞车(同一段内容被重放两次)—— 实测必冲突。
|
|
956
|
+
# 有未推送提交就跳过:那是用户自己的时间线,脚本不碰。
|
|
957
|
+
def _rev_count(spec: str) -> int:
|
|
958
|
+
raw = _git_bytes("git", "-C", str(git_root), "rev-list", "--count", spec)
|
|
959
|
+
try:
|
|
960
|
+
return int((raw or b"-1").decode("ascii", "replace").strip())
|
|
961
|
+
except ValueError:
|
|
962
|
+
return -1
|
|
963
|
+
|
|
964
|
+
ahead_n = _rev_count(f"{upstream}..HEAD")
|
|
965
|
+
behind_n = _rev_count(f"HEAD..{upstream}")
|
|
966
|
+
if ahead_n == 0 and behind_n > 0:
|
|
967
|
+
ff = _git(
|
|
968
|
+
"git", "-C", str(git_root), "merge", "--ff-only", "-q", upstream, timeout=30
|
|
969
|
+
)
|
|
970
|
+
if ff.returncode == 0:
|
|
971
|
+
print(
|
|
972
|
+
f"提示:写前把本地快进到 {upstream}({behind_n} 个提交,仅 fast-forward)。",
|
|
973
|
+
file=sys.stderr,
|
|
974
|
+
)
|
|
975
|
+
else:
|
|
976
|
+
# 不中止:工作区有未提交改动时 ff 必然失败,那是常态,后面还有脏检查兜底。
|
|
977
|
+
# 但必须说出来,否则「为什么没快进」无从查起。
|
|
978
|
+
print(
|
|
979
|
+
f"提示:写前快进到 {upstream} 没成功"
|
|
980
|
+
f"({_why(ff.stderr or ff.stdout, f'退出码 {ff.returncode}')}),"
|
|
981
|
+
"改为只刷新本次要写的台账文件。",
|
|
982
|
+
file=sys.stderr,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
root = str(git_root)
|
|
986
|
+
progress_rel = f"{ledger_rel}/{PROGRESS_REL.name}"
|
|
987
|
+
worklog_rel = f"{ledger_rel}/{WORKLOG_DIR.name}"
|
|
988
|
+
# 只刷「这条命令真的要写」的文件:progress.json 人人都写;
|
|
989
|
+
# 历史层只有 close 写,而且只写结案时间所在的那一个月(--finished 可能补录旧月份)。
|
|
990
|
+
managed = [progress_rel]
|
|
991
|
+
if action == "close":
|
|
992
|
+
finished = ""
|
|
993
|
+
raw_finished = getattr(args, "finished", None)
|
|
994
|
+
if raw_finished:
|
|
995
|
+
try:
|
|
996
|
+
finished = normalize_time(raw_finished, field="--finished")
|
|
997
|
+
except Fail:
|
|
998
|
+
finished = "" # 参数不合法交给 close 自己报错,这里按当前月兜底
|
|
999
|
+
target = (
|
|
1000
|
+
history_path_for(repo, finished)
|
|
1001
|
+
if finished
|
|
1002
|
+
else history_path_for(repo, now())
|
|
1003
|
+
)
|
|
1004
|
+
try:
|
|
1005
|
+
managed.append(target.resolve().relative_to(git_root.resolve()).as_posix())
|
|
1006
|
+
except ValueError:
|
|
1007
|
+
pass # 历史文件不在仓内:跳过(写的时候自有报错)
|
|
1008
|
+
|
|
1009
|
+
remote_files = {rel for rel in managed if _remote_exists(root, upstream, rel)}
|
|
1010
|
+
|
|
1011
|
+
_presync_guard(root, upstream, managed, remote_files)
|
|
1012
|
+
|
|
1013
|
+
for rel in remote_files:
|
|
1014
|
+
_presync_fetch_file(root, upstream, rel)
|
|
1015
|
+
|
|
1016
|
+
# worklog:只补本地缺的新文件,已有文件(正文由人写)一个字都不碰
|
|
1017
|
+
added = 0
|
|
1018
|
+
for rel in _remote_dir_files(root, upstream, worklog_rel):
|
|
1019
|
+
if (git_root / rel).exists():
|
|
1020
|
+
continue
|
|
1021
|
+
# track=True:补下来的新文件要随本次提交入库,否则它会一直以未跟踪状态挡在
|
|
1022
|
+
# 工作树里(后续 rebase 直接被它挡住)
|
|
1023
|
+
if _presync_fetch_file(root, upstream, rel, track=True):
|
|
1024
|
+
added += 1
|
|
1025
|
+
if added:
|
|
498
1026
|
print(
|
|
499
|
-
"
|
|
1027
|
+
f"提示:从 {upstream} 补下 {added} 个本地缺的 worklog 文件(没有动已有正文)。",
|
|
500
1028
|
file=sys.stderr,
|
|
501
1029
|
)
|
|
502
|
-
# 只取远端 ledger/ 到工作树;该路径在远端不存在(全新台账)时忽略
|
|
503
|
-
try:
|
|
504
|
-
_git(
|
|
505
|
-
"git",
|
|
506
|
-
"-C",
|
|
507
|
-
str(git_root),
|
|
508
|
-
"checkout",
|
|
509
|
-
upstream,
|
|
510
|
-
"--",
|
|
511
|
-
ledger_rel,
|
|
512
|
-
timeout=30,
|
|
513
|
-
check=True,
|
|
514
|
-
)
|
|
515
|
-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
516
|
-
pass
|
|
517
|
-
# 取消预暂存:只更新工作树,避免把他人改动混进本次提交(git_sync 只 add 本次改动的文件)
|
|
518
|
-
_git("git", "-C", str(git_root), "reset", "--", ledger_rel)
|
|
519
1030
|
|
|
520
1031
|
|
|
521
1032
|
def require_task_id(tid: str) -> str:
|
|
@@ -811,7 +1322,7 @@ def created_for_history(log_cell: str, history_path: Path) -> str | None:
|
|
|
811
1322
|
if not rel:
|
|
812
1323
|
return None
|
|
813
1324
|
try:
|
|
814
|
-
text = (history_path.parent / rel).read_text(encoding="utf-8")
|
|
1325
|
+
text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
|
|
815
1326
|
except (OSError, UnicodeDecodeError):
|
|
816
1327
|
return None
|
|
817
1328
|
for ln in text.splitlines():
|
|
@@ -836,7 +1347,7 @@ def creator_for_history(log_cell: str | None, history_path: Path) -> str | None:
|
|
|
836
1347
|
if not rel:
|
|
837
1348
|
return None
|
|
838
1349
|
try:
|
|
839
|
-
text = (history_path.parent / rel).read_text(encoding="utf-8")
|
|
1350
|
+
text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
|
|
840
1351
|
except (OSError, UnicodeDecodeError):
|
|
841
1352
|
return None
|
|
842
1353
|
for ln in text.splitlines():
|
|
@@ -861,7 +1372,7 @@ def labels_for_log(
|
|
|
861
1372
|
if not rel:
|
|
862
1373
|
return (None, [])
|
|
863
1374
|
try:
|
|
864
|
-
text = (history_path.parent / rel).read_text(encoding="utf-8")
|
|
1375
|
+
text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
|
|
865
1376
|
except (OSError, UnicodeDecodeError):
|
|
866
1377
|
return (None, [])
|
|
867
1378
|
return parse_label_line(text)
|
|
@@ -1334,7 +1845,8 @@ def append_exec_log(text: str, line: str) -> str:
|
|
|
1334
1845
|
lines = text.splitlines()
|
|
1335
1846
|
at = next((i for i, ln in enumerate(lines) if ln.strip() == EXEC_HEAD), None)
|
|
1336
1847
|
if at is None:
|
|
1337
|
-
|
|
1848
|
+
# 只去文末多余空行:正文最后一行的行尾空格是人家写的,别顺手抹掉
|
|
1849
|
+
return text.rstrip("\n") + f"\n\n{EXEC_HEAD}\n\n{EXEC_HINT}\n\n{line}\n"
|
|
1338
1850
|
end = next(
|
|
1339
1851
|
(i for i in range(at + 1, len(lines)) if lines[i].startswith("## ")), len(lines)
|
|
1340
1852
|
)
|
|
@@ -1374,6 +1886,36 @@ def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
|
|
|
1374
1886
|
return log
|
|
1375
1887
|
|
|
1376
1888
|
|
|
1889
|
+
def worklog_is_skeleton(repo: Path, tid: str, task: dict[str, Any]) -> bool:
|
|
1890
|
+
"""正文是否还是模板骨架(「执行记录」之前只剩标题、引用行和注释占位)。
|
|
1891
|
+
|
|
1892
|
+
读不到文件返回 False(那是别处要报的错,不在这里重复)。只做体检、不改盘。
|
|
1893
|
+
"""
|
|
1894
|
+
log = find_worklog(repo, tid, task)
|
|
1895
|
+
if log is None:
|
|
1896
|
+
return False
|
|
1897
|
+
try:
|
|
1898
|
+
text = log.read_text(encoding="utf-8")
|
|
1899
|
+
except (OSError, UnicodeDecodeError):
|
|
1900
|
+
return False
|
|
1901
|
+
in_comment = False
|
|
1902
|
+
for line in text.splitlines():
|
|
1903
|
+
s = line.strip()
|
|
1904
|
+
if s == EXEC_HEAD:
|
|
1905
|
+
break
|
|
1906
|
+
if in_comment:
|
|
1907
|
+
if "-->" in s:
|
|
1908
|
+
in_comment = False
|
|
1909
|
+
continue
|
|
1910
|
+
if s.startswith("<!--"):
|
|
1911
|
+
in_comment = "-->" not in s
|
|
1912
|
+
continue
|
|
1913
|
+
if not s or s.startswith(("#", ">")):
|
|
1914
|
+
continue
|
|
1915
|
+
return False
|
|
1916
|
+
return True
|
|
1917
|
+
|
|
1918
|
+
|
|
1377
1919
|
def print_worklog(repo: Path, tid: str, task: dict[str, Any] | None = None) -> None:
|
|
1378
1920
|
"""把该任务的过程文档全文打出来;读不到只提示,不拖垮主操作。"""
|
|
1379
1921
|
log = find_worklog(repo, tid, task)
|
|
@@ -1470,6 +2012,8 @@ def cmd_new(args: argparse.Namespace) -> None:
|
|
|
1470
2012
|
write_json(path, data)
|
|
1471
2013
|
except Fail:
|
|
1472
2014
|
pass
|
|
2015
|
+
# 撤销完成:别让写后自动同步把这个「有任务、没文档」的半成品提交上去
|
|
2016
|
+
CHANGED_FILES.clear()
|
|
1473
2017
|
raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
|
|
1474
2018
|
record_exec(repo, tid, task, creator, "new", f"创建任务({task['status']})")
|
|
1475
2019
|
print(f"已创建任务 {tid}")
|
|
@@ -1638,9 +2182,17 @@ def cmd_close(args: argparse.Namespace) -> None:
|
|
|
1638
2182
|
|
|
1639
2183
|
owner = task.get("owner") or me
|
|
1640
2184
|
# 老文件留在平铺位置,新文件在按月子目录;找不到就按新规则推算目标路径。
|
|
1641
|
-
log_path = find_worklog(repo, tid, task)
|
|
1642
|
-
|
|
1643
|
-
|
|
2185
|
+
log_path = find_worklog(repo, tid, task)
|
|
2186
|
+
if log_path is None and task.get("worklog"):
|
|
2187
|
+
# 记过路径却找不到文件:绝不能按模板新建一份空文档封卷 —— 那会把真正的
|
|
2188
|
+
# 正文永久顶掉(封卷后不再改)。让调用方先把文件恢复回来。
|
|
2189
|
+
raise Fail(
|
|
2190
|
+
f"任务 {tid} 的过程文档找不到:ledger/{task['worklog']}。\n"
|
|
2191
|
+
"不要新建同名空文档(会把正文顶掉)。先从 git 恢复再结案:\n"
|
|
2192
|
+
f" git checkout HEAD -- ledger/{task['worklog']}"
|
|
2193
|
+
)
|
|
2194
|
+
if log_path is None:
|
|
2195
|
+
log_path = repo / worklog_dir_for(task) / worklog_name(tid)
|
|
1644
2196
|
# 历史行按结案时间落月文件;文档链接相对该文件所在目录算(../worklog/…)。
|
|
1645
2197
|
history_path = history_path_for(repo, finished)
|
|
1646
2198
|
log_rel = worklog_link(log_path, history_path)
|
|
@@ -1995,6 +2547,7 @@ def cmd_validate(args: argparse.Namespace) -> None:
|
|
|
1995
2547
|
repo = find_repo(args.repo)
|
|
1996
2548
|
progress_path = repo / PROGRESS_REL
|
|
1997
2549
|
problems: list[str] = []
|
|
2550
|
+
blank: list[str] = []
|
|
1998
2551
|
|
|
1999
2552
|
if not progress_path.exists():
|
|
2000
2553
|
raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
|
|
@@ -2038,6 +2591,18 @@ def cmd_validate(args: argparse.Namespace) -> None:
|
|
|
2038
2591
|
problems.append(
|
|
2039
2592
|
f"{tid}: 缺 worklog 文件(状态表记的路径:{task.get('worklog') or '未记'})"
|
|
2040
2593
|
)
|
|
2594
|
+
elif worklog_is_skeleton(repo, tid, task):
|
|
2595
|
+
blank.append(tid)
|
|
2596
|
+
|
|
2597
|
+
if blank:
|
|
2598
|
+
# 只提示、不算问题:正文写没写是人的事,脚本不替他下判断;
|
|
2599
|
+
# 但「骨架」通常意味着忘了写、或曾被写前同步覆盖,给一条可见信号。
|
|
2600
|
+
print(
|
|
2601
|
+
f"提示:{len(blank)} 个在办任务的 worklog 正文还是模板骨架"
|
|
2602
|
+
f"(背景/待办/下一步/过程/结论都是注释占位):{'、'.join(blank)}\n"
|
|
2603
|
+
' 补写正文后跑一次 log --id <任务号> --note "…"。',
|
|
2604
|
+
file=sys.stderr,
|
|
2605
|
+
)
|
|
2041
2606
|
|
|
2042
2607
|
if problems:
|
|
2043
2608
|
for p in problems:
|
|
@@ -2242,7 +2807,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2242
2807
|
parser.add_argument(
|
|
2243
2808
|
"--no-sync",
|
|
2244
2809
|
action="store_true",
|
|
2245
|
-
help="
|
|
2810
|
+
help="跳过写前取最新与写后自动 git 提交 / 推送(等价于 TASK_LEDGER_NO_SYNC=1)",
|
|
2246
2811
|
)
|
|
2247
2812
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
2248
2813
|
|
|
@@ -2374,30 +2939,70 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2374
2939
|
return parser
|
|
2375
2940
|
|
|
2376
2941
|
|
|
2942
|
+
def _repo_lock_dir(args: argparse.Namespace) -> Path | None:
|
|
2943
|
+
"""写命令要仓级串行时返回 git 目录;只读 / 纯读 tags / --no-sync / 无 git 返回 None。"""
|
|
2944
|
+
if os.environ.get("TASK_LEDGER_NO_SYNC"):
|
|
2945
|
+
return None
|
|
2946
|
+
action = getattr(args, "command", "")
|
|
2947
|
+
if action in READ_ONLY_COMMANDS:
|
|
2948
|
+
return None
|
|
2949
|
+
if action == "tags" and not getattr(args, "rename", None):
|
|
2950
|
+
return None
|
|
2951
|
+
try:
|
|
2952
|
+
repo = find_repo(getattr(args, "repo", None))
|
|
2953
|
+
except Fail:
|
|
2954
|
+
return None
|
|
2955
|
+
out = _git("git", "-C", str(repo), "rev-parse", "--absolute-git-dir")
|
|
2956
|
+
if out.returncode != 0:
|
|
2957
|
+
return None
|
|
2958
|
+
return Path(out.stdout.strip())
|
|
2959
|
+
|
|
2960
|
+
|
|
2377
2961
|
def main(argv: list[str] | None = None) -> int:
|
|
2378
2962
|
args = build_parser().parse_args(argv)
|
|
2379
2963
|
if getattr(args, "no_sync", False):
|
|
2380
2964
|
os.environ["TASK_LEDGER_NO_SYNC"] = "1"
|
|
2965
|
+
lock_dir = _repo_lock_dir(args)
|
|
2966
|
+
lock = (
|
|
2967
|
+
repo_sync_lock(lock_dir) if lock_dir is not None else contextlib.nullcontext()
|
|
2968
|
+
)
|
|
2969
|
+
try:
|
|
2970
|
+
# 整段持锁:写前同步的「检查 → 覆盖」与随后的写入、提交推送必须是同一段临界区
|
|
2971
|
+
with lock:
|
|
2972
|
+
return _run_once(args)
|
|
2973
|
+
except Fail as exc: # 这里只可能是等锁超时
|
|
2974
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
2975
|
+
return 1
|
|
2976
|
+
|
|
2977
|
+
|
|
2978
|
+
def _run_once(args: argparse.Namespace) -> int:
|
|
2979
|
+
"""一条命令的完整生命周期:写前同步 → 命令本身 → 写后提交推送。"""
|
|
2381
2980
|
try:
|
|
2382
2981
|
git_presync(args.command, args) # 写前刷新 ledger/ 到远端最新
|
|
2383
2982
|
args.func(args)
|
|
2384
2983
|
except Fail as exc:
|
|
2984
|
+
# 命令没成功:把写前刷新覆盖过的文件恢复原样,别让远端版本冒充用户改动
|
|
2985
|
+
presync_rollback()
|
|
2385
2986
|
print(f"错误:{exc}", file=sys.stderr)
|
|
2386
2987
|
return 1
|
|
2387
2988
|
except KeyboardInterrupt:
|
|
2989
|
+
presync_rollback()
|
|
2388
2990
|
print("已中断。", file=sys.stderr)
|
|
2389
2991
|
return 130
|
|
2390
2992
|
except BrokenPipeError:
|
|
2391
|
-
# 输出被下游截断(如 | head
|
|
2993
|
+
# 输出被下游截断(如 | head):命令本身已经写成功,不回滚
|
|
2392
2994
|
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
2393
2995
|
os.dup2(devnull, sys.stdout.fileno())
|
|
2394
2996
|
return 0
|
|
2395
2997
|
except OSError as exc:
|
|
2998
|
+
presync_rollback()
|
|
2396
2999
|
print(f"错误:{exc}", file=sys.stderr)
|
|
2397
3000
|
return 1
|
|
2398
3001
|
except Exception as exc: # noqa: BLE001 —— 兜底:未预期的失败用独立退出码,与业务错误区分
|
|
3002
|
+
presync_rollback()
|
|
2399
3003
|
print(f"错误:未预期的失败 {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
2400
3004
|
return 3
|
|
3005
|
+
PRESYNC_BACKUPS.clear() # 写成功:留底作废
|
|
2401
3006
|
git_sync(args.command) # 写成功后一次性自动同步(commit / push)
|
|
2402
3007
|
return 0
|
|
2403
3008
|
|