task-ledger 1.2.9 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,11 +120,13 @@
120
120
  - 冲突时**报错要求重跑**,不静默覆盖
121
121
  - 写入成功后脚本会**自动同步**:把本次改动的台账文件 `git add <精确文件>` + `commit`(消息 `chore(ledger): <命令> <提交者>`,即 conventional commits 格式,多数仓库装了 commitlint 也认);当前分支有上游跟踪分支时再 `push`。全程确定性脚本,不依赖大模型
122
122
  - 仓库若装了自定义提交检查并拒了这条消息,脚本会**再用 `--no-verify` 提交一次**(台账是数据文件,不过 lint);仍失败则把 git 的报错打到 stderr 并要求手动提交 —— **同步失败绝不静默**,但退出码保持 0(台账本身已写成功)
123
- - 写前会**定向取最新**:只刷新 `ledger/progress.json` 与 `ledger/history/`(这两个只由脚本写);`ledger/worklog/` 里已有的正文一个字都不动,只在本地缺文件时补下远端的新文件(补下的文件随本次提交入库)。本地有未推送的台账提交、未提交改动、或未入库的同名文件时**中止**,不静默覆盖;命令随后失败时,写前刷下来的内容会回滚原样
124
- - 本地**没有未推送提交**时,写前会先 fast-forward 到上游(只快进、不产生合并提交);有未推送提交就跳过,退回上面那条的定向刷新
123
+ - 写前会**定向取最新**:只刷新 `ledger/progress.json` 与 `ledger/history/`(这两个只由脚本写);`ledger/worklog/` 里已有的正文一个字都不动,只在本地缺文件时补下远端的新文件(补下的文件随本次提交入库)。有未提交的台账改动、或未入库的同名文件时**中止**,不静默覆盖;命令随后失败时,写前刷下来的内容会回滚原样
124
+ - 本地有未推送的台账提交时,写前会**先自动补推**(非快进就 `rebase` 一次再推),成功后照常写 —— 离线攒下的那几笔,联网后第一笔写就会自己推上去
125
+ - 本地**没有未推送提交**时,写前先 fast-forward 到上游(只快进、不产生合并提交)
125
126
  - 同一个 clone 里的写命令**排队**执行(仓级文件锁,最多等 60 秒),避免两个 agent 同时写时,一个把另一个刚写完还没提交的改动静默覆盖
126
- - 写后推送被拒时,工作树干净才自动 `rebase` 一次再推(各自改各自的任务时按行自动合并);冲突立刻 `rebase --abort`,绝不 `--force`、绝不留 rebase 中间态
127
- - 无 git 仓库 / 无上游跟踪 / 被 `.gitignore` 排除 / 无实际变更 都会静默跳过;`--no-sync` 或环境变量 `TASK_LEDGER_NO_SYNC=1` 可同时关掉写前取最新与写后同步
127
+ - 写后推送被拒时,工作树干净才自动 `rebase` 一次再推(各自改各自的任务时按行自动合并);冲突立刻 `rebase --abort`,绝不 `--force`、绝不留 rebase 中间态。**本地有用户自己的(非台账)未推送提交时不做 rebase**(那会改写它们的提交号),只提示并让用户自己合并;推送前还会说明「这次会一并推走你本地几个提交」
128
+ - 无 git 仓库 / 无上游跟踪 / 被 `.gitignore` 排除(含「台账先提交过、之后才加忽略规则」)/ 无实际变更 都会静默跳过;`--no-sync`(写在子命令前后都行)或环境变量 `TASK_LEDGER_NO_SYNC=1` 可同时关掉写前取最新与写后同步
129
+ - **环境要求 git ≥ 2.27**(自动合并依赖 `rebase --autostash` 的冲突回滚)。没有 git 的目录自动跳过:台账照写,不提交、不推送
128
130
 
129
131
  ### 结案是原子操作
130
132
 
package/SKILL.md CHANGED
@@ -116,9 +116,9 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
116
116
 
117
117
  非 0 退出就停手、原样报告,**不要绕过脚本手改 `progress.json` 或历史层**。
