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,348 @@
1
+ """``git-worklog doctor`` — can this environment actually run the tool?
2
+
3
+ Each check reports ``ok`` / ``warn`` / ``fail`` with the value it observed, so a
4
+ failure names the thing that is wrong rather than the thing that broke later.
5
+ ``fail`` means a real run would not work; ``warn`` means it would work but
6
+ something is worth knowing (a shallow clone limits history, a legacy layout
7
+ needs migrating).
8
+
9
+ The roadmap's §12.1 list is covered in full, language and index_language
10
+ included. A green check must never imply coverage it does not have, so anything
11
+ this command cannot check says so rather than being silently omitted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+
23
+ from git_worklog import config, language
24
+ from git_worklog import markers as wm
25
+ from git_worklog import paths, providers
26
+
27
+ # git log --since-as-filter, which the collector relies on, landed in 2.37.
28
+ MIN_GIT = (2, 37)
29
+ MIN_PYTHON = (3, 9)
30
+
31
+
32
+ def _check(name: str, status: str, detail: str, **extra) -> dict:
33
+ return {"check": name, "status": status, "detail": detail, **extra}
34
+
35
+
36
+ def _git(repo: str, *args: str) -> "tuple[int, str]":
37
+ try:
38
+ p = subprocess.run(["git", "-C", repo, *args],
39
+ capture_output=True, text=True)
40
+ return p.returncode, (p.stdout or p.stderr).strip()
41
+ except OSError as exc:
42
+ return 127, str(exc)
43
+
44
+
45
+ def _check_python() -> dict:
46
+ v = sys.version_info[:3]
47
+ got = ".".join(str(x) for x in v)
48
+ if v[:2] < MIN_PYTHON:
49
+ return _check("python", "fail",
50
+ f"Python {got} is below the {'.'.join(str(x) for x in MIN_PYTHON)} floor.",
51
+ value=got)
52
+ return _check("python", "ok", f"Python {got}.", value=got)
53
+
54
+
55
+ def _check_git_version() -> dict:
56
+ if not shutil.which("git"):
57
+ return _check("git", "fail", "git was not found on PATH.", value=None)
58
+ code, out = _git(".", "--version")
59
+ m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", out or "")
60
+ if code != 0 or not m:
61
+ return _check("git", "fail", f"Could not read the git version: {out}", value=None)
62
+ got = tuple(int(g) for g in m.groups(default="0"))
63
+ text = ".".join(str(x) for x in got)
64
+ if got[:2] < MIN_GIT:
65
+ return _check("git", "fail",
66
+ f"git {text} is below {'.'.join(str(x) for x in MIN_GIT)}; "
67
+ "`git log --since-as-filter` is unavailable.", value=text)
68
+ return _check("git", "ok", f"git {text}.", value=text)
69
+
70
+
71
+ def _check_repo(repo: str) -> "tuple[dict, bool]":
72
+ code, out = _git(repo, "rev-parse", "--is-inside-work-tree")
73
+ if code != 0 or out != "true":
74
+ return _check("repository", "fail",
75
+ f"{os.path.abspath(repo)} is not inside a Git work tree.",
76
+ value=os.path.abspath(repo)), False
77
+ return _check("repository", "ok", f"{os.path.abspath(repo)} is a Git work tree.",
78
+ value=os.path.abspath(repo)), True
79
+
80
+
81
+ def _check_shallow(repo: str) -> dict:
82
+ code, out = _git(repo, "rev-parse", "--is-shallow-repository")
83
+ if code != 0:
84
+ return _check("shallow_clone", "warn", "Could not determine clone depth.")
85
+ if out == "true":
86
+ # Not a failure: a shallow clone works, it just cannot see far back, and
87
+ # a day whose commits were truncated away would look empty rather than
88
+ # wrong. Better said out loud than discovered in a worklog.
89
+ return _check("shallow_clone", "warn",
90
+ "Shallow clone: history is truncated, so older days may "
91
+ "appear to have no commits. `git fetch --unshallow` to fix.",
92
+ value=True)
93
+ return _check("shallow_clone", "ok", "Full clone.", value=False)
94
+
95
+
96
+ def _check_worktree(repo: str) -> dict:
97
+ code, out = _git(repo, "status", "--porcelain")
98
+ if code != 0:
99
+ return _check("worktree", "warn", "Could not read the working tree status.")
100
+ n = len([ln for ln in out.splitlines() if ln.strip()])
101
+ if n:
102
+ return _check("worktree", "ok",
103
+ f"{n} uncommitted change(s). Analysis covers them only when "
104
+ "explicitly asked (include_uncommitted).", value=n)
105
+ return _check("worktree", "ok", "Clean.", value=0)
106
+
107
+
108
+ def _check_worklog_dir(worklog_dir: str) -> dict:
109
+ if not os.path.isdir(worklog_dir):
110
+ return _check("worklog_dir", "ok",
111
+ f"{worklog_dir} does not exist yet; it is created on first apply.",
112
+ value=None)
113
+ layout = wm.detect_layout(worklog_dir)
114
+ days = len(wm.list_day_dates(worklog_dir, layout))
115
+ if layout == wm.LAYOUT_LEGACY:
116
+ return _check("worklog_dir", "warn",
117
+ f"{worklog_dir} uses the pre-v0.6 flat layout ({days} day file(s) "
118
+ "at its root). It can be read but not written; run "
119
+ "migrate_legacy_worklog.py --from-dir to convert it.",
120
+ value=layout, day_count=days)
121
+ return _check("worklog_dir", "ok",
122
+ f"{worklog_dir}: {layout} layout, {days} day file(s).",
123
+ value=layout, day_count=days)
124
+
125
+
126
+ def _check_config(worklog_dir: str) -> dict:
127
+ path = wm.config_path(worklog_dir)
128
+ if not os.path.exists(path):
129
+ return _check("config", "ok",
130
+ "No config.json yet; it is written on first apply.", value=None)
131
+ try:
132
+ with open(path, "r", encoding="utf-8") as fh:
133
+ cfg = json.load(fh)
134
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
135
+ return _check("config", "fail", f"config.json is unreadable: {exc}", value=path)
136
+ if not isinstance(cfg, dict):
137
+ return _check("config", "fail", "config.json must contain an object.", value=path)
138
+ got = cfg.get("schema_version")
139
+ if got is None:
140
+ return _check("config", "warn", "config.json has no schema_version.", value=path)
141
+ if got > wm.LAYOUT_VERSION:
142
+ return _check("config", "fail",
143
+ f"config.json declares schema_version {got}, newer than this "
144
+ f"build understands ({wm.LAYOUT_VERSION}). Upgrade git-worklog.",
145
+ value=got)
146
+ return _check("config", "ok", f"config.json: schema_version {got}.", value=got)
147
+
148
+
149
+ def _check_language(worklog_dir: str) -> dict:
150
+ """Whether config's language setting is one this build can honour.
151
+
152
+ A bad tag here is worth surfacing now rather than at the point a run dies
153
+ on it: config.json is edited by hand, and the failure it causes otherwise
154
+ appears in the middle of an analysis with the diff already collected.
155
+ """
156
+ cfg = config.load(worklog_dir)
157
+ raw = cfg.get("language")
158
+ if raw is None:
159
+ return _check("language", "ok",
160
+ "No language set; it resolves per run.", value=None)
161
+ if isinstance(raw, str) and raw.strip().lower() == language.AUTO:
162
+ return _check("language", "ok",
163
+ "language: auto — resolved per run from the request, the "
164
+ "environment or English.", value="auto")
165
+ try:
166
+ tag = language.normalize(raw)
167
+ except language.LanguageError as exc:
168
+ return _check("language", "fail",
169
+ f"config.json language: {exc.message}", value=raw)
170
+ return _check("language", "ok", f"language: {tag}.", value=tag)
171
+
172
+
173
+ def _check_index_language(worklog_dir: str) -> dict:
174
+ """What the index is written in, and whether that is pinned or inherited."""
175
+ cfg = config.load(worklog_dir)
176
+ raw = cfg.get("index_language")
177
+ pinned = None
178
+ if isinstance(raw, str) and raw.strip() and raw.strip().lower() != language.AUTO:
179
+ try:
180
+ pinned = language.normalize(raw)
181
+ except language.LanguageError as exc:
182
+ return _check("index_language", "fail",
183
+ f"config.json index_language: {exc.message}", value=raw)
184
+
185
+ index_path = wm.index_path(worklog_dir)
186
+ stamped = None
187
+ if os.path.exists(index_path):
188
+ try:
189
+ with open(index_path, "r", encoding="utf-8") as fh:
190
+ stamped = wm.index_language_of(fh.read())
191
+ except (OSError, UnicodeDecodeError):
192
+ stamped = None # the index's own check owns reporting this
193
+
194
+ if pinned:
195
+ detail = f"index_language: {pinned} (pinned by config)."
196
+ if stamped and stamped != pinned:
197
+ detail += (f" The index on disk is {stamped} and will be rebuilt in "
198
+ f"{pinned}.")
199
+ return _check("index_language", "ok", detail, value=pinned)
200
+ if stamped:
201
+ return _check("index_language", "ok",
202
+ f"index_language: {stamped} (fixed when the index was "
203
+ f"first built).", value=stamped)
204
+ return _check("index_language", "ok",
205
+ "index_language: auto — the first build decides.", value="auto")
206
+
207
+
208
+ def _check_version_file(worklog_dir: str) -> dict:
209
+ path = wm.version_path(worklog_dir)
210
+ if not os.path.exists(path):
211
+ return _check("layout_version", "ok",
212
+ "No VERSION yet; it is written on first apply.", value=None)
213
+ try:
214
+ with open(path, "r", encoding="utf-8") as fh:
215
+ raw = fh.read().strip()
216
+ got = int(raw)
217
+ except (OSError, ValueError) as exc:
218
+ return _check("layout_version", "fail", f"VERSION is unreadable: {exc}", value=path)
219
+ if got > wm.LAYOUT_VERSION:
220
+ return _check("layout_version", "fail",
221
+ f"The worklog declares layout version {got}, newer than this "
222
+ f"build understands ({wm.LAYOUT_VERSION}). Upgrade git-worklog.",
223
+ value=got)
224
+ return _check("layout_version", "ok", f"Layout version {got}.", value=got)
225
+
226
+
227
+ def _check_state_dir() -> dict:
228
+ home = paths.home()
229
+ if not os.path.isdir(home):
230
+ return _check("state_dir", "ok",
231
+ f"{home} does not exist yet; it is created on first preview.",
232
+ value=home)
233
+ if not os.access(home, os.W_OK | os.X_OK):
234
+ return _check("state_dir", "fail",
235
+ f"{home} is not writable; previews cannot be recorded.", value=home)
236
+ mode = os.stat(home).st_mode & 0o777
237
+ if os.name == "posix" and mode & 0o077:
238
+ # These files quote source and diffs from private repositories.
239
+ return _check("state_dir", "warn",
240
+ f"{home} is mode {mode:03o}; it holds code excerpts, so 700 is "
241
+ "advisable.", value=home, mode=f"{mode:03o}")
242
+ return _check("state_dir", "ok", f"{home} is writable.", value=home,
243
+ mode=f"{mode:03o}")
244
+
245
+
246
+ def _check_legacy_state_dir() -> dict:
247
+ legacy = paths.legacy_home()
248
+ if os.path.isdir(legacy) and os.path.abspath(legacy) != os.path.abspath(paths.home()):
249
+ return _check("legacy_state_dir", "warn",
250
+ f"{legacy} is left over from before v0.7. Nothing reads it now — "
251
+ "previews there have long expired. Delete it when convenient.",
252
+ value=legacy)
253
+ return _check("legacy_state_dir", "ok", "No leftover state directory.", value=None)
254
+
255
+
256
+ def _check_provider_models() -> dict:
257
+ """The user's model table against the defaults this version ships.
258
+
259
+ Differing is not a fault -- editing that file is how a model is changed --
260
+ so it is a warning that names each difference: the same list serves someone
261
+ who edited on purpose and someone whose copy an upgrade has left behind.
262
+ """
263
+ path = paths.models_path()
264
+ if not os.path.exists(path):
265
+ return _check("provider_models", "ok",
266
+ f"{path} does not exist yet; it is created from the shipped "
267
+ "defaults the first time a model is resolved. Those may be "
268
+ "older than what your host offers: `git-worklog models` sets "
269
+ "the current ones.", value=path)
270
+ try:
271
+ user = providers.load_config(path)
272
+ except providers.ProviderError as exc:
273
+ return _check("provider_models", "fail",
274
+ f"{exc.message} Fix it, or delete it to start again from the "
275
+ "shipped defaults.", value=path)
276
+ differ = providers.differences(user, providers.load_config())
277
+ if not differ:
278
+ return _check("provider_models", "ok",
279
+ f"{path} matches the shipped defaults.", value=path)
280
+ listed = "; ".join(f"{d['provider']}.{d['field']}: yours {d['yours']!r}, "
281
+ f"shipped {d['shipped']!r}" for d in differ)
282
+ return _check("provider_models", "warn",
283
+ f"{path} differs from the shipped defaults ({listed}). Your file "
284
+ "is the one used; edit it if these are not the models you want.",
285
+ value=path, differences=differ)
286
+
287
+
288
+ def _check_skill_compat() -> dict:
289
+ from git_worklog.cli.version import SKILL_COMPAT_VERSION
290
+ skill_md = os.path.join(os.path.dirname(os.path.dirname(
291
+ os.path.dirname(os.path.abspath(__file__)))), "SKILL.md")
292
+ if not os.path.exists(skill_md):
293
+ # A pip-only install has no skill body; that is a valid way to use the CLI.
294
+ return _check("skill_compat", "ok",
295
+ "No SKILL.md alongside the package (CLI-only install).",
296
+ value=None)
297
+ return _check("skill_compat", "ok",
298
+ f"Skill body found; compat version {SKILL_COMPAT_VERSION}.",
299
+ value=SKILL_COMPAT_VERSION)
300
+
301
+
302
+ def run(args) -> "tuple[dict, int]":
303
+ repo = args.repo or "."
304
+ worklog_dir = args.dir or os.path.join(repo, wm.WORKLOG_DIRNAME)
305
+
306
+ checks = [_check_python(), _check_git_version()]
307
+ repo_check, in_repo = _check_repo(repo)
308
+ checks.append(repo_check)
309
+ if in_repo:
310
+ checks.append(_check_shallow(repo))
311
+ checks.append(_check_worktree(repo))
312
+ checks.append(_check_worklog_dir(worklog_dir))
313
+ checks.append(_check_version_file(worklog_dir))
314
+ checks.append(_check_config(worklog_dir))
315
+ checks.append(_check_state_dir())
316
+ checks.append(_check_legacy_state_dir())
317
+ checks.append(_check_provider_models())
318
+ checks.append(_check_skill_compat())
319
+ checks.append(_check_language(worklog_dir))
320
+ checks.append(_check_index_language(worklog_dir))
321
+
322
+ failed = [c for c in checks if c["status"] == "fail"]
323
+ warned = [c for c in checks if c["status"] == "warn"]
324
+ return {
325
+ "ok": not failed,
326
+ "repo": os.path.abspath(repo),
327
+ "worklog_dir": os.path.abspath(worklog_dir),
328
+ "checks": checks,
329
+ "failed": [c["check"] for c in failed],
330
+ "warnings": [c["check"] for c in warned],
331
+ }, (1 if failed else 0)
332
+
333
+
334
+ _GLYPH = {"ok": "✓", "warn": "!", "fail": "✗", "skipped": "-"}
335
+
336
+
337
+ def render_text(p: dict) -> str:
338
+ lines = [f"git-worklog doctor — {p['repo']}", ""]
339
+ for c in p["checks"]:
340
+ lines.append(f" {_GLYPH.get(c['status'], '?')} {c['check']:<18} {c['detail']}")
341
+ lines.append("")
342
+ if p["failed"]:
343
+ lines.append(f"FAILED: {', '.join(p['failed'])}")
344
+ elif p["warnings"]:
345
+ lines.append(f"OK, with warnings: {', '.join(p['warnings'])}")
346
+ else:
347
+ lines.append("All checks passed.")
348
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,43 @@
1
+ """``git-worklog migrate`` — move a legacy worklog into ``.git-worklog/``.
2
+
3
+ On the CLI surface since roadmap §2.4, and never part of a normal run: it is
4
+ invoked explicitly or not at all. Dry-run is the default, which is why ``--apply``
5
+ exists rather than a ``--dry-run`` flag — the safe direction is the one you get
6
+ by not thinking about it.
7
+
8
+ It never deletes the source and never overwrites a day file that already exists.
9
+ Corrupt legacy markers end the run rather than produce a half-migrated worklog:
10
+ the engine (:mod:`git_worklog.migrate`) refuses instead of guessing a repair.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from git_worklog import migrate as engine
16
+
17
+
18
+ def run(args) -> "tuple[dict, int]":
19
+ try:
20
+ return engine.run(from_dir=args.from_dir, from_file=args.from_file,
21
+ dir=args.dir, timezone=args.timezone,
22
+ apply=args.apply), 0
23
+ except engine.MigrateError as exc:
24
+ return {"ok": False, "errors": [exc.as_error()]}, 2
25
+
26
+
27
+ def render_text(p: dict) -> str:
28
+ if not p.get("ok"):
29
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
30
+
31
+ lines = [f"git-worklog migrate — {p['mode']}\n"]
32
+ lines.append(f" source : {p['source']} ({p['source_kind']})\n")
33
+ lines.append(f" target : {p['worklog_dir']}\n\n")
34
+
35
+ for change in p["planned_changes"]:
36
+ mark = "+" if change["action"] == "create" else "="
37
+ note = "" if change["action"] == "create" else " (exists, left alone)"
38
+ lines.append(f" {mark} {change['date']}{note}\n")
39
+ if not p["planned_changes"]:
40
+ lines.append(" nothing to migrate\n")
41
+
42
+ lines.append(f"\n{p['note']}\n")
43
+ return "".join(lines)
@@ -0,0 +1,112 @@
1
+ """``git-worklog models`` — read or change the user's subagent model table.
2
+
3
+ The table is ``~/.git-worklog/provider_models.json``
4
+ (:mod:`git_worklog.providers`): the user's file, seeded from the shipped
5
+ defaults and never rewritten behind their back. This command is the same edit
6
+ they could make by hand, for when the one making it is a coding agent that
7
+ knows which models its own host currently offers -- so a new model does not
8
+ wait for a release of this tool.
9
+
10
+ With no ``--host`` it only reports: where the file is, what each host resolves
11
+ to, and where that differs from the shipped defaults. With ``--host`` and at
12
+ least one field it shows the change, and writes it only with ``--apply`` --
13
+ dry-run first, like every other command here that writes. It changes the one
14
+ host it is given and leaves every other key of the file as it found it.
15
+
16
+ It does not check that a model id exists. Only the host can say that, at
17
+ dispatch time, and an id it rejects still halts the run and asks
18
+ (``model_unavailable_policy``); nothing is substituted.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+
25
+ from git_worklog import paths, providers
26
+
27
+ # argparse destination -> the table's field.
28
+ FIELDS = (
29
+ ("model", "model_id"),
30
+ ("display_name", "display_name"),
31
+ ("reasoning_effort", "reasoning_effort"),
32
+ ("escalation_model", "escalation_model_id"),
33
+ ("escalation_reasoning_effort", "escalation_reasoning_effort"),
34
+ )
35
+
36
+
37
+ NOT_CREATED = ("The file does not exist yet, so these are the defaults shipped with "
38
+ "this version and may be older than the models the host offers now. "
39
+ "Set the current ones with --host <host> --model <id> --apply.")
40
+
41
+
42
+ def _fail(code: str, message: str, **extra) -> "tuple[dict, int]":
43
+ return {"ok": False, "errors": [{"code": code, "message": message, **extra}]}, 2
44
+
45
+
46
+ def run(args) -> "tuple[dict, int]":
47
+ path = paths.models_path()
48
+ exists = os.path.exists(path)
49
+ try:
50
+ shipped = providers.load_config()
51
+ current = providers.load_config(path) if exists else providers.seed()
52
+ except providers.ProviderError as exc:
53
+ return {"ok": False, "errors": [exc.as_error()]}, 2
54
+
55
+ changes = {field: getattr(args, dest) for dest, field in FIELDS if getattr(args, dest)}
56
+ common = {"ok": True, "path": path, "exists": exists}
57
+ if not args.host:
58
+ if changes or args.apply:
59
+ return _fail("ARG_CONFLICT", "--host says whose model to change; "
60
+ "without it this command only reports.")
61
+ return {**common, "mode": "show", "providers": current["providers"],
62
+ "differences": providers.differences(current, shipped),
63
+ **({} if exists else {"note": NOT_CREATED})}, 0
64
+ if args.host not in providers.VALID_HOSTS:
65
+ return _fail("UNKNOWN_HOST", f"Unknown host '{args.host}'.",
66
+ requested_host=args.host, valid_hosts=list(providers.VALID_HOSTS))
67
+ if not changes:
68
+ return _fail("ARG_CONFLICT", "Nothing to change: pass --model, "
69
+ "--escalation-model or another field.")
70
+
71
+ before = dict(current["providers"].get(args.host) or {})
72
+ # A new default model under the old model's name would be a label that lies.
73
+ if "model_id" in changes and "display_name" not in changes \
74
+ and changes["model_id"] != before.get("model_id"):
75
+ changes["display_name"] = changes["model_id"]
76
+ after = dict(before, **changes)
77
+ changed = sorted(f for f in after if after[f] != before.get(f))
78
+ payload = {**common, "host": args.host, "before": before, "after": after,
79
+ "changed": changed}
80
+ if not args.apply:
81
+ return {**payload, "mode": "dry-run",
82
+ "note": "Nothing was written. Re-run with --apply to write it."}, 0
83
+ if changed or not exists:
84
+ updated = dict(current, providers=dict(current["providers"], **{args.host: after}))
85
+ try:
86
+ providers.write_user_config(updated)
87
+ except OSError as exc:
88
+ return _fail("IO_ERROR", f"{path} could not be written: {exc}", target=path)
89
+ return {**payload, "mode": "apply", "exists": True}, 0
90
+
91
+
92
+ def render_text(p: dict) -> str:
93
+ if not p.get("ok"):
94
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
95
+ lines = [f"git-worklog models — {p['path']}"
96
+ + ("" if p["exists"] else " (not created yet; shipped defaults shown)")]
97
+ if p["mode"] == "show":
98
+ for host, entry in p["providers"].items():
99
+ lines.append(f" {host}: {entry.get('model_id')}"
100
+ f" (escalation: {entry.get('escalation_model_id')})")
101
+ lines.extend(f" differs from shipped — {d['provider']}.{d['field']}: "
102
+ f"yours {d['yours']!r}, shipped {d['shipped']!r}"
103
+ for d in p["differences"])
104
+ return "\n".join(lines) + "\n"
105
+ for field in p["changed"]:
106
+ lines.append(f" {p['host']}.{field}: {p['before'].get(field)!r} -> "
107
+ f"{p['after'][field]!r}")
108
+ if not p["changed"]:
109
+ lines.append(f" {p['host']}: already set; nothing to change")
110
+ lines.append(" written" if p["mode"] == "apply"
111
+ else " dry-run: nothing written (re-run with --apply)")
112
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,124 @@
1
+ """``git-worklog preview`` — freeze what apply will write (roadmap §10.2).
2
+
3
+ Takes a collected run and the day prose the agent's LLM wrote for it, computes
4
+ every target file's final text, and stores the lot as one immutable record. What
5
+ comes back is a summary and a ``preview_id``; the bytes are on disk, not in the
6
+ conversation, and :mod:`git_worklog.cli.apply` writes them from there.
7
+
8
+ ``--show`` and ``--cancel`` share this command because they act on the thing it
9
+ made. Creation is the bare form, matching §10.2, because it is the one on the
10
+ hot path — a skill runs it every time and reads the other two almost never.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ from git_worklog import preview as pv
20
+ from git_worklog import writer
21
+ from git_worklog.analysis import AnalysisError
22
+ from git_worklog.analysis import results as ar
23
+
24
+
25
+ def _load_entries(path: "str | None") -> dict:
26
+ # Reading a TTY here would hang waiting for a human who was never asked to
27
+ # type a worklog. The same guard rebuild_worklog_index.py needs.
28
+ if path is None and sys.stdin.isatty():
29
+ return {}
30
+ raw = (open(path, "r", encoding="utf-8").read()
31
+ if path and path != "-" else sys.stdin.read())
32
+ if not raw.strip():
33
+ return {}
34
+ payload = json.loads(raw)
35
+ entries = payload.get("entries")
36
+ if not isinstance(entries, dict):
37
+ raise pv.PreviewError(
38
+ "NO_ENTRIES",
39
+ "Pass the rendered day files as {\"entries\": {\"<date>\": "
40
+ "{\"generated_markdown\": \"...\"}}} on stdin or via --input.")
41
+ return entries
42
+
43
+
44
+ def _run_dir_for(args) -> str:
45
+ if args.run_dir:
46
+ return args.run_dir
47
+ if not args.run_id:
48
+ raise AnalysisError("NO_RUN",
49
+ "Pass --run-id (from `analyze prepare`) or --run-dir.")
50
+ return os.path.join(ar.analysis_dir(), args.run_id)
51
+
52
+
53
+ def _create(args) -> "tuple[dict, int]":
54
+ entries = _load_entries(args.input)
55
+ record = pv.build(
56
+ run_dir=_run_dir_for(args), entries=entries, repo=args.repo,
57
+ worklog_dir=args.dir, ttl_seconds=args.ttl_seconds,
58
+ now=pv.now_utc(args.now),
59
+ )
60
+ state_path = pv.save(record)
61
+ payload = pv.public(record)
62
+ payload.update({"ok": True, "state_path": state_path})
63
+ # The full text of every file, returned once so the caller can show the user
64
+ # what they are approving. Apply never asks for it again.
65
+ payload["previews"] = {d["date"]: d["content"] for d in record["payload"]["days"]}
66
+ payload["index_preview"] = record["payload"]["index"]["content"]
67
+ return payload, 0
68
+
69
+
70
+ def _show(args) -> "tuple[dict, int]":
71
+ record = pv.load(args.show)
72
+ verdict = pv.evaluate(record, pv.snapshot(record) if args.check else None,
73
+ pv.now_utc(args.now))
74
+ payload = pv.public(record)
75
+ payload.update({"ok": True, **verdict})
76
+ return payload, (0 if verdict["applicable"] or not args.check else 1)
77
+
78
+
79
+ def _cancel(args) -> "tuple[dict, int]":
80
+ record = pv.cancel(pv.load(args.cancel), pv.now_utc(args.now))
81
+ payload = pv.public(record)
82
+ payload.update({"ok": True})
83
+ return payload, 0
84
+
85
+
86
+ def run(args) -> "tuple[dict, int]":
87
+ try:
88
+ if args.show:
89
+ return _show(args)
90
+ if args.cancel:
91
+ return _cancel(args)
92
+ return _create(args)
93
+ except (pv.PreviewError, writer.WriterError, AnalysisError) as exc:
94
+ return {"ok": False, "errors": [
95
+ {"code": exc.code, "message": exc.message, **exc.extra}]}, 2
96
+ except json.JSONDecodeError as exc:
97
+ return {"ok": False, "errors": [
98
+ {"code": "IO_ERROR", "message": f"Entries are not valid JSON: {exc}"}]}, 2
99
+
100
+
101
+ def render_text(p: dict) -> str:
102
+ if not p.get("ok"):
103
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
104
+
105
+ lines = [f"preview {p['preview_id']} ({p.get('state', pv.PREVIEWED)})\n"]
106
+ lang = p.get("language") or {}
107
+ if lang.get("resolved"):
108
+ lines.append(f" language : {lang['resolved']} (via {lang.get('source')})\n")
109
+ lines.append(f" expires : {p['expires_at']}\n")
110
+ if p.get("reason"):
111
+ lines.append(f" reason : {p['reason']}\n")
112
+ lines.append("\n")
113
+ for f in p.get("files", []):
114
+ lines.append(f" {f['action']:9} {f['path']}\n")
115
+ if p.get("not_written"):
116
+ lines.append(f"\n analysed but not written: {', '.join(p['not_written'])}\n")
117
+ for w in p.get("warnings", []):
118
+ lines.append(f" warning: {w.get('message', w)}\n")
119
+ for m in p.get("mismatches", []):
120
+ lines.append(f" changed: {m['field']}\n")
121
+ if p.get("state_path"):
122
+ lines.append("\nNo files have been modified. Apply with "
123
+ f"`git-worklog apply --preview-id {p['preview_id']}`.\n")
124
+ return "".join(lines)