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.
Files changed (44) hide show
  1. git_worklog/__init__.py +58 -0
  2. git_worklog/__main__.py +10 -0
  3. git_worklog/analysis/__init__.py +48 -0
  4. git_worklog/analysis/commits.py +118 -0
  5. git_worklog/analysis/coverage.py +149 -0
  6. git_worklog/analysis/history.py +339 -0
  7. git_worklog/analysis/manifest.py +432 -0
  8. git_worklog/analysis/reconcile.py +211 -0
  9. git_worklog/analysis/refs.py +178 -0
  10. git_worklog/analysis/results.py +871 -0
  11. git_worklog/analysis/worktree.py +114 -0
  12. git_worklog/cli/__init__.py +414 -0
  13. git_worklog/cli/analyze.py +340 -0
  14. git_worklog/cli/apply.py +61 -0
  15. git_worklog/cli/coverage.py +62 -0
  16. git_worklog/cli/doctor.py +348 -0
  17. git_worklog/cli/migrate.py +43 -0
  18. git_worklog/cli/models.py +112 -0
  19. git_worklog/cli/preview.py +124 -0
  20. git_worklog/cli/refs.py +55 -0
  21. git_worklog/cli/reindex.py +72 -0
  22. git_worklog/cli/report.py +227 -0
  23. git_worklog/cli/validate.py +252 -0
  24. git_worklog/cli/version.py +47 -0
  25. git_worklog/cli/view.py +218 -0
  26. git_worklog/config.py +79 -0
  27. git_worklog/data/provider_models.json +34 -0
  28. git_worklog/dates.py +237 -0
  29. git_worklog/language.py +295 -0
  30. git_worklog/markers.py +765 -0
  31. git_worklog/mdhtml.py +266 -0
  32. git_worklog/migrate.py +305 -0
  33. git_worklog/paths.py +85 -0
  34. git_worklog/preview.py +776 -0
  35. git_worklog/providers.py +337 -0
  36. git_worklog/viewer.py +1198 -0
  37. git_worklog/viewserver.py +120 -0
  38. git_worklog/writer.py +420 -0
  39. git_worklog-1.6.0.dist-info/METADATA +352 -0
  40. git_worklog-1.6.0.dist-info/RECORD +44 -0
  41. git_worklog-1.6.0.dist-info/WHEEL +5 -0
  42. git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
  43. git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
  44. git_worklog-1.6.0.dist-info/top_level.txt +1 -0
@@ -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
+ )