task-ledger 1.3.0 → 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.
- package/README.md +29 -247
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +97 -32
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +173 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +43 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3109
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""时间:本地墙钟 ↔ UTC 的换算、格式化、排序键。
|
|
2
|
+
|
|
3
|
+
进门规则:不读文件、不写文件、不打印;只依赖 errors。
|
|
4
|
+
所有**存盘**时间都是 UTC(`YYYY-MM-DD HH:MM:SS`,不带偏移量);
|
|
5
|
+
所有**人话显示**都过 `fmt_local`;`--json` 原样吐 UTC。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
from ..errors import Fail
|
|
14
|
+
|
|
15
|
+
_UTC = UTC
|
|
16
|
+
|
|
17
|
+
TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
|
|
18
|
+
_TIME_IN_RE = re.compile(
|
|
19
|
+
r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def now() -> str:
|
|
24
|
+
"""当前时刻(UTC,到秒)。"""
|
|
25
|
+
return datetime.now(_UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_time(
|
|
29
|
+
value: str | datetime,
|
|
30
|
+
*,
|
|
31
|
+
field: str = "时间",
|
|
32
|
+
tid: str | None = None,
|
|
33
|
+
date_only_ref: str | None = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""把「正确但不完整」的写法归一化到 `YYYY-MM-DD HH:MM:SS`。
|
|
36
|
+
|
|
37
|
+
接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
|
|
38
|
+
`date_only_ref`:只给了日期时用这个时刻顶替(`--until` 想含整天传 `23:59:59`)。
|
|
39
|
+
"""
|
|
40
|
+
where = f"{tid}: " if tid else ""
|
|
41
|
+
if isinstance(value, datetime):
|
|
42
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
43
|
+
if not isinstance(value, str) or not value.strip():
|
|
44
|
+
raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
|
|
45
|
+
s = " ".join(
|
|
46
|
+
value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
|
|
47
|
+
)
|
|
48
|
+
m = _TIME_IN_RE.fullmatch(s)
|
|
49
|
+
if not m:
|
|
50
|
+
raise Fail(
|
|
51
|
+
f"{where}{field} 时间格式不对:{value!r}"
|
|
52
|
+
"(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
|
|
53
|
+
)
|
|
54
|
+
y, mo, d, hh, mm, ss = m.groups()
|
|
55
|
+
if date_only_ref is not None and hh is None:
|
|
56
|
+
out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
|
|
57
|
+
else:
|
|
58
|
+
out = (
|
|
59
|
+
f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
|
|
60
|
+
f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})") from exc
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_utc(
|
|
70
|
+
value: str,
|
|
71
|
+
*,
|
|
72
|
+
field: str,
|
|
73
|
+
tid: str | None = None,
|
|
74
|
+
date_only_ref: str | None = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""用户按本地墙钟给的时间 → 存盘用的 UTC 串。只由外壳调用。"""
|
|
77
|
+
wall = normalize_time(value, field=field, tid=tid, date_only_ref=date_only_ref)
|
|
78
|
+
naive = datetime.strptime(wall, "%Y-%m-%d %H:%M:%S")
|
|
79
|
+
return naive.astimezone(_UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fmt_local(stamp: object) -> str:
|
|
83
|
+
"""存盘的 UTC → 查看者本地时间,只用于人看的输出。
|
|
84
|
+
|
|
85
|
+
存量老数据(2026-10-22 前写的是写入者本地时间、无标记)无法区分,
|
|
86
|
+
按 UTC 转会显示偏后若干小时 —— 这是已知例外,不在脚本里猜。
|
|
87
|
+
"""
|
|
88
|
+
key = time_key(stamp)
|
|
89
|
+
if key is None:
|
|
90
|
+
return "" if stamp is None else str(stamp)
|
|
91
|
+
try:
|
|
92
|
+
dt = datetime.strptime(key, "%Y-%m-%d %H:%M:%S").replace(tzinfo=_UTC)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return str(stamp)
|
|
95
|
+
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def time_key(value: object) -> str | None:
|
|
99
|
+
"""已存时间(可能只到分)→ 秒精度串,用于比较/排序;不合法返回 None。"""
|
|
100
|
+
if not isinstance(value, str) or not TIME_RE.fullmatch(value):
|
|
101
|
+
return None
|
|
102
|
+
return value if len(value) == 19 else value + ":00"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""过程文档的文本操作:给一段文字,返回新文字。不读文件、不写文件。
|
|
2
|
+
|
|
3
|
+
模板的**读取**归 `store/WorklogStore.template()`,这里只做字符串替换。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from itertools import takewhile
|
|
11
|
+
|
|
12
|
+
from ..errors import Fail
|
|
13
|
+
from .labels import label_line, parse_label_line
|
|
14
|
+
from .time import fmt_local, normalize_time
|
|
15
|
+
|
|
16
|
+
EXEC_HEAD = "## 执行记录"
|
|
17
|
+
EXEC_HINT = (
|
|
18
|
+
"<!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。\n"
|
|
19
|
+
' 补记用 progress.py log --id <任务号> --note "一句话"。 -->'
|
|
20
|
+
)
|
|
21
|
+
FROZEN_MARK = "✅ 完成封卷"
|
|
22
|
+
FROZEN_RE = re.compile(r"^>\s*" + FROZEN_MARK, re.MULTILINE)
|
|
23
|
+
CREATED_RE = re.compile(r"^\s*>\s*\*\*创建\*\*")
|
|
24
|
+
# 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)` —— 创建时间与发起人两个来源。
|
|
25
|
+
CREATED_AT_RE = re.compile(
|
|
26
|
+
r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
|
|
27
|
+
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
28
|
+
)
|
|
29
|
+
CREATOR_RE = re.compile(r"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
|
|
30
|
+
|
|
31
|
+
_HEAD_RE = re.compile(r"\A(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: 过程文档应有的六节,顺序即模板顺序(真源是 `assets/worklog.md`)。
|
|
35
|
+
WORKLOG_SECTIONS = ("背景", "待办", "下一步", "过程", "结论", "执行记录")
|
|
36
|
+
#: 判「格式坏」只要求前五节;「执行记录」由脚本维护,老文档没有、写入时会补,缺了只提示。
|
|
37
|
+
REQUIRED_SECTIONS = WORKLOG_SECTIONS[:5]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def section_heads(text: str) -> list[str]:
|
|
41
|
+
"""文档里的二级标题(`## 背景` → `背景`),按出现顺序。"""
|
|
42
|
+
return [
|
|
43
|
+
line[3:].strip() for line in (text or "").splitlines() if line.startswith("## ")
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _head_lines(lines: list[str]) -> list[str]:
|
|
48
|
+
"""第一个二级标题之前的行(头部引用块)。"""
|
|
49
|
+
return list(takewhile(lambda line: not line.startswith("## "), lines))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_problems(text: str) -> list[str]:
|
|
53
|
+
"""过程文档的**格式**问题:只看骨架结构,不看内容、不判业务。空列表 = 没问题。
|
|
54
|
+
|
|
55
|
+
五项:第一行是标题 / 头部「创建」行有时间 / 创建行有发起人 / 前五节齐全 / 前五节顺序对。
|
|
56
|
+
结案封卷、分类标签、正文写得怎么样都不在这里判(那是内容与业务)。
|
|
57
|
+
"""
|
|
58
|
+
problems: list[str] = []
|
|
59
|
+
body = (text or "").lstrip("\ufeff")
|
|
60
|
+
lines = body.splitlines()
|
|
61
|
+
if not lines or not lines[0].startswith("# "):
|
|
62
|
+
problems.append("第一行不是标题(应为 `# <任务号>:<标题>`)")
|
|
63
|
+
head = _head_lines(lines)
|
|
64
|
+
if not any(CREATED_AT_RE.match(line) for line in head):
|
|
65
|
+
problems.append(
|
|
66
|
+
"头部缺「创建」时间行(应为 `> **创建**:YYYY-MM-DD HH:MM(@某人)`)"
|
|
67
|
+
)
|
|
68
|
+
elif not any(CREATOR_RE.match(line) for line in head):
|
|
69
|
+
problems.append("头部「创建」行缺发起人(应为 `…(@某人)`)")
|
|
70
|
+
heads = section_heads(body)
|
|
71
|
+
missing = [s for s in REQUIRED_SECTIONS if s not in heads]
|
|
72
|
+
if missing:
|
|
73
|
+
problems.append("缺节:" + "、".join(f"`## {s}`" for s in missing))
|
|
74
|
+
else:
|
|
75
|
+
# 按「首次出现」去重再比顺序:老文档里同一节重复出现很常见(编辑残留),
|
|
76
|
+
# 那不影响读写(脚本按锚点定位),只作提示,不判错。
|
|
77
|
+
order: list[str] = []
|
|
78
|
+
for head in heads:
|
|
79
|
+
if head in REQUIRED_SECTIONS and head not in order:
|
|
80
|
+
order.append(head)
|
|
81
|
+
if order != list(REQUIRED_SECTIONS):
|
|
82
|
+
problems.append(
|
|
83
|
+
"节的顺序不对:应为 "
|
|
84
|
+
+ " → ".join(f"`## {s}`" for s in REQUIRED_SECTIONS)
|
|
85
|
+
)
|
|
86
|
+
return problems
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def duplicated_sections(text: str) -> list[str]:
|
|
90
|
+
"""重复出现的节标题(老文档编辑残留;不影响读写,只作提示)。"""
|
|
91
|
+
heads = section_heads(text)
|
|
92
|
+
return [s for s in REQUIRED_SECTIONS if heads.count(s) > 1]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def lacks_exec_section(text: str) -> bool:
|
|
96
|
+
"""缺「执行记录」节(老文档没有;只提示,脚本写入时会自动补上这一节)。"""
|
|
97
|
+
return WORKLOG_SECTIONS[-1] not in section_heads(text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_skeleton(text: str) -> bool:
|
|
101
|
+
"""是不是骨架(正文还没填):前五节里没有任何实质内容。"""
|
|
102
|
+
body = _sections_body(text)
|
|
103
|
+
if not body.strip():
|
|
104
|
+
return True
|
|
105
|
+
for line in body.splitlines():
|
|
106
|
+
stripped = line.strip()
|
|
107
|
+
if not stripped or stripped.startswith(("<!--", "-->", ">")):
|
|
108
|
+
continue
|
|
109
|
+
if stripped.startswith("#"):
|
|
110
|
+
continue
|
|
111
|
+
if stripped in {"-", "—"}:
|
|
112
|
+
continue
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _sections_body(text: str) -> str:
|
|
118
|
+
"""去掉「执行记录」节之后剩下的正文。"""
|
|
119
|
+
idx = text.find(f"\n{EXEC_HEAD}")
|
|
120
|
+
return text[:idx] if idx >= 0 else text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_worklog(
|
|
124
|
+
*,
|
|
125
|
+
tid: str,
|
|
126
|
+
title: str,
|
|
127
|
+
created: str,
|
|
128
|
+
creator: str,
|
|
129
|
+
category: str | None = None,
|
|
130
|
+
tags: Iterable[str] | None = None,
|
|
131
|
+
template: str,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>` 四个占位符)。"""
|
|
134
|
+
text = (
|
|
135
|
+
template.replace("<id>", tid)
|
|
136
|
+
.replace("<title>", title or "")
|
|
137
|
+
.replace("<created>", created or "")
|
|
138
|
+
.replace("<creator>", creator or "")
|
|
139
|
+
)
|
|
140
|
+
meta = label_line(category, tags)
|
|
141
|
+
if not meta:
|
|
142
|
+
return text
|
|
143
|
+
return "\n".join(_insert_after_head(text.splitlines(), meta)) + "\n"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _insert_after_head(lines: list[str], line: str) -> list[str]:
|
|
147
|
+
"""把一行插到「创建」行之后(头部引用块内);找不到创建行就插标题后。"""
|
|
148
|
+
at = next((i for i, ln in enumerate(lines) if CREATED_RE.match(ln)), None)
|
|
149
|
+
if at is None:
|
|
150
|
+
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
151
|
+
lines.insert(at + 1, line)
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
|
|
156
|
+
"""一条执行记录:`- 本地时刻 · 类型 · @某人 · 说明`。
|
|
157
|
+
|
|
158
|
+
时刻是**给人读的日志行**,所以在这里转成查看者本地时间(调用方一律传 UTC,
|
|
159
|
+
别在外面先转一遍)。类型取动作名(new / claim / set / release / log / close)。
|
|
160
|
+
"""
|
|
161
|
+
head = f"- {fmt_local(when)} · {kind}"
|
|
162
|
+
tail = f" · @{who} · {note}" if who else f" · {note}"
|
|
163
|
+
return head + tail
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def append_exec_log(text: str, line: str) -> str:
|
|
167
|
+
"""把一条记录追加到「执行记录」节末尾;老文档没有这一节就先补一节。"""
|
|
168
|
+
if f"\n{EXEC_HEAD}" in text or text.startswith(EXEC_HEAD):
|
|
169
|
+
return text.rstrip("\n") + "\n" + line + "\n"
|
|
170
|
+
block = f"\n{EXEC_HEAD}\n\n{EXEC_HINT}\n{line}\n"
|
|
171
|
+
return text.rstrip("\n") + "\n" + block
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def has_frozen_line(text: str) -> bool:
|
|
175
|
+
"""这篇文档封卷了没有(认标记行)。"""
|
|
176
|
+
return bool(FROZEN_RE.search(text or ""))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def stamp_frozen(text: str, finished: str, who: str | None = "") -> str:
|
|
180
|
+
"""封卷:在头部打标记行(`finished` 传 UTC,这里转本地给人看)。"""
|
|
181
|
+
mark = f"> {FROZEN_MARK}:{fmt_local(finished)}"
|
|
182
|
+
if who:
|
|
183
|
+
mark += f"(@{who})"
|
|
184
|
+
if FROZEN_RE.search(text or ""):
|
|
185
|
+
return text
|
|
186
|
+
return "\n".join(_insert_after_head(text.splitlines(), mark)) + "\n"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def rewrite_label_line(
|
|
190
|
+
text: str, category: str | None, tags: Iterable[str] | None
|
|
191
|
+
) -> str:
|
|
192
|
+
"""改写头部的分类 / 标签行:有就地替换,没有就插到创建行后。"""
|
|
193
|
+
line = label_line(category, tags)
|
|
194
|
+
lines = text.splitlines()
|
|
195
|
+
for i, ln in enumerate(lines):
|
|
196
|
+
if ln.startswith("## "):
|
|
197
|
+
break
|
|
198
|
+
if "**分类**" in ln or "**标签**" in ln:
|
|
199
|
+
if line:
|
|
200
|
+
lines[i] = line
|
|
201
|
+
else:
|
|
202
|
+
del lines[i]
|
|
203
|
+
return "\n".join(lines) + "\n"
|
|
204
|
+
if not line:
|
|
205
|
+
return text
|
|
206
|
+
return "\n".join(_insert_after_head(lines, line)) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def head_meta(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
210
|
+
"""过程文档头部的分类与标签(供历史回链与标签统计共用)。"""
|
|
211
|
+
return parse_label_line(text)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def head_created(text: str) -> str | None:
|
|
215
|
+
"""头部「创建」行的时间(原样返回,不转时区);读不到返回 None。"""
|
|
216
|
+
for line in text.splitlines():
|
|
217
|
+
m = CREATED_AT_RE.match(line)
|
|
218
|
+
if m:
|
|
219
|
+
try:
|
|
220
|
+
return normalize_time(m.group(1), field="创建")
|
|
221
|
+
except Fail:
|
|
222
|
+
return None
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def head_creator(text: str) -> str | None:
|
|
227
|
+
"""头部「创建」行括号里的发起人(裸标识,不带 @);读不到返回 None。"""
|
|
228
|
+
for line in text.splitlines():
|
|
229
|
+
m = CREATOR_RE.match(line)
|
|
230
|
+
if m and m.group(1).strip():
|
|
231
|
+
return m.group(1).strip()
|
|
232
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""唯一允许去问 git「我现在是谁」的地方。
|
|
2
|
+
|
|
3
|
+
只**读**配置(`GIT_COMMITTER_IDENT` / `config user.email` / `config user.name`),
|
|
4
|
+
不 pull、不 commit、不 push —— 那不算 git 操作,算环境。
|
|
5
|
+
兜底身份这件事本身只 `notices.note("提示:…")`,不打印。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import notices
|
|
18
|
+
from .core.identity import Identity, normalize
|
|
19
|
+
|
|
20
|
+
_IDENT_RE = re.compile(r"<([^>]*)>")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(*args: str) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
return subprocess.run(["git", *args], capture_output=True, text=True, check=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def git_value(repo: Path | None, *args: str) -> str | None:
|
|
28
|
+
"""读一个 git 配置值;cwd 给定时按那个仓库判(--repo 未必是当前目录)。
|
|
29
|
+
|
|
30
|
+
没装 git 二进制不算故障:当「读不到」处理,走兜底身份(2026-10-24:
|
|
31
|
+
以前直接 FileNotFoundError 崩,没 git 的机器连台账都建不起来)。
|
|
32
|
+
"""
|
|
33
|
+
base = ("git", "-C", str(repo)) if repo is not None else ("git",)
|
|
34
|
+
try:
|
|
35
|
+
proc = subprocess.run(
|
|
36
|
+
[*base, *args], capture_output=True, text=True, check=False
|
|
37
|
+
)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return None
|
|
40
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
41
|
+
return proc.stdout.strip()
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def git_email(repo: Path | None) -> str | None:
|
|
46
|
+
"""GIT_COMMITTER_IDENT 优先,退回 user.email。"""
|
|
47
|
+
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
48
|
+
if ident:
|
|
49
|
+
m = _IDENT_RE.search(ident)
|
|
50
|
+
if m and m.group(1).strip():
|
|
51
|
+
return m.group(1).strip()
|
|
52
|
+
return git_value(repo, "config", "user.email")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def git_name(repo: Path | None) -> str | None:
|
|
56
|
+
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
57
|
+
if ident:
|
|
58
|
+
head = ident.split("<", 1)[0].strip()
|
|
59
|
+
if head:
|
|
60
|
+
return head
|
|
61
|
+
return git_value(repo, "config", "user.name")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fallback_identity() -> str:
|
|
65
|
+
"""不在 git 仓库里时的兜底链:系统用户名 → getpass → 主机名 → 字面 default。"""
|
|
66
|
+
for key in ("USER", "LOGNAME"):
|
|
67
|
+
value = os.environ.get(key, "").strip()
|
|
68
|
+
if value:
|
|
69
|
+
return value
|
|
70
|
+
try:
|
|
71
|
+
name = getpass.getuser().strip()
|
|
72
|
+
except Exception: # noqa: BLE001 —— 拿不到用户名是正常分支,不该炸
|
|
73
|
+
name = ""
|
|
74
|
+
if name:
|
|
75
|
+
return name
|
|
76
|
+
try:
|
|
77
|
+
host = socket.gethostname().strip()
|
|
78
|
+
except OSError:
|
|
79
|
+
host = ""
|
|
80
|
+
return host or "default"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def current(repo: Path | None = None) -> Identity:
|
|
84
|
+
"""当前操作者。邮箱与名字取不到都给兜底,并留一条提示(不打印)。"""
|
|
85
|
+
email = git_email(repo)
|
|
86
|
+
if not email:
|
|
87
|
+
email = fallback_identity()
|
|
88
|
+
notices.note(
|
|
89
|
+
f"提示:读不到 git 身份(user.email),本次按兜底身份 {email!r} 记账;"
|
|
90
|
+
"配好 git config user.email 再跑,任务才会记到正确的名下。"
|
|
91
|
+
)
|
|
92
|
+
name = git_name(repo) or email
|
|
93
|
+
return Identity(email=normalize(email), name=name.strip(), aliases=())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""只放两样东西:业务错误 `Fail`,以及退出码数字。
|
|
2
|
+
|
|
3
|
+
任何层都可以 import 它;它自己谁也不 import。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Fail(Exception):
|
|
8
|
+
"""业务错误:抛出即中止,由外壳接住转成「错误:…」并返回对应退出码。"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
OK = 0
|
|
12
|
+
BUSINESS = 1 # 业务错:任务不存在、不是你的、参数不合法
|
|
13
|
+
USAGE = 2 # 用法错:argparse 自己会退出,这里留给外壳显式用
|
|
14
|
+
UNEXPECTED = 3 # 未预期异常
|
|
15
|
+
INTERRUPTED = 130 # Ctrl-C / SIGINT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""进程级提示收集器:只收文本,永不打印。
|
|
2
|
+
|
|
3
|
+
各层都能往里放(数据层读盘时的版本告警、env 的兜底身份、业务层的兜底提示),
|
|
4
|
+
最后由 shell/render.py 一次性打到 stderr。同一句只收一次。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
_seen: set[str] = set()
|
|
8
|
+
_items: list[str] = []
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def note(text: str) -> None:
|
|
12
|
+
"""收一条提示;重复的同一句自动忽略。"""
|
|
13
|
+
line = (text or "").strip()
|
|
14
|
+
if not line or line in _seen:
|
|
15
|
+
return
|
|
16
|
+
_seen.add(line)
|
|
17
|
+
_items.append(line)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def warnings() -> tuple[str, ...]:
|
|
21
|
+
"""取当前全部提示(只有 render.py 与 runner.py 能调)。"""
|
|
22
|
+
return tuple(_items)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clear() -> None:
|
|
26
|
+
"""清空(只有 render.py 与 runner.py 能调)。"""
|
|
27
|
+
_seen.clear()
|
|
28
|
+
_items.clear()
|
|
File without changes
|