118
118
  **退出码 0 不等于没事**:写命令之后的同步(commit / push)失败仍返回 0,原因只写在输出里。写命令跑完必看输出,出现 `提示:` / `错误:` / `警告:` 就先用一句话说清「发生了什么 + 你要做什么」,再附原文关键行,按 `references/troubleshooting.md` 对应条目给步骤;纯告知类(如已用 `--no-verify` 提交)一句话带过。只读命令只有 `错误:` 才需要转述。
119
- 并发冲突(文件被改动)重跑一次即可,不要用 `git pull` / `rebase` 去「解决」。
119
+ 并发冲突(文件被改动)重跑一次即可,**台账同步一律交给脚本**,不要自己用 `git pull` / `rebase` 去「解决」;只有提示明确说脚本已回滚、或说它不替你 rebase 时,才让用户手动 `git pull --rebase` / `git merge` 后 `git push`,推完再重跑。
120
120
  写前会**定向取最新**:只刷新 `ledger/progress.json` 与 `ledger/history/`,`worklog/` 里已有的正文一个字都不动;
121
- 本地有未推送的台账提交、未提交改动或未入库的同名文件时会中止 —— 按提示先提交 / 推送,或加 `--no-sync` 继续本地写。
121
+ 有未推送的台账提交时**先自动补推再写**,不要让用户先去 push。只有补推失败、或本地有用户自己的非台账提交(脚本不替他推业务提交)时才中止;有未提交的台账改动、未入库的同名文件,同样中止 —— 按提示处理,或加 `--no-sync`(写在子命令前后都行)继续本地写。
122
122
  同一仓库里写命令**排队**执行(最多等 60 秒),并发时不要重试轰炸,等前一个跑完再重跑。
123
123
 
124
124
  细节见 `references/troubleshooting.md`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-ledger",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
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,6 +1,6 @@
1
1
  # 排错
2
2
 
3
- > TL;DR:非 0 退出就停手、原样报告;**退出码 0 但输出里有 `提示:` / `错误:` 同样要读并转述**(同步失败仍返回 0)。别绕过脚本改 JSON。并发冲突重跑一次,不要用 `git pull` / `rebase` 去「解决」。
3
+ > TL;DR:非 0 退出就停手、原样报告;**退出码 0 但输出里有 `提示:` / `错误:` 同样要读并转述**(同步失败仍返回 0)。别绕过脚本改 JSON。并发冲突重跑一次;台账同步交给脚本,只有提示说脚本已回滚、或说它拒绝 rebase 时,才让用户手动 `git pull --rebase` / `git merge`。
4
4
 
5
5
  ## 退出码
6
6
 
@@ -19,13 +19,16 @@
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` 继续本地写。
22
+ - **「本地台账有未提交改动,写前取最新会覆盖它」**:写前只取 `progress.json` / `history/` 的最新版,本地这两处脏就先停下。先提交(`git add ledger/ && git commit -m "chore(ledger): ..."`)再重跑;暂时离线可加 `--no-sync`(写在子命令前后都行)继续本地写。
23
23
  - **「另一个会话正在同步同一份台账(已等 60 秒)」**:同一仓库里另一个 agent 正在写并推送,锁没抢到。等它跑完再重跑即可,不要并行重试(会把排队队列越堆越长)。
24
- - **「本地有 N 个台账提交还没推到 …」**:上次推送没成功(离线 / 他人先推 / 认证失败)。先 `git push` —— 推台账时脚本自己会 fetch + rebase 重试一次,被拒就按它打出的提示手动合并,**不要自己 `git pull` / `git rebase` 台账目录**(会把写前同步的护栏绕过去)。推完重跑;离线场景用 `--no-sync`,联网后统一提交推送。
24
+ - **「本地有 N 个台账提交还没推到 …」**:写前本来会自动补推,看到这句说明**补推失败**了(多半是本地还有你自己的提交,脚本不替你 rebase)。先 `git push`(非快进就 `git pull --rebase` / `git merge` 后再推),推完重跑;离线场景用 `--no-sync`(写在子命令前后都行),联网后统一提交推送。
25
+ - **「本地有 N 个你自己的提交还没推送 … 脚本不替你 rebase」**:推送被拒,且本地有非台账路径的提交。脚本故意不动它们(rebase 会改写提交号),**不要重跑台账命令**(会二次写入)。让用户自己 `git pull --rebase` / `git merge` 后 `git push`;台账改动已安全留在本地,推完重跑即可。
26
+ - **「没能确认本地有没有你自己的提交(git 数不出来)」**:旧版 git / 浅克隆下 `rev-list` 失败,脚本按「可能有」保守处理,不推也不 rebase。让用户 `git status` / `git log @{u}..HEAD` 自己看,处理完再重跑。
27
+ - **「写前把本地 N 个未推送的提交推上去了」/「本次推送会把本地 N 个你自己的提交一并推上去」**:纯告知(脚本已推或即将推),一句话带过「这次顺带把你本地 N 个提交推上去了」,不要回滚、不要重推。
25
28
  - **「本地已提交,但推送被拒」**:改动已安全落盘,只是远端没收,**不要重跑台账命令**(会二次写入)。看提示里 `git:` 那行的原文:`! [rejected] ... (fetch first)` = 他人先推,脚本已自动合并过一次仍失败就手动合并台账文件;`Permission denied (publickey)` / `could not read Username` = 凭据问题,找用户修,不要反复重试。
26
29
  - **「自动 rebase 未成功(已回滚到 rebase 前)」**:同一条任务被两边同时改了。脚本已 abort,仓库是干净的;按提示里的冲突原文手动合并后 `git push`。
27
30
  - **「自动 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}` 取出。
