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,114 @@
1
+ """Inspect the Git working tree for uncommitted changes.
2
+
3
+ Only used when ``include_uncommitted`` is on. It classifies changes into
4
+ staged / unstaged / untracked, flags binary and submodule entries, and computes
5
+ a stable working-tree fingerprint the preview state uses to detect drift between
6
+ dry-run and apply.
7
+
8
+ Uncommitted content may only ever be attributed to *today*: this module does not
9
+ guess historical dates from filesystem mtimes, because a file's mtime says when
10
+ it was last written, not when the work happened.
11
+
12
+ The git helpers and diff parser come from :mod:`~git_worklog.analysis.history`.
13
+ They were duplicated here while these were two standalone scripts that had to stay
14
+ individually portable; now that both ship in the package that rationale is gone,
15
+ so this module imports them rather than carrying its own copy (#20).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+
22
+ from git_worklog.analysis import AnalysisError
23
+ # The git helpers and diff parser live in history. They were duplicated here only
24
+ # while these two modules were standalone scripts that had to stay individually
25
+ # portable (#20); now that both ship in the package, that rationale is gone.
26
+ # history's `_parse_raw` returns a fuller entry — it also needs old/new mode and
27
+ # sha for submodule and mode-change detection — and `_diff_files` below reads the
28
+ # subset it needs, so sharing the parser leaves this module's output unchanged.
29
+ # GitError is now the one class, so a working-tree git failure surfaces as
30
+ # GIT_ERROR through the same CLI catch as every other Git call.
31
+ from git_worklog.analysis.history import ( # noqa: F401
32
+ GitError, _git, _git_ok, _parse_numstat, _parse_raw,
33
+ )
34
+
35
+
36
+ def _diff_files(repo: str, cached: bool) -> "list[dict]":
37
+ base = ["diff", "-M", "-C", "-z"]
38
+ if cached:
39
+ base.append("--cached")
40
+ raw = _parse_raw(_git(repo, [*base, "--raw"]))
41
+ nums = _parse_numstat(_git(repo, [*base, "--numstat"]))
42
+ files = []
43
+ for idx, entry in enumerate(raw):
44
+ add, dele = nums[idx] if idx < len(nums) else ("-", "-")
45
+ is_binary = add == "-" and dele == "-"
46
+ files.append({
47
+ "status": entry["status"],
48
+ "path": entry["path"],
49
+ "old_path": entry["old_path"],
50
+ "similarity": entry["similarity"],
51
+ "additions": None if is_binary else int(add or 0),
52
+ "deletions": None if is_binary else int(dele or 0),
53
+ "is_binary": is_binary,
54
+ "is_submodule": entry["is_submodule"],
55
+ })
56
+ return files
57
+
58
+
59
+ def _untracked(repo: str) -> "list[dict]":
60
+ out = _git(repo, ["ls-files", "--others", "--exclude-standard", "-z"])
61
+ paths = [p for p in out.split("\x00") if p]
62
+ result = []
63
+ for path in paths:
64
+ # Sniff for a NUL byte in the first 8 KB, the same heuristic Git uses.
65
+ try:
66
+ with open(f"{repo}/{path}", "rb") as fh:
67
+ is_binary = b"\x00" in fh.read(8000)
68
+ except OSError:
69
+ is_binary = False
70
+ result.append({"status": "A", "path": path, "is_binary": is_binary})
71
+ return result
72
+
73
+
74
+ def _fingerprint(repo: str) -> str:
75
+ h = hashlib.sha256()
76
+ h.update(_git(repo, ["status", "--porcelain=v2", "-z"], binary=True))
77
+ h.update(b"\x00STAGED\x00")
78
+ h.update(_git(repo, ["diff", "--cached"], binary=True))
79
+ h.update(b"\x00UNSTAGED\x00")
80
+ h.update(_git(repo, ["diff"], binary=True))
81
+ h.update(b"\x00UNTRACKED\x00")
82
+ for entry in _untracked(repo):
83
+ h.update(entry["path"].encode("utf-8", "replace"))
84
+ try:
85
+ with open(f"{repo}/{entry['path']}", "rb") as fh:
86
+ h.update(fh.read())
87
+ except OSError:
88
+ pass
89
+ h.update(b"\x00")
90
+ return h.hexdigest()
91
+
92
+
93
+ def inspect(repo: str) -> dict:
94
+ """Everything uncommitted in ``repo``, plus a fingerprint of that state."""
95
+ if not _git_ok(repo, ["rev-parse", "--is-inside-work-tree"]):
96
+ raise AnalysisError("NOT_A_GIT_REPO",
97
+ "The target directory is not inside a Git repository.",
98
+ path=repo)
99
+ staged = _diff_files(repo, cached=True)
100
+ unstaged = _diff_files(repo, cached=False)
101
+ untracked = _untracked(repo)
102
+ return {
103
+ "ok": True,
104
+ "staged": staged,
105
+ "unstaged": unstaged,
106
+ "untracked": untracked,
107
+ "counts": {
108
+ "staged": len(staged),
109
+ "unstaged": len(unstaged),
110
+ "untracked": len(untracked),
111
+ },
112
+ "has_uncommitted": bool(staged or unstaged or untracked),
113
+ "worktree_fingerprint": _fingerprint(repo),
114
+ }
@@ -0,0 +1,414 @@
1
+ """The ``git-worklog`` command.
2
+
3
+ ``version``, ``doctor`` and ``validate`` came first (roadmap §12.1): the things
4
+ you want *before* trusting anything else, none of which need the analysis
5
+ pipeline. ``analyze prepare``/``collect`` (§7) then bracket the pipeline — they
6
+ decide what must be analysed and check what came back, while the analysis itself
7
+ stays with the hosting agent's LLM. The CLI does not replace it and needs no
8
+ model API key (§6.1). ``preview`` and ``apply`` (§10) close the loop: the first
9
+ turns a collected run plus the agent's prose into a frozen payload, the second
10
+ writes that payload and takes no other input. ``coverage`` and ``refs`` serve
11
+ report mode's two questions — "is there an analysis behind these dates?" and
12
+ "which commits is this version actually made of?" — and ``migrate`` (§2.4) moves
13
+ a legacy worklog into the current layout. ``view`` is for reading what all of
14
+ that produced: the worklog as one self-contained page in a browser, read-only.
15
+
16
+ Every subcommand prints one JSON object to stdout, matching the scripts'
17
+ contract, so the same parsing works everywhere. ``--text`` switches to a
18
+ human-readable rendering for terminal use.
19
+
20
+ Exit codes:
21
+ 0 ok
22
+ 1 ran fine, but the answer is "no" (doctor found a problem, validate failed)
23
+ 2 the command itself could not run
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import sys
31
+
32
+ from git_worklog import __version__, language
33
+ from git_worklog import markers as wm
34
+
35
+
36
+ def _emit(payload: dict, as_text: bool, render) -> None:
37
+ if as_text:
38
+ sys.stdout.write(render(payload))
39
+ else:
40
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
41
+ sys.stdout.write("\n")
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ p = argparse.ArgumentParser(
46
+ prog="git-worklog",
47
+ description="Engineering worklogs from real Git history and code.",
48
+ )
49
+ p.add_argument("--version", action="version", version=f"git-worklog {__version__}")
50
+ p.add_argument("--text", action="store_true",
51
+ help="Human-readable output instead of JSON.")
52
+ p.add_argument("--interface-language", default=None, metavar="TAG",
53
+ help="Language for this command's own messages (BCP 47). "
54
+ "English only for now; anything else is reported and "
55
+ "falls back. Separate from the worklog's content "
56
+ "language, which these commands do not set.")
57
+ sub = p.add_subparsers(dest="command", metavar="<command>")
58
+
59
+ sub.add_parser("version", help="Report the CLI, layout and schema versions.")
60
+
61
+ d = sub.add_parser("doctor", help="Check that this environment can run the tool.")
62
+ d.add_argument("--repo", default=".", help="Repository to check (default: cwd).")
63
+ d.add_argument("--dir", help="Worklog directory (default: <repo>/.git-worklog).")
64
+
65
+ v = sub.add_parser("validate", help="Validate a worklog directory.")
66
+ v.add_argument("--repo", default=".", help="Repository to check (default: cwd).")
67
+ v.add_argument("--dir", help="Worklog directory (default: <repo>/.git-worklog).")
68
+
69
+ a = sub.add_parser("analyze", help="Prepare per-day analysis tasks, and collect them back.")
70
+ asub = a.add_subparsers(dest="analyze_command", metavar="<prepare|collect>",
71
+ required=True)
72
+
73
+ prep = asub.add_parser("prepare", help="Mint a run and write one manifest per day.")
74
+ prep.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
75
+ help="Date shortcut: NNd (last N days, e.g. 7d) or a bare "
76
+ "YYYY-MM-DD. Alternative to the flags below.")
77
+ prep.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
78
+ prep.add_argument("--days", type=int, metavar="N",
79
+ help="The last N calendar days, including today.")
80
+ prep.add_argument("--from", metavar="DATE",
81
+ help="First day to analyse (YYYY-MM-DD, inclusive). Needs --to.")
82
+ prep.add_argument("--to", metavar="DATE",
83
+ help="Last day to analyse (YYYY-MM-DD, inclusive). Needs --from.")
84
+ prep.add_argument("--timezone", metavar="TZ",
85
+ help="IANA timezone deciding where each day starts. Detected "
86
+ "from the environment when omitted; the run reports "
87
+ "which zone it used and where that came from.")
88
+ prep.add_argument("--today", metavar="DATE",
89
+ help="Override today's date (YYYY-MM-DD) for deterministic "
90
+ "runs. Affects NNd/--days and where uncommitted work lands.")
91
+ prep.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
92
+ prep.add_argument("--dir", help="Worklog directory, read for its config.json "
93
+ f"language setting (default: ./{wm.WORKLOG_DIRNAME}).")
94
+ prep.add_argument("--run-dir", help="Override the run directory (default: "
95
+ "~/.git-worklog/analysis/<run_id>).")
96
+ prep.add_argument("--date-field", choices=["committer", "author"],
97
+ default="committer",
98
+ help="Which date decides day attribution (default: committer).")
99
+ prep.add_argument("--worklog-dir", default=wm.WORKLOG_DIRNAME,
100
+ help="Worklog output directory; commits touching only this "
101
+ "directory are excluded as self-referential.")
102
+ prep.add_argument("--include-uncommitted", action="store_true",
103
+ help="Also hand the subagent the working tree's uncommitted "
104
+ "changes. They are attributed to today and to no other "
105
+ "day; if today is outside --from/--to they are left out "
106
+ "and the run says so.")
107
+ prep.add_argument("--host", metavar="KEY",
108
+ help="Detected agent host (anthropic / openai / google). "
109
+ "Resolves the subagent model from the packaged config, "
110
+ "honouring $GIT_WORKLOG_<HOST>_MODEL and --model. The "
111
+ "host is never guessed: unknown or unconfigured is an "
112
+ "error, not a fallback to the first provider.")
113
+ prep.add_argument("--model", default="",
114
+ help="Explicit model id, the highest-precedence override. "
115
+ "Needs --host.")
116
+ prep.add_argument("--escalate", action="store_true",
117
+ help="Use the host's escalation_model_id. Opt-in only, after "
118
+ "the user approves an escalation re-run. Needs --host.")
119
+ prep.add_argument("--provider", default=None,
120
+ help="Subagent provider key, stated rather than resolved "
121
+ "(default: anthropic). Use --host instead unless you "
122
+ "already hold the model object.")
123
+ prep.add_argument("--model-json", default="",
124
+ help="Structured model object (JSON: {display_name, "
125
+ "model_id[, reasoning_effort]}). Goes with --provider.")
126
+ prep.add_argument("--language", default="auto",
127
+ help="Content language for the worklog as a BCP 47 tag "
128
+ "(zh-TW, en, ja), or 'auto' to fall through to project "
129
+ "config and GIT_WORKLOG_LANGUAGE. Resolved once and "
130
+ "stamped on every manifest in the run.")
131
+ prep.add_argument("--language-source", default=None,
132
+ help="Where --language came from, so the manifest records "
133
+ "why and not just what: user-request, agent-host, "
134
+ "conversation, cli-argument, project-config, "
135
+ "environment, system-locale, fallback.")
136
+
137
+ coll = asub.add_parser("collect", help="Read and validate a prepared run's results.")
138
+ coll.add_argument("--run-id", help="The run to collect (from `analyze prepare`).")
139
+ coll.add_argument("--run-dir", help="The run directory, if it is not "
140
+ "~/.git-worklog/analysis/<run-id>.")
141
+ coll.add_argument("--repo", default=".",
142
+ help="Repository the evidence cites. Every entry is checked "
143
+ "against the tree of the commit it names — not the "
144
+ "checkout, which holds everything changed since.")
145
+
146
+ pv = sub.add_parser("preview", help="Freeze what an apply would write.")
147
+ pv.add_argument("--run-id", help="The collected run to preview (from "
148
+ "`analyze prepare`).")
149
+ pv.add_argument("--run-dir", help="The run directory, if it is not "
150
+ "~/.git-worklog/analysis/<run-id>.")
151
+ pv.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
152
+ pv.add_argument("--dir", help=f"Worklog directory (default: <repo>/{wm.WORKLOG_DIRNAME}).")
153
+ pv.add_argument("--input", help="Rendered day files as JSON, or '-' / omit "
154
+ "for stdin: {\"entries\": {\"<date>\": "
155
+ "{\"generated_markdown\": \"...\"}}}.")
156
+ # Defaulted by the engine, not here: reading the real default would mean
157
+ # importing git_worklog.preview (and the writer, and Git) on every run,
158
+ # including `version`, which this parser is built for too.
159
+ pv.add_argument("--ttl-seconds", type=int, default=None,
160
+ help="How long the preview stays applicable (default: 24h).")
161
+ pv.add_argument("--show", metavar="PREVIEW_ID",
162
+ help="Print a stored preview instead of creating one.")
163
+ pv.add_argument("--check", action="store_true",
164
+ help="With --show, also re-read the repository and worklog "
165
+ "to report whether the preview is still applicable.")
166
+ pv.add_argument("--cancel", metavar="PREVIEW_ID",
167
+ help="Retire a preview instead of creating one.")
168
+ pv.add_argument("--now", help="Override current time (ISO 8601) for "
169
+ "deterministic runs.")
170
+
171
+ ap = sub.add_parser("apply", help="Write a preview's stored payload.")
172
+ ap.add_argument("--preview-id", required=True,
173
+ help="The preview to apply. This is the only input: the "
174
+ "content comes from the record, never from the caller.")
175
+ ap.add_argument("--now", help="Override current time (ISO 8601) for "
176
+ "deterministic runs.")
177
+
178
+ cov = sub.add_parser("coverage",
179
+ help="Which dates have a worklog behind them (report mode).")
180
+ # Two ways in, because report mode has two scopes: a date scope is a range,
181
+ # a ref scope is whatever days a tag's commits landed on — an arbitrary set.
182
+ cov.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
183
+ help="Date shortcut: NNd or a bare YYYY-MM-DD.")
184
+ cov.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
185
+ cov.add_argument("--days", type=int, metavar="N",
186
+ help="The last N calendar days, including today.")
187
+ cov.add_argument("--from", metavar="DATE", help="Range start (inclusive).")
188
+ cov.add_argument("--to", metavar="DATE", help="Range end (inclusive).")
189
+ cov.add_argument("--dates", metavar="LIST",
190
+ help="Exact days as a comma-separated list, e.g. "
191
+ "2026-07-01,2026-07-02. For a ref scope, whose dates "
192
+ "are a set with gaps rather than a span.")
193
+ cov.add_argument("--today", metavar="DATE",
194
+ help="Override today's date for deterministic runs.")
195
+ # Defaulted by the engine, not here: naming the number would mean importing
196
+ # analysis.coverage (and history, and Git) to build a parser that `version`
197
+ # also uses. Same reason as --ttl-seconds below.
198
+ cov.add_argument("--max-days", type=int, default=None,
199
+ help="Maximum span in calendar days (default: 90). Higher "
200
+ "than generation's 30: report mode reads day files and "
201
+ "spawns no subagents, so the cost that cap bounds is "
202
+ "not at stake.")
203
+ cov.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
204
+ cov.add_argument("--dir", default=wm.WORKLOG_DIRNAME,
205
+ help="Worklog directory, absolute or relative to the repo "
206
+ f"root (default: {wm.WORKLOG_DIRNAME}).")
207
+ cov.add_argument("--timezone", default=None,
208
+ help="IANA timezone deciding each day's bounds. Detected "
209
+ "from the environment when omitted; with --dates it "
210
+ "defaults to UTC.")
211
+ cov.add_argument("--date-field", choices=["committer", "author"],
212
+ default="committer",
213
+ help="Which date decides day attribution (default: committer).")
214
+ cov.add_argument("--worklog-dir", default=None,
215
+ help="Repo-relative worklog path whose commits count as "
216
+ "self-referential and are excluded from the counts.")
217
+
218
+ rf = sub.add_parser("refs",
219
+ help="Resolve a tag into its commit set (report mode).")
220
+ rf.add_argument("--tag", help="Tag to report on; its predecessor is found "
221
+ "automatically.")
222
+ rf.add_argument("--from-ref", help="Explicit range start, exclusive (any ref).")
223
+ rf.add_argument("--to-ref", help="Explicit range end, inclusive (any ref).")
224
+ rf.add_argument("--list-tags", action="store_true",
225
+ help="List the repository's tags, newest-first.")
226
+ rf.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
227
+ rf.add_argument("--timezone", default="UTC",
228
+ help="IANA timezone deciding each commit's calendar day "
229
+ "(default: UTC).")
230
+ rf.add_argument("--date-field", choices=["committer", "author"],
231
+ default="committer",
232
+ help="Which date decides day attribution (default: committer).")
233
+
234
+ rp = sub.add_parser("report",
235
+ help="Settle a report's scope, coverage and language "
236
+ "before it is written (report mode).")
237
+ # The same two scopes as `coverage`, for the same reason, plus the ref flags
238
+ # of `refs`: this is the one call that needs both halves at once, because
239
+ # reconciling a tag's commits against the day files is impossible with
240
+ # either alone.
241
+ rp.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
242
+ help="Date shortcut: NNd or a bare YYYY-MM-DD.")
243
+ rp.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
244
+ rp.add_argument("--days", type=int, metavar="N",
245
+ help="The last N calendar days, including today.")
246
+ rp.add_argument("--from", metavar="DATE", help="Range start (inclusive).")
247
+ rp.add_argument("--to", metavar="DATE", help="Range end (inclusive).")
248
+ rp.add_argument("--tag", help="Report on a tag; its predecessor is found "
249
+ "automatically. The commit set is the scope, "
250
+ "not the dates it spans.")
251
+ rp.add_argument("--from-ref", help="Explicit range start, exclusive (any ref).")
252
+ rp.add_argument("--to-ref", help="Explicit range end, inclusive (any ref).")
253
+ rp.add_argument("--language", default="auto",
254
+ help="Language to write the report in, as a BCP 47 tag. "
255
+ "Independent of the language the day files are written "
256
+ "in: zh-TW worklogs can produce an English release "
257
+ "report (default: auto).")
258
+ rp.add_argument("--language-source", default=None,
259
+ help="Where --language came from, so the payload records "
260
+ "whether a user asked or a default applied.")
261
+ rp.add_argument("--today", metavar="DATE",
262
+ help="Override today's date for deterministic runs.")
263
+ rp.add_argument("--max-days", type=int, default=None,
264
+ help="Maximum span in calendar days (default: 90), as "
265
+ "`coverage`. Date scope only.")
266
+ rp.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
267
+ rp.add_argument("--dir", default=wm.WORKLOG_DIRNAME,
268
+ help="Worklog directory, absolute or relative to the repo "
269
+ f"root (default: {wm.WORKLOG_DIRNAME}).")
270
+ rp.add_argument("--timezone", default=None,
271
+ help="IANA timezone deciding each day's bounds. Detected "
272
+ "from the environment when omitted; for a ref scope it "
273
+ "defaults to UTC.")
274
+ rp.add_argument("--date-field", choices=["committer", "author"],
275
+ default="committer",
276
+ help="Which date decides day attribution (default: committer).")
277
+ rp.add_argument("--worklog-dir", default=None,
278
+ help="Repo-relative worklog path whose commits count as "
279
+ "self-referential and are excluded from the counts.")
280
+
281
+ mg = sub.add_parser("migrate",
282
+ help="Move a legacy worklog into .git-worklog/ (dry-run "
283
+ "by default).")
284
+ mg.add_argument("--from-dir", dest="from_dir",
285
+ help="Flat legacy worklog directory (v0.2-v0.5).")
286
+ mg.add_argument("--from-file", dest="from_file",
287
+ help="Single-file legacy worklog (pre-v0.2).")
288
+ mg.add_argument("--dir", help=f"Target worklog directory (default: "
289
+ f"{wm.WORKLOG_DIRNAME}).")
290
+ mg.add_argument("--timezone",
291
+ help="Timezone recorded in config.json, and in each day file's "
292
+ "header when migrating from a single file. Ignored for "
293
+ "--from-dir, whose day files already record their own.")
294
+ mg.add_argument("--apply", action="store_true",
295
+ help="Write the migration. Without this the run is a dry-run.")
296
+
297
+ ri = sub.add_parser("reindex",
298
+ help="Rebuild index.md from the day files (dry-run by "
299
+ "default). The repair for INDEX_WRITE_FAILED.")
300
+ ri.add_argument("--dir", help=f"Worklog directory (default: "
301
+ f"{wm.WORKLOG_DIRNAME}).")
302
+ ri.add_argument("--language", default=None,
303
+ help="Language for the index, used only when it does not "
304
+ "exist yet. An index that already has one keeps it.")
305
+ ri.add_argument("--apply", action="store_true",
306
+ help="Write index.md. Without this the run is a dry-run.")
307
+
308
+ md = sub.add_parser("models",
309
+ help="Show the user's subagent model table "
310
+ "(~/.git-worklog/provider_models.json), or change "
311
+ "one host's models in it (dry-run by default).")
312
+ md.add_argument("--host", help="The host whose models to change: "
313
+ "anthropic | openai | google.")
314
+ md.add_argument("--model", help="The host's default subagent model id.")
315
+ md.add_argument("--display-name", dest="display_name",
316
+ help="Human label for --model (default: the model id).")
317
+ md.add_argument("--reasoning-effort", dest="reasoning_effort",
318
+ help="Reasoning effort for --model (openai).")
319
+ md.add_argument("--escalation-model", dest="escalation_model",
320
+ help="The host's escalation model id.")
321
+ md.add_argument("--escalation-reasoning-effort", dest="escalation_reasoning_effort",
322
+ help="Reasoning effort for --escalation-model (openai).")
323
+ md.add_argument("--apply", action="store_true",
324
+ help="Write the change. Without this the run is a dry-run.")
325
+
326
+ vw = sub.add_parser("view",
327
+ help="Show the worklog as one HTML page in a browser "
328
+ "(read-only).")
329
+ vw.add_argument("--repo", default=".", help="Repository to show (default: cwd).")
330
+ vw.add_argument("--dir", help=f"Worklog directory (default: <repo>/{wm.WORKLOG_DIRNAME}).")
331
+ where = vw.add_mutually_exclusive_group()
332
+ where.add_argument("--output", metavar="FILE",
333
+ help="Where to write the page (default: ~/.git-worklog/view/"
334
+ "<project>-<hash>.html, the same file every run). "
335
+ "Refused inside the worklog directory.")
336
+ where.add_argument("--serve", action="store_true",
337
+ help="Serve the page on 127.0.0.1 instead of writing it, "
338
+ "so that it can show each commit's diff. Runs until "
339
+ "interrupted (Ctrl+C).")
340
+ vw.add_argument("--no-open", action="store_true",
341
+ help="Write the page without opening a browser.")
342
+ return p
343
+
344
+
345
+ def main(argv: "list[str] | None" = None) -> int:
346
+ parser = build_parser()
347
+ args = parser.parse_args(argv)
348
+ if not args.command:
349
+ parser.print_help()
350
+ return 0
351
+
352
+ # Imported lazily so `git-worklog version` stays fast and cannot be broken
353
+ # by an unrelated subcommand's import. Spelled out rather than defaulted:
354
+ # argparse already rejects an unknown command, so a name reaching here with
355
+ # no module is a wiring mistake, and it should say so instead of quietly
356
+ # running whichever subcommand happened to be the fallback.
357
+ if args.command == "version":
358
+ from git_worklog.cli import version as cmd
359
+ elif args.command == "doctor":
360
+ from git_worklog.cli import doctor as cmd
361
+ elif args.command == "validate":
362
+ from git_worklog.cli import validate as cmd
363
+ elif args.command == "analyze":
364
+ from git_worklog.cli import analyze as cmd
365
+ elif args.command == "preview":
366
+ from git_worklog.cli import preview as cmd
367
+ elif args.command == "apply":
368
+ from git_worklog.cli import apply as cmd
369
+ elif args.command == "coverage":
370
+ from git_worklog.cli import coverage as cmd
371
+ elif args.command == "refs":
372
+ from git_worklog.cli import refs as cmd
373
+ elif args.command == "report":
374
+ from git_worklog.cli import report as cmd
375
+ elif args.command == "migrate":
376
+ from git_worklog.cli import migrate as cmd
377
+ elif args.command == "reindex":
378
+ from git_worklog.cli import reindex as cmd
379
+ elif args.command == "models":
380
+ from git_worklog.cli import models as cmd
381
+ elif args.command == "view":
382
+ from git_worklog.cli import view as cmd
383
+ else:
384
+ raise AssertionError(f"no module wired for command {args.command!r}")
385
+
386
+ # §6.2.13 keeps interface language and content language apart. Phase one
387
+ # ships English messages only -- which the roadmap allows -- but an
388
+ # unsupported request is answered rather than ignored: silence would look
389
+ # like it worked.
390
+ try:
391
+ interface = language.resolve_interface(args.interface_language)
392
+ except language.LanguageError as exc:
393
+ _emit({"ok": False, "errors": [{"code": exc.code, "message": exc.message}]},
394
+ args.text, lambda p: f"error: {exc.message}\n")
395
+ return 2
396
+
397
+ try:
398
+ payload, code = cmd.run(args)
399
+ except Exception as exc: # never let a traceback replace the JSON contract
400
+ _emit({"ok": False, "errors": [{
401
+ "code": "UNEXPECTED_ERROR",
402
+ "message": f"{type(exc).__name__}: {exc}",
403
+ }]}, args.text, lambda p: f"error: {exc}\n")
404
+ return 2
405
+
406
+ if interface.warnings:
407
+ payload.setdefault("warnings", []).extend(interface.warnings)
408
+ payload.setdefault("interface_language", interface.resolved)
409
+
410
+ _emit(payload, args.text, cmd.render_text)
411
+ # `view --serve` answers first and then stays: the one command that outlives
412
+ # its own output.
413
+ after = getattr(cmd, "after_emit", None)
414
+ return after(args, payload, code) if after else code