git-worklog 1.6.0__py3-none-any.whl
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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
git_worklog/cli/refs.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``git-worklog refs`` — turn a tag into the commit set it actually contains.
|
|
2
|
+
|
|
3
|
+
For report mode's version questions ("整理 v1.0.1 CHANGELOG"). The worklog is
|
|
4
|
+
indexed by calendar date and a version is bounded by a commit set; the two do not
|
|
5
|
+
map onto each other, so this emits the commits as the authority and the dates
|
|
6
|
+
only as an index for locating day files worth reading. See
|
|
7
|
+
:mod:`git_worklog.analysis.refs` for why converting a tag to a date span is wrong
|
|
8
|
+
in both directions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from git_worklog.analysis import AnalysisError
|
|
14
|
+
from git_worklog.analysis import refs as engine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(args) -> "tuple[dict, int]":
|
|
18
|
+
try:
|
|
19
|
+
return engine.resolve(repo=args.repo, tag=args.tag, from_ref=args.from_ref,
|
|
20
|
+
to_ref=args.to_ref, list_tags_only=args.list_tags,
|
|
21
|
+
timezone=args.timezone,
|
|
22
|
+
date_field=args.date_field), 0
|
|
23
|
+
except AnalysisError as exc:
|
|
24
|
+
return {"ok": False, "errors": [
|
|
25
|
+
{"code": exc.code, "message": exc.message, **exc.extra}]}, 2
|
|
26
|
+
except engine.GitError as exc:
|
|
27
|
+
return {"ok": False, "errors": [
|
|
28
|
+
{"code": "GIT_ERROR", "message": str(exc)}]}, 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_text(p: dict) -> str:
|
|
32
|
+
if not p.get("ok"):
|
|
33
|
+
lines = []
|
|
34
|
+
for e in p.get("errors", []):
|
|
35
|
+
lines.append(f"error: {e['message']}\n")
|
|
36
|
+
for tag in e.get("available_tags", []):
|
|
37
|
+
lines.append(f" available: {tag}\n")
|
|
38
|
+
return "".join(lines)
|
|
39
|
+
|
|
40
|
+
if "tags" in p:
|
|
41
|
+
if not p["tags"]:
|
|
42
|
+
return "No tags in this repository.\n"
|
|
43
|
+
return "".join(f"{t}\n" for t in p["tags"])
|
|
44
|
+
|
|
45
|
+
lines = [f"git-worklog refs — {p['commit_range']}\n"]
|
|
46
|
+
if p["first_release"]:
|
|
47
|
+
lines.append(" first release: the range runs from the root commit\n")
|
|
48
|
+
span = p["date_span"]
|
|
49
|
+
lines.append(f" {p['commit_count']} commit(s)"
|
|
50
|
+
+ (f", {span['from']} .. {span['to']}\n" if span else "\n"))
|
|
51
|
+
lines.append(f" ({p['date_field']} date, {p['timezone']})\n\n")
|
|
52
|
+
for c in p["commits"]:
|
|
53
|
+
lines.append(f" {c['short_hash']} {c['date']} {c['subject']}\n")
|
|
54
|
+
lines.append(f"\n{p['note']}\n")
|
|
55
|
+
return "".join(lines)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""``git-worklog reindex`` — rebuild index.md from the day files.
|
|
2
|
+
|
|
3
|
+
The index is navigation only, and a pure function of the day files: a
|
|
4
|
+
date-descending table linking each one, each row carrying that day's 當日摘要.
|
|
5
|
+
That is what makes this safe to re-run and what makes it the repair for
|
|
6
|
+
``INDEX_WRITE_FAILED`` — when an apply writes the days and then fails to write
|
|
7
|
+
the index, nothing is lost, because the index can be derived again from what
|
|
8
|
+
landed.
|
|
9
|
+
|
|
10
|
+
Normal runs do not need it: ``apply`` rebuilds the index itself. Dry-run is the
|
|
11
|
+
default here too.
|
|
12
|
+
|
|
13
|
+
The index's MANUAL region is preserved byte-for-byte. Files that are not
|
|
14
|
+
``<date>.md`` (including ``index.md`` itself) are ignored.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from git_worklog import writer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(args) -> "tuple[dict, int]":
|
|
25
|
+
try:
|
|
26
|
+
worklog_dir = args.dir or writer.DEFAULT_DIR
|
|
27
|
+
# plan_index is happy to plan an index for a directory that is not there
|
|
28
|
+
# — correct for `apply`, which is allowed to create the worklog. Here it
|
|
29
|
+
# is not: this command repairs a worklog that exists, so a --dir that
|
|
30
|
+
# does not is a typo, and planning an empty index over it would answer a
|
|
31
|
+
# question nobody asked. Same answer `validate` gives.
|
|
32
|
+
if not os.path.isdir(worklog_dir):
|
|
33
|
+
return {"ok": False, "errors": [{
|
|
34
|
+
"code": "NOT_FOUND",
|
|
35
|
+
"message": f"{os.path.abspath(worklog_dir)} does not exist.",
|
|
36
|
+
"target": os.path.abspath(worklog_dir),
|
|
37
|
+
}]}, 2
|
|
38
|
+
plan = writer.plan_index(worklog_dir, {}, args.language)
|
|
39
|
+
common = {k: v for k, v in plan.items() if k not in ("original", "content")}
|
|
40
|
+
|
|
41
|
+
if not args.apply:
|
|
42
|
+
return {"ok": True, "mode": "dry-run", **common,
|
|
43
|
+
"preview": plan["content"],
|
|
44
|
+
"note": "No files have been modified."}, 0
|
|
45
|
+
|
|
46
|
+
writer.apply_index(plan["index_path"], plan["content"])
|
|
47
|
+
with open(plan["index_path"], "r", encoding="utf-8") as fh:
|
|
48
|
+
written = fh.read()
|
|
49
|
+
return {"ok": True, "mode": "apply", **common,
|
|
50
|
+
"written_sha256": writer.sha256(written),
|
|
51
|
+
"note": ("index.md written atomically. No git add / commit / "
|
|
52
|
+
"push was performed.")}, 0
|
|
53
|
+
except writer.WriterError as exc:
|
|
54
|
+
return {"ok": False, "errors": [
|
|
55
|
+
{"code": exc.code, "message": exc.message, **exc.extra}]}, 2
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
return {"ok": False, "errors": [
|
|
58
|
+
{"code": "IO_ERROR", "message": str(exc)}]}, 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_text(p: dict) -> str:
|
|
62
|
+
if not p.get("ok"):
|
|
63
|
+
return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
|
|
64
|
+
|
|
65
|
+
lines = [f"git-worklog reindex — {p['mode']}\n"]
|
|
66
|
+
lines.append(f" {p['index_path']}\n")
|
|
67
|
+
lines.append(f" {len(p['dates'])} day file(s) listed\n")
|
|
68
|
+
lines.append(f" action: {p['action']}\n")
|
|
69
|
+
if p.get("preserved_index_manual"):
|
|
70
|
+
lines.append(" the index's MANUAL region was kept\n")
|
|
71
|
+
lines.append(f"\n{p['note']}\n")
|
|
72
|
+
return "".join(lines)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""``git-worklog report`` — everything a report needs decided before it is written.
|
|
2
|
+
|
|
3
|
+
Report mode's one entry point. It answers, in a single call, the four questions
|
|
4
|
+
that must be settled before a word of a report is written:
|
|
5
|
+
|
|
6
|
+
* **What is in scope?** A date range, or a tag's commit set (:mod:`.refs`).
|
|
7
|
+
* **Is it backed by analysis?** Which days have a day file (:mod:`.coverage`).
|
|
8
|
+
* **What must be left out?** For a tag, which citations in those day files belong
|
|
9
|
+
to another release (:mod:`.reconcile`).
|
|
10
|
+
* **In which language?** Resolved here, independently of the day files being read.
|
|
11
|
+
|
|
12
|
+
It writes nothing. The prose is the agent LLM's job (§6.1): this command decides
|
|
13
|
+
*what may be said*, not what to say. There is deliberately no ``--out`` — the
|
|
14
|
+
report does not exist yet at this point, so there would be nothing to write.
|
|
15
|
+
|
|
16
|
+
Why one command rather than a module per report type
|
|
17
|
+
----------------------------------------------------
|
|
18
|
+
The roadmap (§11) sketched ``weekly.py`` / ``release.py`` / ``contributor.py``
|
|
19
|
+
and four more. They are not here, because the seven report types differ only in
|
|
20
|
+
their scope, an optional filter, and which question the user asked — and the
|
|
21
|
+
v0.4.0 design that shipped report mode said so explicitly: the types are prompt
|
|
22
|
+
examples in ``references/report-mode.md``, "非各自的程式分支". Seven modules each
|
|
23
|
+
forwarding to the same two scope resolvers would be an abstraction over a single
|
|
24
|
+
call site. §3.2 reserved the namespace for the day the types actually diverge;
|
|
25
|
+
they have not, so the namespace stays unbuilt.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
from git_worklog import dates as gwdates
|
|
33
|
+
from git_worklog import language
|
|
34
|
+
from git_worklog import markers as wm
|
|
35
|
+
from git_worklog.analysis import AnalysisError
|
|
36
|
+
from git_worklog.analysis import coverage as cov
|
|
37
|
+
from git_worklog.analysis import history as ah
|
|
38
|
+
from git_worklog.analysis import manifest as am
|
|
39
|
+
from git_worklog.analysis import reconcile as rec
|
|
40
|
+
from git_worklog.analysis import refs as refs_engine
|
|
41
|
+
|
|
42
|
+
def _material(worklog_dir: str, dates: "list[str]") -> "list[dict]":
|
|
43
|
+
"""The day files worth reading, in date order."""
|
|
44
|
+
out = []
|
|
45
|
+
for date in dates:
|
|
46
|
+
path = wm.day_path(worklog_dir, date)
|
|
47
|
+
if os.path.isfile(path):
|
|
48
|
+
out.append({"date": date, "path": path})
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(args) -> "tuple[dict, int]":
|
|
53
|
+
try:
|
|
54
|
+
payload = _run(args)
|
|
55
|
+
# Exit 1 for a gap, matching `coverage`: it ran fine and the answer to
|
|
56
|
+
# "can I report on this?" is no. `out_of_range` deliberately does not
|
|
57
|
+
# earn a 1 — a day file citing another release's work is the normal
|
|
58
|
+
# case, not a failure. It is *why* the reconciliation exists, and the
|
|
59
|
+
# report simply leaves those commits out. Exiting 1 on the ordinary case
|
|
60
|
+
# would train the caller to ignore the code.
|
|
61
|
+
return payload, (0 if payload["fully_covered"] else 1)
|
|
62
|
+
except (AnalysisError, gwdates.DateError) as exc:
|
|
63
|
+
return {"ok": False, "errors": [
|
|
64
|
+
{"code": exc.code, "message": exc.message, **exc.extra}]}, 2
|
|
65
|
+
except language.LanguageError as exc:
|
|
66
|
+
return {"ok": False, "errors": [
|
|
67
|
+
{"code": exc.code, "message": exc.message, **exc.extra}]}, 2
|
|
68
|
+
except (ah.GitError, refs_engine.GitError) as exc:
|
|
69
|
+
return {"ok": False, "errors": [
|
|
70
|
+
{"code": "GIT_ERROR", "message": str(exc)}]}, 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run(args) -> dict:
|
|
74
|
+
is_ref = bool(args.tag or args.from_ref or args.to_ref)
|
|
75
|
+
is_date = bool(args.shortcut or args.date or args.days is not None
|
|
76
|
+
or getattr(args, "from") or args.to)
|
|
77
|
+
if is_ref and is_date:
|
|
78
|
+
raise AnalysisError(
|
|
79
|
+
"ARG_CONFLICT",
|
|
80
|
+
"A report has one scope: a tag's commit set (--tag/--to-ref) or a "
|
|
81
|
+
"date range. The two do not mean the same thing — a version is "
|
|
82
|
+
"bounded by commits, the worklog is indexed by date. Pass one.")
|
|
83
|
+
if not is_ref and not is_date:
|
|
84
|
+
raise AnalysisError(
|
|
85
|
+
"NO_SCOPE",
|
|
86
|
+
"Provide a date scope (NNd, --date, --days, --from/--to) or a ref "
|
|
87
|
+
"scope (--tag, or --to-ref with an optional --from-ref).")
|
|
88
|
+
|
|
89
|
+
# Resolved before any Git work: an unusable language tag should say so
|
|
90
|
+
# whether or not the tag exists. Report language is decided here and owes
|
|
91
|
+
# nothing to the language the day files happen to be written in (§6.2.11) —
|
|
92
|
+
# this command never reads their language, only their hashes and prose.
|
|
93
|
+
lang = am.resolve_language(args.language, args.language_source, args.dir)
|
|
94
|
+
|
|
95
|
+
scope: dict
|
|
96
|
+
reconciliation = None
|
|
97
|
+
if is_ref:
|
|
98
|
+
r = refs_engine.resolve(
|
|
99
|
+
repo=args.repo, tag=args.tag, from_ref=args.from_ref,
|
|
100
|
+
to_ref=args.to_ref, list_tags_only=False,
|
|
101
|
+
timezone=args.timezone or "UTC", date_field=args.date_field)
|
|
102
|
+
scope = {
|
|
103
|
+
"kind": "ref",
|
|
104
|
+
"tag": r["tag"],
|
|
105
|
+
"prev_tag": r["prev_tag"],
|
|
106
|
+
"first_release": r["first_release"],
|
|
107
|
+
"commit_range": r["commit_range"],
|
|
108
|
+
"date_span": r["date_span"],
|
|
109
|
+
}
|
|
110
|
+
dates_arg = ",".join(r["dates"])
|
|
111
|
+
# An empty range has no dates to check; coverage would reject "".
|
|
112
|
+
coverage = (cov.check(repo=args.repo, dir=args.dir, dates=dates_arg,
|
|
113
|
+
timezone=args.timezone or "UTC",
|
|
114
|
+
date_field=args.date_field,
|
|
115
|
+
worklog_dir=args.worklog_dir)
|
|
116
|
+
if r["dates"] else None)
|
|
117
|
+
else:
|
|
118
|
+
scope = {"kind": "date"}
|
|
119
|
+
coverage = cov.check(
|
|
120
|
+
repo=args.repo, dir=args.dir, dates="", timezone=args.timezone,
|
|
121
|
+
date_field=args.date_field, worklog_dir=args.worklog_dir,
|
|
122
|
+
shortcut=args.shortcut, date=args.date, days=args.days,
|
|
123
|
+
from_=getattr(args, "from"), to=args.to, today=args.today,
|
|
124
|
+
max_days=args.max_days)
|
|
125
|
+
r = None
|
|
126
|
+
|
|
127
|
+
if coverage is None:
|
|
128
|
+
# A tag containing no commits: real (an empty release), and not a gap.
|
|
129
|
+
# There are no dates to ask `coverage` about — it rejects an empty
|
|
130
|
+
# --dates — so the same answer is assembled here. detect_timezone rather
|
|
131
|
+
# than a literal, so `timezone.source` carries the same vocabulary as
|
|
132
|
+
# every other path: a caller must not have to special-case the shape of
|
|
133
|
+
# an empty release.
|
|
134
|
+
info = ah.repo_info(args.repo)
|
|
135
|
+
_tz, tz_name, tz_source = gwdates.detect_timezone(args.timezone or "UTC")
|
|
136
|
+
resolved_dir = (args.dir if os.path.isabs(args.dir)
|
|
137
|
+
else os.path.join(info["root"], args.dir))
|
|
138
|
+
coverage = {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"repository": {"root": info["root"], "has_commits": info["has_commits"]},
|
|
141
|
+
"worklog_dir": resolved_dir, "dir_exists": os.path.isdir(resolved_dir),
|
|
142
|
+
"timezone": {"resolved": tz_name, "source": tz_source},
|
|
143
|
+
"date_field": args.date_field, "dates": [], "covered": [], "gaps": [],
|
|
144
|
+
"no_commit_dates": [], "gap_commit_count": 0, "fully_covered": True,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
dates = [row["date"] for row in coverage["dates"]]
|
|
148
|
+
if is_ref:
|
|
149
|
+
# The check the prose could not hold. Runs for a ref scope only: a date
|
|
150
|
+
# scope has no authoritative commit set to reconcile against — its dates
|
|
151
|
+
# *are* the authority, so a day file citing anything on those days is
|
|
152
|
+
# in scope by definition.
|
|
153
|
+
reconciliation = rec.reconcile(args.repo, coverage["worklog_dir"],
|
|
154
|
+
r["commits"], r["dates"])
|
|
155
|
+
|
|
156
|
+
payload = {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"scope": scope,
|
|
159
|
+
"language": lang.as_manifest(),
|
|
160
|
+
"repository": coverage["repository"],
|
|
161
|
+
"worklog_dir": coverage["worklog_dir"],
|
|
162
|
+
"dir_exists": coverage["dir_exists"],
|
|
163
|
+
"timezone": coverage["timezone"],
|
|
164
|
+
"date_field": coverage["date_field"],
|
|
165
|
+
"dates": coverage["dates"],
|
|
166
|
+
"covered": coverage["covered"],
|
|
167
|
+
"gaps": coverage["gaps"],
|
|
168
|
+
"no_commit_dates": coverage["no_commit_dates"],
|
|
169
|
+
"gap_commit_count": coverage["gap_commit_count"],
|
|
170
|
+
"fully_covered": coverage["fully_covered"],
|
|
171
|
+
"material": _material(coverage["worklog_dir"], dates),
|
|
172
|
+
"warnings": list(lang.warnings),
|
|
173
|
+
}
|
|
174
|
+
if is_ref:
|
|
175
|
+
payload["commit_count"] = r["commit_count"]
|
|
176
|
+
payload["commits"] = r["commits"]
|
|
177
|
+
payload["reconciliation"] = reconciliation
|
|
178
|
+
return payload
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def render_text(p: dict) -> str:
|
|
182
|
+
if not p.get("ok"):
|
|
183
|
+
return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
|
|
184
|
+
|
|
185
|
+
s, tz = p["scope"], p["timezone"]
|
|
186
|
+
head = (f"{s['commit_range']}" if s["kind"] == "ref"
|
|
187
|
+
else f"{p['dates'][0]['date']} .. {p['dates'][-1]['date']}"
|
|
188
|
+
if p["dates"] else "no dates")
|
|
189
|
+
lines = [f"git-worklog report — {head}\n"]
|
|
190
|
+
lines.append(f" language: {p['language']['resolved']} "
|
|
191
|
+
f"(via {p['language']['source']})\n")
|
|
192
|
+
lines.append(f" ({tz['resolved']} via {tz['source']})\n")
|
|
193
|
+
if s["kind"] == "ref" and s["first_release"]:
|
|
194
|
+
lines.append(" first release: the range runs from the root commit\n")
|
|
195
|
+
lines.append("\n")
|
|
196
|
+
|
|
197
|
+
symbol = {"covered": "✓", "gap": "✗", "no-commits": "·"}
|
|
198
|
+
for row in p["dates"]:
|
|
199
|
+
note = (f"{row['commit_count']} commit(s)" if row["commit_count"]
|
|
200
|
+
else "no commits, no file expected")
|
|
201
|
+
lines.append(f" {symbol[row['status']]} {row['date']} "
|
|
202
|
+
f"{row['status']:11} {note}\n")
|
|
203
|
+
|
|
204
|
+
if not p["fully_covered"]:
|
|
205
|
+
lines.append(f"\n{len(p['gaps'])} gap(s) covering {p['gap_commit_count']} "
|
|
206
|
+
f"commit(s): {', '.join(p['gaps'])}\n")
|
|
207
|
+
lines.append("Real work exists on those days that nothing has analysed.\n")
|
|
208
|
+
|
|
209
|
+
rn = p.get("reconciliation")
|
|
210
|
+
if rn is not None:
|
|
211
|
+
lines.append(f"\n{p['commit_count']} commit(s) in range; "
|
|
212
|
+
f"{rn['backed_count']} backed by a day file.\n")
|
|
213
|
+
for c in rn["unbacked"]:
|
|
214
|
+
lines.append(f" unbacked {c['short_hash']} {c['subject']}\n")
|
|
215
|
+
if rn["unbacked"]:
|
|
216
|
+
lines.append(" ^ in this release, but no day file describes them. "
|
|
217
|
+
"Do not claim to have analysed these.\n")
|
|
218
|
+
for c in rn["out_of_range"]:
|
|
219
|
+
lines.append(f" out of range {c['short_hash']} {c['subject']}\n"
|
|
220
|
+
f" cited on {', '.join(c['cited_on'])}\n")
|
|
221
|
+
if rn["out_of_range"]:
|
|
222
|
+
lines.append(" ^ the day files describe these, but they belong to a "
|
|
223
|
+
"different release. Leave them out.\n")
|
|
224
|
+
for u in rn["unresolved"]:
|
|
225
|
+
lines.append(f" unresolved {u['hash']} "
|
|
226
|
+
f"cited on {', '.join(u['cited_on'])}, names no commit here\n")
|
|
227
|
+
return "".join(lines)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""``git-worklog validate`` — is what is on disk actually well-formed?
|
|
2
|
+
|
|
3
|
+
This is the read-only integrity check: day files, their GENERATED/MANUAL
|
|
4
|
+
markers, the index, and config. It reuses ``git_worklog.markers`` — the same
|
|
5
|
+
parser the writers use — rather than re-deriving the rules, so validate can
|
|
6
|
+
never disagree with what update/rebuild would produce.
|
|
7
|
+
|
|
8
|
+
Roadmap §12.1 also lists preview records, analysis results and evidence links.
|
|
9
|
+
Those are reported as ``skipped`` rather than quietly dropped: they are transient
|
|
10
|
+
user-level state, and their validators live in ``git_worklog.preview`` /
|
|
11
|
+
``analysis.results``, which run inside a pipeline where the run id is known. A
|
|
12
|
+
result's language is checked there too, against the manifest that asked for it --
|
|
13
|
+
a worklog directory has no manifest in scope to check against.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from git_worklog import config, language
|
|
22
|
+
from git_worklog import markers as wm
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _issue(code: str, message: str, **extra) -> dict:
|
|
26
|
+
return {"code": code, "message": message, **extra}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_day(path: str, date: str) -> "tuple[list[dict], list[dict]]":
|
|
30
|
+
try:
|
|
31
|
+
with open(path, "rb") as fh:
|
|
32
|
+
raw = fh.read()
|
|
33
|
+
except OSError as exc:
|
|
34
|
+
return [_issue("UNREADABLE", f"{path}: {exc}", date=date)], []
|
|
35
|
+
try:
|
|
36
|
+
text = raw.decode("utf-8")
|
|
37
|
+
except UnicodeDecodeError as exc:
|
|
38
|
+
return [_issue("NON_UTF8", f"{path} is not valid UTF-8: {exc}", date=date)], []
|
|
39
|
+
|
|
40
|
+
_, issues = wm.scan_day(text, date)
|
|
41
|
+
fatal = [dict(i, date=date, target=path)
|
|
42
|
+
for i in issues if i["code"] in wm.FATAL_CODES]
|
|
43
|
+
warn = [dict(i, date=date, target=path)
|
|
44
|
+
for i in issues if i["code"] not in wm.FATAL_CODES]
|
|
45
|
+
return fatal, warn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_index(worklog_dir: str, layout: str,
|
|
49
|
+
disk_dates: "list[str]") -> "tuple[list[dict], list[dict]]":
|
|
50
|
+
path = wm.index_path(worklog_dir)
|
|
51
|
+
if not os.path.exists(path):
|
|
52
|
+
if disk_dates:
|
|
53
|
+
return [_issue("INDEX_MISSING",
|
|
54
|
+
f"{len(disk_dates)} day file(s) exist but there is no "
|
|
55
|
+
f"{path}. Rebuild it with rebuild_worklog_index.py.",
|
|
56
|
+
target=path)], []
|
|
57
|
+
return [], []
|
|
58
|
+
try:
|
|
59
|
+
with open(path, "rb") as fh:
|
|
60
|
+
text = fh.read().decode("utf-8")
|
|
61
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
62
|
+
return [_issue("INDEX_UNREADABLE", f"{path}: {exc}", target=path)], []
|
|
63
|
+
|
|
64
|
+
doc, issues = wm.scan_index(text)
|
|
65
|
+
fatal = [dict(i, target=path) for i in issues if i["code"] in wm.FATAL_CODES]
|
|
66
|
+
warn = [dict(i, target=path) for i in issues if i["code"] not in wm.FATAL_CODES]
|
|
67
|
+
if doc is None:
|
|
68
|
+
return fatal, warn
|
|
69
|
+
|
|
70
|
+
listed = {d for d, _ in doc.rows}
|
|
71
|
+
on_disk = set(disk_dates)
|
|
72
|
+
# A row pointing at a day file that is gone is a broken link in the one
|
|
73
|
+
# document whose whole job is navigation.
|
|
74
|
+
for d in sorted(listed - on_disk):
|
|
75
|
+
fatal.append(_issue("INDEX_ROW_WITHOUT_FILE",
|
|
76
|
+
f"The index lists {d} but {wm.day_path(worklog_dir, d, layout)} "
|
|
77
|
+
"does not exist.", date=d, target=path))
|
|
78
|
+
for d in sorted(on_disk - listed):
|
|
79
|
+
warn.append(_issue("DAY_FILE_NOT_INDEXED",
|
|
80
|
+
f"{d} has a day file but no index row; rebuild the index.",
|
|
81
|
+
date=d, target=path))
|
|
82
|
+
return fatal, warn
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_config(worklog_dir: str) -> "tuple[list[dict], list[dict]]":
|
|
86
|
+
path = wm.config_path(worklog_dir)
|
|
87
|
+
if not os.path.exists(path):
|
|
88
|
+
return [], []
|
|
89
|
+
try:
|
|
90
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
91
|
+
cfg = json.load(fh)
|
|
92
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
93
|
+
return [_issue("CONFIG_INVALID", f"{path}: {exc}", target=path)], []
|
|
94
|
+
if not isinstance(cfg, dict):
|
|
95
|
+
return [_issue("CONFIG_INVALID", f"{path} must contain an object.",
|
|
96
|
+
target=path)], []
|
|
97
|
+
got = cfg.get("schema_version")
|
|
98
|
+
if got is None:
|
|
99
|
+
return [], [_issue("CONFIG_NO_SCHEMA_VERSION",
|
|
100
|
+
f"{path} has no schema_version.", target=path)]
|
|
101
|
+
if not isinstance(got, int):
|
|
102
|
+
return [_issue("CONFIG_INVALID",
|
|
103
|
+
f"{path}: schema_version must be an integer (got {got!r}).",
|
|
104
|
+
target=path)], []
|
|
105
|
+
if got > wm.LAYOUT_VERSION:
|
|
106
|
+
return [_issue("CONFIG_TOO_NEW",
|
|
107
|
+
f"{path} declares schema_version {got}, newer than this build "
|
|
108
|
+
f"understands ({wm.LAYOUT_VERSION}).", target=path)], []
|
|
109
|
+
return [], []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _validate_language(worklog_dir: str, layout: str,
|
|
113
|
+
dates: "list[str]") -> "tuple[list, list]":
|
|
114
|
+
"""Language settings and stamps on disk (§6.2.7, §6.2.12).
|
|
115
|
+
|
|
116
|
+
What is checkable from a worklog alone is that its declared languages are
|
|
117
|
+
tags this build can honour, and that day files carry a summary marker so the
|
|
118
|
+
index can find their summary whatever language they are in. What is NOT
|
|
119
|
+
checkable is whether the prose is *in* that language: engineering writing is
|
|
120
|
+
full of English identifiers by contract (§6.2.9), so a detector would flag
|
|
121
|
+
correct zh-TW work items. This deliberately does not try.
|
|
122
|
+
"""
|
|
123
|
+
errors: "list[dict]" = []
|
|
124
|
+
warnings: "list[dict]" = []
|
|
125
|
+
cfg = config.load(worklog_dir)
|
|
126
|
+
|
|
127
|
+
for key in ("language", "index_language"):
|
|
128
|
+
raw = cfg.get(key)
|
|
129
|
+
if not isinstance(raw, str) or raw.strip().lower() == language.AUTO:
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
language.normalize(raw)
|
|
133
|
+
except language.LanguageError as exc:
|
|
134
|
+
errors.append(_issue("CONFIG_LANGUAGE_INVALID",
|
|
135
|
+
f"config.json {key}: {exc.message}",
|
|
136
|
+
target=wm.config_path(worklog_dir)))
|
|
137
|
+
|
|
138
|
+
index_path = wm.index_path(worklog_dir)
|
|
139
|
+
if os.path.exists(index_path):
|
|
140
|
+
try:
|
|
141
|
+
with open(index_path, "r", encoding="utf-8") as fh:
|
|
142
|
+
stamped = wm.index_language_of(fh.read())
|
|
143
|
+
except (OSError, UnicodeDecodeError):
|
|
144
|
+
stamped = None # the index's own validator reports this
|
|
145
|
+
else:
|
|
146
|
+
if stamped is not None:
|
|
147
|
+
try:
|
|
148
|
+
language.normalize(stamped)
|
|
149
|
+
except language.LanguageError as exc:
|
|
150
|
+
errors.append(_issue("INDEX_LANGUAGE_INVALID",
|
|
151
|
+
f"index.md is stamped lang={stamped!r}, "
|
|
152
|
+
f"which is not usable: {exc.message}",
|
|
153
|
+
target=index_path))
|
|
154
|
+
|
|
155
|
+
for d in dates:
|
|
156
|
+
path = wm.day_path(worklog_dir, d, layout)
|
|
157
|
+
try:
|
|
158
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
159
|
+
day = wm.parse_day(fh.read(), d)
|
|
160
|
+
except (OSError, UnicodeDecodeError, wm.WorklogFormatError):
|
|
161
|
+
continue # _validate_day already reported it
|
|
162
|
+
if wm.summarise_generated(day.generated) and not wm.has_summary_marker(day.generated):
|
|
163
|
+
# Not an error: the zh-TW heading fallback still finds it, which is
|
|
164
|
+
# exactly why this is worth saying. The day works today and silently
|
|
165
|
+
# loses its index summary the moment it is regenerated in another
|
|
166
|
+
# language.
|
|
167
|
+
warnings.append(_issue(
|
|
168
|
+
"DAY_SUMMARY_UNMARKED",
|
|
169
|
+
f"{d}.md has no summary marker; its index row relies on the "
|
|
170
|
+
f"zh-TW heading fallback and would go blank if the day were "
|
|
171
|
+
f"rewritten in another language.", target=path))
|
|
172
|
+
|
|
173
|
+
return errors, warnings
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run(args) -> "tuple[dict, int]":
|
|
177
|
+
repo = args.repo or "."
|
|
178
|
+
worklog_dir = args.dir or os.path.join(repo, wm.WORKLOG_DIRNAME)
|
|
179
|
+
|
|
180
|
+
if not os.path.isdir(worklog_dir):
|
|
181
|
+
return {
|
|
182
|
+
"ok": False,
|
|
183
|
+
"worklog_dir": os.path.abspath(worklog_dir),
|
|
184
|
+
"errors": [_issue("NOT_FOUND",
|
|
185
|
+
f"{os.path.abspath(worklog_dir)} does not exist.",
|
|
186
|
+
target=os.path.abspath(worklog_dir))],
|
|
187
|
+
"warnings": [], "day_count": 0,
|
|
188
|
+
}, 2
|
|
189
|
+
|
|
190
|
+
layout = wm.detect_layout(worklog_dir)
|
|
191
|
+
dates = wm.list_day_dates(worklog_dir, layout)
|
|
192
|
+
|
|
193
|
+
errors: "list[dict]" = []
|
|
194
|
+
warnings: "list[dict]" = []
|
|
195
|
+
for d in dates:
|
|
196
|
+
f, w = _validate_day(wm.day_path(worklog_dir, d, layout), d)
|
|
197
|
+
errors.extend(f)
|
|
198
|
+
warnings.extend(w)
|
|
199
|
+
|
|
200
|
+
f, w = _validate_index(worklog_dir, layout, dates)
|
|
201
|
+
errors.extend(f)
|
|
202
|
+
warnings.extend(w)
|
|
203
|
+
f, w = _validate_config(worklog_dir)
|
|
204
|
+
errors.extend(f)
|
|
205
|
+
warnings.extend(w)
|
|
206
|
+
f, w = _validate_language(worklog_dir, layout, dates)
|
|
207
|
+
errors.extend(f)
|
|
208
|
+
warnings.extend(w)
|
|
209
|
+
|
|
210
|
+
if layout == wm.LAYOUT_LEGACY:
|
|
211
|
+
warnings.append(_issue("LEGACY_LAYOUT",
|
|
212
|
+
f"{worklog_dir} uses the pre-v0.6 flat layout. It is "
|
|
213
|
+
"valid and readable, but not writable; migrate it with "
|
|
214
|
+
"migrate_legacy_worklog.py --from-dir."))
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"ok": not errors,
|
|
218
|
+
"worklog_dir": os.path.abspath(worklog_dir),
|
|
219
|
+
"layout": layout,
|
|
220
|
+
"day_count": len(dates),
|
|
221
|
+
"dates": dates,
|
|
222
|
+
"errors": errors,
|
|
223
|
+
"warnings": warnings,
|
|
224
|
+
"skipped": [
|
|
225
|
+
{"check": "preview_records",
|
|
226
|
+
"reason": "Transient user-level state; validated by `git-worklog apply` "
|
|
227
|
+
"against the record itself, where the preview_id is known."},
|
|
228
|
+
{"check": "analysis_results",
|
|
229
|
+
"reason": "Transient user-level state; validated by `git-worklog analyze "
|
|
230
|
+
"collect` within a run, where the run_dir is known."},
|
|
231
|
+
{"check": "result_language",
|
|
232
|
+
"reason": "Analysis results are transient user-level state; their "
|
|
233
|
+
"language is checked by collect_day_results.py against the "
|
|
234
|
+
"run's manifest, where the expected tag is known."},
|
|
235
|
+
],
|
|
236
|
+
}, (1 if errors else 0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def render_text(p: dict) -> str:
|
|
240
|
+
lines = [f"git-worklog validate — {p['worklog_dir']}"]
|
|
241
|
+
if p.get("layout"):
|
|
242
|
+
lines.append(f" layout: {p['layout']}, {p['day_count']} day file(s)")
|
|
243
|
+
lines.append("")
|
|
244
|
+
for e in p.get("errors", []):
|
|
245
|
+
lines.append(f" ✗ {e['code']}: {e['message']}")
|
|
246
|
+
for w in p.get("warnings", []):
|
|
247
|
+
lines.append(f" ! {w['code']}: {w['message']}")
|
|
248
|
+
if not p.get("errors") and not p.get("warnings"):
|
|
249
|
+
lines.append(" ✓ No problems found.")
|
|
250
|
+
lines.append("")
|
|
251
|
+
lines.append("OK." if p["ok"] else f"FAILED: {len(p.get('errors', []))} error(s).")
|
|
252
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``git-worklog version`` — every version that matters, and what each governs.
|
|
2
|
+
|
|
3
|
+
Roadmap §12.1 asks for the CLI, engine, schema and skill-compatibility versions.
|
|
4
|
+
They are reported separately because they answer different questions and move on
|
|
5
|
+
different clocks: the product version tracks releases, while the layout and
|
|
6
|
+
schema versions track the shape of data already on someone's disk and bump only
|
|
7
|
+
when a migration is needed. Collapsing them would imply every release migrates
|
|
8
|
+
user data. See issue #12 and docs/naming-conventions.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import platform
|
|
14
|
+
|
|
15
|
+
from git_worklog import __version__
|
|
16
|
+
from git_worklog.markers import LAYOUT_VERSION
|
|
17
|
+
|
|
18
|
+
# The skill body and this package ship together in one directory, so they cannot
|
|
19
|
+
# drift apart in a normal install. The number exists for the case where they can:
|
|
20
|
+
# a pip-installed CLI meeting a separately-copied skill. Bump it when the CLI
|
|
21
|
+
# changes in a way an older skill body cannot drive.
|
|
22
|
+
SKILL_COMPAT_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(args) -> "tuple[dict, int]":
|
|
26
|
+
return {
|
|
27
|
+
"ok": True,
|
|
28
|
+
"cli_version": __version__,
|
|
29
|
+
# The engine is this same package today; PR 7 may split it out, at which
|
|
30
|
+
# point this stops being a copy of cli_version.
|
|
31
|
+
"engine_version": __version__,
|
|
32
|
+
"layout_version": LAYOUT_VERSION,
|
|
33
|
+
"schema_version": LAYOUT_VERSION,
|
|
34
|
+
"skill_compat_version": SKILL_COMPAT_VERSION,
|
|
35
|
+
"python_version": platform.python_version(),
|
|
36
|
+
}, 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_text(p: dict) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f"git-worklog {p['cli_version']}\n"
|
|
42
|
+
f" engine {p['engine_version']}\n"
|
|
43
|
+
f" layout {p['layout_version']} (.git-worklog/VERSION)\n"
|
|
44
|
+
f" config schema {p['schema_version']} (config.json schema_version)\n"
|
|
45
|
+
f" skill compat {p['skill_compat_version']}\n"
|
|
46
|
+
f" python {p['python_version']}\n"
|
|
47
|
+
)
|