31
+ - **「暂存了你的未提交改动」+ autostash 冲突**:你的未提交改动在 stash 里,没有丢;提示里点名的那几个文件此刻**带着 `<<<<<<<` 冲突标记**,git 不会拦你提交,别把它们当成品。解冲突后确认内容,再 `git stash drop`;只想要内容可 `git stash show -p stash@{0}` 取出。
29
32
  - **「git add 失败」/「自动提交失败」**:台账已写进本地文件,只是没进 git。按错误原话处理后手动 `git add <文件> && git commit -m "chore(ledger): ..."`。
30
33
  - **「没有上游跟踪分支」/「当前是 detached HEAD」**:台账照写、照提交到本地,但**不会推送**。让用户建上游(`git push -u origin <分支>`)或切回分支;离线时这样用,联网后统一推一次。
31
34
  - **「写前快进 … 没成功」**:通常是工作区里有未提交改动(git 按文件拦,不看行),脚本改走「只刷新本次要写的台账文件」。想快进就先提交或 stash 那些文件。
@@ -35,3 +38,8 @@
35
38
  ## 硬约束
36
39
 
37
40
  不要直接编辑 `ledger/progress.json`、`ledger/history/*.md`;不要另建 worklog;不要手写「执行记录」节。
41
+
42
+ ## 环境要求
43
+
44
+ - **git ≥ 2.27**:写前/写后的自动合并依赖 `rebase --autostash`,低于这个版本 autostash 冲突回滚不可靠。
45
+ - **没有 git 的目录自动跳过**:台账照常写入,不提交、不推送、不报错。
@@ -490,26 +490,57 @@ def _why(raw: str | None, fallback: str) -> str:
490
490
  return " / ".join(lines[-3:]) if lines else fallback
491
491
 
492
492
 
493
- def _foreign_commits(g: tuple[str, ...], upstream: str) -> tuple[int, list[str]]:
494
- """本地领先 upstream、但不在 `ledger/` 里的提交(用户自己的代码):返回 (数量, 前几条摘要)。
493
+ def _ledger_rel(files: list[Path], root: str) -> str:
494
+ """台账目录相对 git 根的路径:monorepo 子目录时是 `svc/api/ledger`,不是硬编码的 `ledger`。"""
495
+ for f in files:
496
+ for cand in (f.parent, *f.parents):
497
+ if (cand / PROGRESS_REL.name).is_file():
498
+ try:
499
+ return cand.resolve().relative_to(Path(root).resolve()).as_posix()
500
+ except ValueError:
501
+ break
502
+ return PROGRESS_REL.parent.as_posix()
503
+
504
+
505
+ def _push(
506
+ g: tuple[str, ...], upstream: str, *, check: bool = False
507
+ ) -> subprocess.CompletedProcess:
508
+ """显式 refspec:本地分支与上游分支不同名(main ↔ master)时,裸 `git push` 直接 fatal。"""
509
+ remote, _, branch = upstream.partition("/")
510
+ refspec = f"HEAD:{branch}" if branch else "HEAD"
511
+ return _git(*g, "push", remote, refspec, timeout=30, check=check)
495
512
 
496
- git 按路径过滤,不看提交消息 —— 用户把台账提交改写成别的 message 也不会被误判成「业务提交」。
513
+
514
+ def _foreign_commits(
515
+ g: tuple[str, ...], upstream: str, ledger_rel: str
516
+ ) -> tuple[int, list[str]]:
517
+ """本地领先 upstream、但不在台账目录里的提交(用户自己的代码):(数量, 前几条摘要)。
518
+
519
+ 数量 -1 = 数不出来,调用方必须按「可能有」处理 —— 当成 0 就等于放行推送 / rebase,
520
+ 那正是脚本声称绝不做的事(fail-closed)。
497
521
  """
498
522
  spec = f"{upstream}..HEAD"
499
- raw = _git_bytes(*g, "rev-list", "--count", spec, "--", ".", ":(exclude)ledger")
523
+ exclude = f":(exclude){ledger_rel}"
524
+ raw = _git_bytes(*g, "rev-list", "--count", spec, "--", ".", exclude)
500
525
  try:
501
- count = int((raw or b"-1").decode("ascii", "replace").strip())
526
+ count = int((raw or b"").decode("ascii", "replace").strip())
502
527
  except ValueError:
503
- return 0, [] # 数不出来就当作没有:宁可少提示,不阻断台账写入
528
+ return -1, []
504
529
  if count <= 0:
505
- return 0, []
530
+ return count, []
506
531
  listed = _git_bytes(
507
- *g, "log", "--format=%h %s", spec, "--", ".", ":(exclude)ledger"
532
+ *g,
533
+ "log",
534
+ "--format=%h %s",
535
+ spec,
536
+ "--",
537
+ ".",
538
+ exclude,
508
539
  )
509
540
  return count, (listed or b"").decode("utf-8", "replace").strip().splitlines()[:3]
510
541
 
511
542
 
512
- def _rebase_retry(g: tuple[str, ...]) -> bool:
543
+ def _rebase_retry(g: tuple[str, ...], ledger_rel: str) -> bool:
513
544
  """推送被拒后自动 rebase 一次;只在工作树干净时做,失败立刻 abort。
514
545
 
515
546
  台账按任务分条存(progress.json 是按任务号 key 的对象、history 一行一条),
@@ -527,12 +558,16 @@ def _rebase_retry(g: tuple[str, ...]) -> bool:
527
558
  return False
528
559
  # 用户自己 commit 的代码不动:rebase 会重放整支分支,把他们的提交号全改写。
529
560
  # 只在自己那几条台账提交上做,才是「只碰台账」这条边界的完整实现。
530
- foreign, titles = _foreign_commits(g, "@{upstream}")
531
- if foreign > 0:
532
- detail = f":{';'.join(titles)}" if titles else ""
561
+ # foreign != 0:-1 表示数不出来,同样按「可能有」处理,宁可不动也不越权。
562
+ foreign, titles = _foreign_commits(g, "@{upstream}", ledger_rel)
563
+ if foreign != 0:
564
+ if foreign > 0:
565
+ detail = f":{';'.join(titles)}" if titles else ""
566
+ what = f"本地有 {foreign} 个你自己的提交还没推送{detail}"
567
+ else:
568
+ what = "没能确认本地有没有你自己的提交(git 数不出来)"
533
569
  print(
534
- f"提示:本地有 {foreign} 个你自己的提交还没推送{detail}。"
535
- "脚本不替你 rebase(那会改写它们的提交号),本次没有自动合并。\n"
570
+ f"提示:{what}。脚本不替你 rebase(那会改写提交号),本次没有自动合并。\n"
536
571
  " 处理:手动 `git pull --rebase` 或 `git merge` 后再 `git push`;"
537
572
  "台账改动已安全留在本地。",
538
573
  file=sys.stderr,
@@ -544,10 +579,29 @@ def _rebase_retry(g: tuple[str, ...]) -> bool:
544
579
  rebase = _git(*g, "rebase", "--autostash", "@{upstream}", timeout=30)
545
580
  if rebase.returncode == 0:
546
581
  combined = f"{rebase.stdout or ''}{rebase.stderr or ''}"
547
- if "autostash" in combined:
582
+ # 判「改动放回时冲突了」不能认 git 的文案:2.27+ 的措辞是
583
+ # 「Your local changes are stashed, however applying them resulted in conflicts」,
584
+ # 跟旧版的「Applying autostash resulted in conflicts」不是一回事 —— 认字符串时
585
+ # 实测 git 2.55 会漏判,把「工作树里带着冲突标记」说成「已放回」(最坏的一种错)。
586
+ # rebase 本身成功(returncode 0)就说明冲突不来自 rebase,只可能来自 autostash,
587
+ # 于是直接看 index 有没有未合并项。
588
+ conflicted = _split_z(
589
+ _git_bytes(*g, "diff", "--name-only", "--diff-filter=U", "-z")
590
+ )
591
+ if conflicted:
592
+ # 必须点名文件:此刻这些文件带 `<<<<<<<` 标记,不写清楚就会有人把冲突标记
593
+ # 当成品提交上去(git 不会替你报错,它只当普通改动)。
594
+ print(
595
+ "提示:rebase 成功,但把你的未提交改动放回工作树时冲突了 —— "
596
+ "这些文件现在带着 `<<<<<<<` 冲突标记,别当成品提交:\n "
597
+ + "\n ".join(conflicted)
598
+ + "\n 改动同时还在 stash 里(`git stash list`);解完冲突、确认内容没错"
599
+ "再 `git stash drop`。",
600
+ file=sys.stderr,
601
+ )
602
+ elif "autostash" in combined:
548
603
  print(
549
- "提示:自动合并前暂存了你的未提交改动,rebase 后已放回;"
550
- "若 git 提示 autostash 冲突,跑 `git stash list` 手动处理。",
604
+ "提示:自动合并前暂存了你的未提交改动,rebase 后已放回。",
551
605
  file=sys.stderr,
552
606
  )
553
607
  return True
@@ -597,11 +651,14 @@ def git_sync(action: str) -> None:
597
651
  return # 不是 git 项目 → 跳过(规则1:有才提交)
598
652
  root = top.stdout.strip()
599
653
  g = ("git", "-C", root)
600
- # 只 add 本次真实改动、且未被 ignore 的文件
654
+ # 只 add 本次真实改动、且未被 ignore 的文件。
655
+ # --no-index 不能省:忽略规则对已跟踪文件同样生效(先提交过台账、之后才把
656
+ # ledger/ 写进 .gitignore 就是这种情形),而默认的 check-ignore 只报未跟踪
657
+ # 文件、漏判后 git add 会撞「paths are ignored」并打一句「请手动提交」的瞎提示。
601
658
  to_add = [
602
659
  str(f)
603
660
  for f in files
604
- if _git(*g, "check-ignore", "-q", str(f)).returncode != 0
661
+ if _git(*g, "check-ignore", "--no-index", "-q", str(f)).returncode != 0
605
662
  ]
606
663
  if not to_add:
607
664
  return # 全被 ignore → 尊重用户选择,不强行提交
@@ -644,7 +701,9 @@ def git_sync(action: str) -> None:
644
701
  return
645
702
  # push 是分支级的:本地有用户自己 commit 但没推的代码,会一并送到远端。
646
703
  # 脚本不替用户下这个决定,但必须说出来,别让它无声发生。
647
- foreign, titles = _foreign_commits(g, upstream.stdout.strip())
704
+ up = upstream.stdout.strip()
705
+ ledger_rel = _ledger_rel(files, root)
706
+ foreign, titles = _foreign_commits(g, up, ledger_rel)
648
707
  if foreign > 0:
649
708
  detail = f":{';'.join(titles)}" if titles else ""
650
709
  print(
@@ -652,13 +711,13 @@ def git_sync(action: str) -> None:
652
711
  file=sys.stderr,
653
712
  )
654
713
  try:
655
- _git(*g, "push", timeout=30, check=True)
714
+ _push(g, up, check=True)
656
715
  except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as push_exc:
657
716
  # 非快进:只在台账工作树干净时自动 rebase 一次再推,冲突立刻 abort。
658
717
  # 各自改各自的任务时 git 按行自动合并;同一条任务冲突就干净失败,绝不 --force。
659
- if _rebase_retry(g):
718
+ if _rebase_retry(g, ledger_rel):
660
719
  try:
661
- _git(*g, "push", timeout=30, check=True)
720
+ _push(g, up, check=True)
662
721
  return
663
722
  except (
664
723
  subprocess.CalledProcessError,
@@ -762,43 +821,54 @@ def _git_midstate(git_root: str) -> str | None:
762
821
 
763
822
 
764
823
  def _presync_guard(
765
- git_root: str, upstream: str, managed: list[str], remote_files: set[str]
824
+ git_root: str,
825
+ upstream: str,
826
+ managed: list[str],
827
+ remote_files: set[str],
828
+ *,
829
+ check_ahead: bool = True,
766
830
  ) -> None:
767
831
  """取最新之前确认本地没有会被覆盖掉的东西;命中就中止,绝不静默覆盖。
768
832
 
769
833
  三种命中:未推送的台账提交(取最新会把它们的内容回退掉)、已跟踪文件的
770
834
  未提交改动、本地还没入库但远端已有同名文件。
835
+
836
+ check_ahead=False 用在自动补推之前:那时本地还领先,ahead 必然非 0;
837
+ 补推完成后再跑一次完整检查。
771
838
  """
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(
839
+ if check_ahead:
840
+ ahead = _git_bytes(
785
841
  "git",
786
842
  "-C",
787
843
  git_root,
788
- "log",
789
- "--oneline",
844
+ "rev-list",
845
+ "--count",
790
846
  f"{upstream}..HEAD",
791
847
  "--",
792
848
  *managed,
793
849
  )
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
- )
850
+ count = (ahead or b"").decode("ascii", "replace").strip()
851
+ if count and count != "0":
852
+ listing = _git_bytes(
853
+ "git",
854
+ "-C",
855
+ git_root,
856
+ "log",
857
+ "--oneline",
858
+ f"{upstream}..HEAD",
859
+ "--",
860
+ *managed,
861
+ )
862
+ recent = (
863
+ (listing or b"").decode("utf-8", "replace").strip().splitlines()[:3]
864
+ )
865
+ detail = ("\n 未推送:" + ";".join(recent)) if recent else ""
866
+ raise Fail(
867
+ f"本地有 {count} 个台账提交还没推到 {upstream},写前取最新会把它们的内容"
868
+ f"回退掉,已中止本次操作。{detail}\n"
869
+ " 处理:先 `git push` 补齐再重跑;暂时离线可加 --no-sync 继续在本地写,"
870
+ "联网后再提交推送。"
871
+ )
802
872
  dirty = _split_z(
803
873
  _git_bytes(
804
874
  "git", "-C", git_root, "diff", "--name-only", "-z", "HEAD", "--", *managed
@@ -982,28 +1052,6 @@ def git_presync(action: str, args: argparse.Namespace) -> None:
982
1052
  file=sys.stderr,
983
1053
  )
984
1054
 
985
- # 离线攒下的台账提交:联网后第一笔写先把它们推上去,别拿「有 N 个没推」挡住写入。
986
- # 用户自己的业务提交不碰:push 会把它们一并带走,那个决定归用户(见 _rebase_retry)。
987
- if ahead_n > 0:
988
- g_tuple = ("git", "-C", str(git_root))
989
- foreign_n, _ = _foreign_commits(g_tuple, upstream)
990
- if foreign_n == 0:
991
- pushed = _git(*g_tuple, "push", timeout=30)
992
- if pushed.returncode != 0 and _rebase_retry(g_tuple):
993
- pushed = _git(*g_tuple, "push", timeout=30)
994
- if pushed.returncode == 0:
995
- print(
996
- f"提示:写前把本地 {ahead_n} 个未推送的提交推上去了。",
997
- file=sys.stderr,
998
- )
999
- ahead_n = 0
1000
- else:
1001
- print(
1002
- "提示:写前推送失败"
1003
- f"({_why(pushed.stderr or pushed.stdout, f'退出码 {pushed.returncode}')})。",
1004
- file=sys.stderr,
1005
- )
1006
-
1007
1055
  root = str(git_root)
1008
1056
  progress_rel = f"{ledger_rel}/{PROGRESS_REL.name}"
1009
1057
  worklog_rel = f"{ledger_rel}/{WORKLOG_DIR.name}"
@@ -1030,6 +1078,35 @@ def git_presync(action: str, args: argparse.Namespace) -> None:
1030
1078
 
1031
1079
  remote_files = {rel for rel in managed if _remote_exists(root, upstream, rel)}
1032
1080
 
1081
+ # 先查本地有没有会被覆盖掉的东西(尤其未提交改动),必须排在自动补推之前:
1082
+ # 补推里的 rebase --autostash 会先把改动收进 stash,abort 后工作树看起来「干净」,
1083
+ # 后续覆盖就会把那一版改动弄丢。
1084
+ _presync_guard(root, upstream, managed, remote_files, check_ahead=False)
1085
+
1086
+ # 离线攒下的台账提交:联网后第一笔写先把它们推上去,别拿「有 N 个没推」挡住写入。
1087
+ # 用户自己的提交一并带走(push 是分支级的),但只提示、不 rebase —— 改写提交号才是越权。
1088
+ if ahead_n > 0:
1089
+ g_tuple = ("git", "-C", str(git_root))
1090
+ foreign_n, _ = _foreign_commits(g_tuple, upstream, ledger_rel)
1091
+ pushed = _push(g_tuple, upstream)
1092
+ if pushed.returncode != 0 and _rebase_retry(g_tuple, ledger_rel):
1093
+ pushed = _push(g_tuple, upstream)
1094
+ if pushed.returncode == 0:
1095
+ extra = f"(其中 {foreign_n} 个是你自己的提交)" if foreign_n > 0 else ""
1096
+ print(
1097
+ f"提示:写前把本地 {ahead_n} 个未推送的提交推上去了{extra}。",
1098
+ file=sys.stderr,
1099
+ )
1100
+ ahead_n = 0
1101
+ else:
1102
+ print(
1103
+ "提示:写前推送失败"
1104
+ f"({_why(pushed.stderr or pushed.stdout, f'退出码 {pushed.returncode}')})。",
1105
+ file=sys.stderr,
1106
+ )
1107
+
1108
+ # 补推后复查:补推失败(多半是本地有你自己的提交,脚本不替你 rebase)时,
1109
+ # 这里仍会把「台账提交领先」拦下来,避免写进过期数据。
1033
1110
  _presync_guard(root, upstream, managed, remote_files)
1034
1111
 
1035
1112
  for rel in remote_files:
@@ -2958,6 +3035,17 @@ def build_parser() -> argparse.ArgumentParser:
2958
3035
  add_repo(p)
2959
3036
  p.set_defaults(func=cmd_validate)
2960
3037
 
3038
+ # 每个子命令也认 --no-sync:人(和 agent)习惯把参数缀在子命令后面,
3039
+ # 只挂在主 parser 上时 `new --no-sync` 会直接 usage 报错、命令根本跑不起来。
3040
+ # default=SUPPRESS 是关键:否则解析子命令时会把主 parser 上已解析的 True 覆盖回 False。
3041
+ for sub_parser in sub.choices.values():
3042
+ sub_parser.add_argument(
3043
+ "--no-sync",
3044
+ action="store_true",
3045
+ default=argparse.SUPPRESS,
3046
+ help="跳过写前取最新与写后自动 git 提交 / 推送(等价于 TASK_LEDGER_NO_SYNC=1;写在子命令前后均可)",
3047
+ )
3048
+
2961
3049
  return parser
2962
3050
 
2963
3051