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
git_worklog/preview.py ADDED
@@ -0,0 +1,776 @@
1
+ """The preview record: what apply will write, decided before anyone confirms it.
2
+
3
+ A preview used to be a *fingerprint* — a hash of everything that must not change
4
+ between the dry-run and the apply — while the content itself stayed in the
5
+ agent's conversation and was handed back at apply time. That makes the guarantee
6
+ backwards: the tool could prove the world had not moved, but not that the bytes
7
+ it wrote were the bytes the user saw. Anything that re-renders between the two
8
+ (a re-run subagent, a lost message, a different model) produces a plausible
9
+ worklog nobody approved, and every check still passes.
10
+
11
+ So the record here is the artifact, not a receipt for one (roadmap §10.1). It
12
+ carries the complete final text of every file the apply will write, and
13
+ ``apply`` writes *that* — it takes a preview id and nothing else. There is no
14
+ input at apply time for a re-render to come in through.
15
+
16
+ What is stored, and why each part is here:
17
+
18
+ * **The payload** — every day file's and index.md's final text, plus their
19
+ hashes. This is the whole point.
20
+ * **Repository fingerprint** — identity, git dir, branch, HEAD, submodules and
21
+ (only when the run read it) the working tree. Answers "is this the same
22
+ repository, in the same state, as the one that was analysed?"
23
+ * **Worklog fingerprint** — index.md's hash, each target day file's hash, and
24
+ the day-file listing. Answers "has anyone edited the worklog since?" An added
25
+ or removed day file counts: the index is a function of the whole directory.
26
+ * **Run fingerprint** — the hashes of the analysis run's manifests and results.
27
+ Answers "is this still the analysis that produced this payload?"
28
+ * **Config fingerprint** — the project's language settings. They are read at
29
+ preview time to decide what gets rendered, so a change to them between preview
30
+ and apply means the stored payload is no longer what the project asked for.
31
+ * **TTL and state** — below.
32
+
33
+ The state machine (roadmap §17, PR 6)::
34
+
35
+ previewed ──(apply: verified)──▶ confirmed ──(write lands)──▶ applied
36
+ │ │
37
+ │ └──(write failed, rolled back)──▶ failed
38
+ ├──(cancel)──▶ cancelled
39
+ ├──(TTL passes)──▶ expired
40
+ └──(the world moved)──▶ stale
41
+
42
+ ``expired`` and ``stale`` are computed, never stored: they are facts about the
43
+ world now, not decisions the tool made, and reading a record must not rewrite
44
+ it. ``confirmed`` *is* stored, and stored before the write starts — a record
45
+ still sitting in ``confirmed`` means a process died mid-apply, which is the one
46
+ state worth refusing to guess about.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import errno
52
+ import hashlib
53
+ import json
54
+ import os
55
+ import socket
56
+ from datetime import datetime, timedelta, timezone
57
+
58
+ from git_worklog import __version__, config, paths, writer
59
+ from git_worklog import markers as wm
60
+ from git_worklog.analysis import RESULTS_SUBDIR, TASKS_SUBDIR
61
+ from git_worklog.analysis import SCHEMA_VERSION as ANALYSIS_SCHEMA_VERSION
62
+ from git_worklog.analysis import history as ah
63
+ from git_worklog.analysis import manifest as am
64
+ from git_worklog.analysis import results as ar
65
+ from git_worklog.analysis import worktree as aw
66
+
67
+ SCHEMA_VERSION = 1
68
+ DEFAULT_TTL_SECONDS = 24 * 3600
69
+
70
+ # Stored states.
71
+ PREVIEWED = "previewed"
72
+ CONFIRMED = "confirmed"
73
+ APPLIED = "applied"
74
+ CANCELLED = "cancelled"
75
+ FAILED = "failed"
76
+ # Computed states -- see the module docstring.
77
+ EXPIRED = "expired"
78
+ STALE = "stale"
79
+
80
+ #: States from which nothing further happens.
81
+ TERMINAL = {APPLIED, CANCELLED, FAILED}
82
+
83
+ MISSING = "missing"
84
+
85
+
86
+ class PreviewError(ValueError):
87
+ """A refused preview or apply, carrying the wire code its caller reports."""
88
+
89
+ def __init__(self, code: str, message: str, **extra):
90
+ self.code = code
91
+ self.message = message
92
+ self.extra = extra
93
+ super().__init__(message)
94
+
95
+
96
+ def _sha256(text: str) -> str:
97
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
98
+
99
+
100
+ def now_utc(override: "str | None" = None) -> datetime:
101
+ """Current time, or a caller-supplied one so tests are not clock-dependent."""
102
+ if override:
103
+ dt = datetime.fromisoformat(override)
104
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
105
+ return datetime.now(timezone.utc)
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Fingerprints — the three worlds a preview depends on
110
+ # --------------------------------------------------------------------------
111
+
112
+ def repository_fingerprint(repo: str, include_uncommitted: bool) -> dict:
113
+ """Everything about the repository the payload depends on."""
114
+ info = ah.repo_info(repo)
115
+ return {
116
+ "identity": ah.identity(repo),
117
+ "root": info["root"],
118
+ "git_dir": info["git_dir"],
119
+ "branch": info["branch"],
120
+ "head": info["head"],
121
+ "submodule_fingerprint": ah.submodule_fingerprint(repo),
122
+ # Only when the run actually read the working tree. A run that analysed
123
+ # commits alone cannot have its output changed by an unrelated edit, and
124
+ # invalidating its preview because someone saved a file would be a
125
+ # refusal with nothing behind it. This is narrower than a literal reading
126
+ # of §10.3's "Working Tree", and deliberately so.
127
+ "worktree_fingerprint": (aw.inspect(repo)["worktree_fingerprint"]
128
+ if include_uncommitted else None),
129
+ }
130
+
131
+
132
+ def worklog_fingerprint(worklog_dir: str, dates: "list[str]") -> dict:
133
+ """The worklog directory as it stands, in the three ways it can drift.
134
+
135
+ Per-date hashes catch an edit to a day this preview would overwrite;
136
+ ``index_sha256`` catches an edit to the index; ``dir_fingerprint`` catches a
137
+ day file appearing or vanishing, which changes the index without touching
138
+ anything this preview names.
139
+ """
140
+ layout = wm.detect_layout(worklog_dir)
141
+ day_files = {}
142
+ for date in dates:
143
+ path = wm.day_path(worklog_dir, date, layout)
144
+ day_files[date] = _file_sha256(path)
145
+ listing = "\x00".join(sorted(wm.list_day_dates(worklog_dir, layout)))
146
+ return {
147
+ "index_sha256": _file_sha256(wm.index_path(worklog_dir)),
148
+ "day_files": day_files,
149
+ "dir_fingerprint": _sha256(listing),
150
+ }
151
+
152
+
153
+ def _file_sha256(path: str) -> str:
154
+ """The file's hash, or ``"missing"``. Absent and empty must not collide."""
155
+ try:
156
+ with open(path, "rb") as fh:
157
+ return hashlib.sha256(fh.read()).hexdigest()
158
+ except OSError:
159
+ return MISSING
160
+
161
+
162
+ def _dir_fingerprint(directory: str) -> str:
163
+ """Hash every ``.json`` in ``directory``, by name and by content."""
164
+ h = hashlib.sha256()
165
+ try:
166
+ names = sorted(n for n in os.listdir(directory) if n.endswith(".json"))
167
+ except OSError:
168
+ return MISSING
169
+ for name in names:
170
+ h.update(name.encode("utf-8"))
171
+ h.update(b"\x00")
172
+ try:
173
+ with open(os.path.join(directory, name), "rb") as fh:
174
+ h.update(fh.read())
175
+ except OSError:
176
+ h.update(b"<unreadable>")
177
+ h.update(b"\x00")
178
+ return h.hexdigest()
179
+
180
+
181
+ def run_fingerprint(run_dir: str) -> dict:
182
+ """The analysis run's manifests and results, hashed separately.
183
+
184
+ Separately because the two say different things when they move: changed
185
+ results mean someone edited the analysis this payload was written from;
186
+ changed tasks mean the run was re-prepared, possibly asking for a different
187
+ language than the one the payload is written in (§6.2.10).
188
+ """
189
+ return {
190
+ "tasks_fingerprint": _dir_fingerprint(os.path.join(run_dir, TASKS_SUBDIR)),
191
+ "results_fingerprint": _dir_fingerprint(os.path.join(run_dir, RESULTS_SUBDIR)),
192
+ }
193
+
194
+
195
+ def config_fingerprint(worklog_dir: str) -> dict:
196
+ """The project settings that decide what a preview renders.
197
+
198
+ Both are read at preview time -- the content language feeds the run, the
199
+ index language decides index.md's headings -- so a change to either between
200
+ preview and apply means the stored bytes are no longer what the project is
201
+ asking for. Recomputing the payload instead would apply something nobody
202
+ saw, which is the failure this whole module exists to prevent.
203
+ """
204
+ data = config.load(worklog_dir)
205
+ return {"language": config.language(data),
206
+ "index_language": config.index_language(data)}
207
+
208
+
209
+ # --------------------------------------------------------------------------
210
+ # Building the record
211
+ # --------------------------------------------------------------------------
212
+
213
+ def build(run_dir: str, entries: dict, repo: str = ".",
214
+ worklog_dir: "str | None" = None,
215
+ ttl_seconds: "int | None" = None,
216
+ now: "datetime | None" = None) -> dict:
217
+ """Turn a collected run plus the day's rendered prose into a preview record.
218
+
219
+ ``entries`` is ``{date: {"generated_markdown": ...}}`` — the one thing the
220
+ CLI cannot produce, because writing it means reading patches and choosing
221
+ words (§6.1). It comes in here, once, before the user is shown anything, and
222
+ is never asked for again.
223
+ """
224
+ now = now or now_utc()
225
+ ttl_seconds = DEFAULT_TTL_SECONDS if ttl_seconds is None else ttl_seconds
226
+ if not isinstance(entries, dict) or not entries:
227
+ raise PreviewError("NO_ENTRIES",
228
+ "No entries provided, so there is nothing to preview.")
229
+
230
+ tasks = am.load_tasks(run_dir)
231
+ worklog_dir = worklog_dir or os.path.join(repo, wm.WORKLOG_DIRNAME)
232
+ writer.check_layout(worklog_dir)
233
+
234
+ # A day the run never dispatched has not been analysed, language-checked or
235
+ # evidence-checked. Writing it would put content into the worklog that no
236
+ # part of the pipeline ever looked at -- the same failure `collect` reports
237
+ # as `unknown`, arriving one step later.
238
+ unknown = sorted(set(entries) - set(tasks["dates"]))
239
+ if unknown:
240
+ raise PreviewError(
241
+ "UNKNOWN_DATE",
242
+ f"Run {os.path.basename(run_dir)} never analysed "
243
+ f"{', '.join(unknown)}, so there is no validated result behind "
244
+ f"{'them' if len(unknown) > 1 else 'it'}. Prepare a run that covers "
245
+ f"the dates you want to write.",
246
+ dates=unknown, run_dir=run_dir)
247
+
248
+ # Re-run collect's verdict rather than trust that it was run. `previewed` is
249
+ # only reachable from `collected`, and a preview that took the caller's word
250
+ # for it would be exactly the conversation-dependence this record removes.
251
+ collected = ar.read_run(tasks["results_dir"], tasks["dates"], repo,
252
+ tasks["language"], tasks["required_by_date"])
253
+ if collected["partial_run"]:
254
+ raise PreviewError(
255
+ "RUN_NOT_COLLECTED",
256
+ "This run is partial — some days are missing, invalid, degraded, or "
257
+ "disagree on language — so it cannot go to preview. Fix or re-run "
258
+ "those days first.",
259
+ run_dir=run_dir,
260
+ missing=collected["missing"], degraded=collected["degraded"],
261
+ invalid=[i["date"] for i in collected["invalid"]],
262
+ languages_seen=collected["languages_seen"])
263
+
264
+ info = ah.repo_info(repo)
265
+ meta = {"timezone": tasks["manifests"][tasks["dates"][0]].get("timezone"),
266
+ "branch": info["branch"], "head": info["short_head"]}
267
+ days = writer.plan_days(worklog_dir, entries, meta)
268
+ index = writer.plan_index(worklog_dir,
269
+ {d["date"]: d["summary"] for d in days},
270
+ tasks["language"])
271
+
272
+ include_uncommitted = _include_uncommitted(tasks)
273
+ dates = sorted(entries)
274
+ fingerprint_dates = sorted(set(tasks["dates"]) | set(dates))
275
+ preview_id = _mint_id(now, days, index)
276
+ record = {
277
+ "schema_version": SCHEMA_VERSION,
278
+ "preview_id": preview_id,
279
+ "state": PREVIEWED,
280
+ "created_at": now.isoformat(),
281
+ "expires_at": (now + timedelta(seconds=ttl_seconds)).isoformat(),
282
+ "ttl_seconds": ttl_seconds,
283
+ "confirmed_at": None,
284
+ "applied_at": None,
285
+ "cancelled_at": None,
286
+ "failure": None,
287
+ "versions": {
288
+ "cli": __version__,
289
+ "layout": wm.LAYOUT_VERSION,
290
+ "preview_schema": SCHEMA_VERSION,
291
+ "analysis_schema": ANALYSIS_SCHEMA_VERSION,
292
+ },
293
+ "run": {
294
+ "run_id": os.path.basename(run_dir.rstrip(os.sep)),
295
+ "run_dir": run_dir,
296
+ "dates": tasks["dates"],
297
+ **run_fingerprint(run_dir),
298
+ },
299
+ "repository": repository_fingerprint(repo, include_uncommitted),
300
+ "worklog": worklog_fingerprint(worklog_dir, fingerprint_dates),
301
+ "params": {
302
+ "worklog_dir": os.path.abspath(worklog_dir),
303
+ "timezone": meta["timezone"],
304
+ "include_uncommitted": include_uncommitted,
305
+ "fingerprint_dates": fingerprint_dates,
306
+ },
307
+ "language": {
308
+ "resolved": tasks["language"],
309
+ "source": _language_source(tasks),
310
+ },
311
+ "config": config_fingerprint(worklog_dir),
312
+ "payload": {
313
+ "days": [{
314
+ "date": d["date"],
315
+ "path": d["path"],
316
+ "action": d["action"],
317
+ "manual_preserved": d["manual_preserved"],
318
+ "summary": d["summary"],
319
+ "content": d["content"],
320
+ "content_sha256": _sha256(d["content"]),
321
+ "original_sha256": (_sha256(d["original"])
322
+ if d["original"] is not None else None),
323
+ } for d in days],
324
+ "index": {
325
+ "path": index["index_path"],
326
+ "action": index["action"],
327
+ "dates": index["dates"],
328
+ "preserved_manual": index["preserved_index_manual"],
329
+ "language": index["index_language"],
330
+ "language_source": index["index_language_source"],
331
+ "content": index["content"],
332
+ "content_sha256": _sha256(index["content"]),
333
+ "original_sha256": (_sha256(index["original"])
334
+ if index["original"] is not None else None),
335
+ },
336
+ },
337
+ # Days the run analysed but that this preview will not write. Usually
338
+ # correct -- a day with no changes gets no file (§6 of worklog-format) --
339
+ # but silently dropping a day the user expected is not something to find
340
+ # out after apply, so the number is put in front of them.
341
+ "not_written": sorted(set(tasks["dates"]) - set(dates)),
342
+ "warnings": _with_unverified(index["warnings"], collected),
343
+ }
344
+ return record
345
+
346
+
347
+ def _include_uncommitted(tasks: dict) -> bool:
348
+ return any(m.get("include_uncommitted") for m in tasks["manifests"].values())
349
+
350
+
351
+ def _language_source(tasks: dict) -> "str | None":
352
+ first = tasks["manifests"][tasks["dates"][0]]
353
+ return (first.get("language") or {}).get("source")
354
+
355
+
356
+ def _with_unverified(warnings: "list[dict]", collected: dict) -> "list[dict]":
357
+ """Carry the checks that could not be run through to the preview.
358
+
359
+ They do not block: "could not be checked" is not "found to be false". But a
360
+ preview that showed nothing of them would be the validator overstating what
361
+ it verified, which is the failure this whole layer exists to prevent.
362
+ """
363
+ out = list(warnings)
364
+ for day in collected.get("unverified", []):
365
+ for issue in day["issues"]:
366
+ out.append({"code": issue["code"],
367
+ "message": f"{day['date']}: {issue['message']}"})
368
+ return out
369
+
370
+
371
+ def _mint_id(now: datetime, days: "list[dict]", index: dict) -> str:
372
+ """Name the record after its payload *and* its moment.
373
+
374
+ The payload alone would be tempting -- identical bytes, identical id -- but
375
+ two previews of the same content would then collide on one file, and the
376
+ second would silently overwrite the first's state. An already-applied record
377
+ losing its "applied" that way is exactly the double-write the state machine
378
+ is there to stop.
379
+ """
380
+ basis = hashlib.sha256()
381
+ for d in days:
382
+ basis.update(f"{d['date']}\x00{d['content']}\x00".encode("utf-8"))
383
+ basis.update(index["content"].encode("utf-8"))
384
+ basis.update(now.isoformat().encode("utf-8"))
385
+ return f"rw-{now.strftime('%Y%m%d')}-{basis.hexdigest()[:6]}"
386
+
387
+
388
+ # --------------------------------------------------------------------------
389
+ # Storage
390
+ # --------------------------------------------------------------------------
391
+
392
+ def preview_path(preview_id: str) -> str:
393
+ return os.path.join(paths.previews_dir(), f"{preview_id}.json")
394
+
395
+
396
+ def save(record: dict) -> str:
397
+ """Write the record owner-only, atomically. Returns its path."""
398
+ paths.ensure_dir(paths.previews_dir())
399
+ path = preview_path(record["preview_id"])
400
+ tmp = f"{path}.tmp"
401
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
402
+ try:
403
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
404
+ json.dump(record, fh, ensure_ascii=False, indent=2)
405
+ fh.flush()
406
+ os.fsync(fh.fileno())
407
+ os.replace(tmp, path)
408
+ finally:
409
+ if os.path.exists(tmp):
410
+ os.unlink(tmp)
411
+ return path
412
+
413
+
414
+ def load(preview_id: str) -> dict:
415
+ path = preview_path(preview_id)
416
+ if not os.path.exists(path):
417
+ raise PreviewError("UNKNOWN_PREVIEW",
418
+ f"No preview found for id {preview_id!r}.",
419
+ preview_id=preview_id)
420
+ try:
421
+ with open(path, "r", encoding="utf-8") as fh:
422
+ record = json.load(fh)
423
+ except (OSError, ValueError) as exc:
424
+ raise PreviewError("PREVIEW_UNREADABLE",
425
+ f"Preview {preview_id} could not be read: {exc}",
426
+ preview_id=preview_id)
427
+ stored = record.get("schema_version")
428
+ if stored != SCHEMA_VERSION:
429
+ # A record from another version of this schema may not mean what this
430
+ # code would read it to mean, and a preview is not worth guessing about:
431
+ # rebuilding one is cheap and applying a misread one is not.
432
+ raise PreviewError(
433
+ "PREVIEW_SCHEMA_MISMATCH",
434
+ f"Preview {preview_id} uses schema version {stored}, but this CLI "
435
+ f"reads version {SCHEMA_VERSION}. Build a fresh preview.",
436
+ preview_id=preview_id, found=stored, expected=SCHEMA_VERSION)
437
+ return record
438
+
439
+
440
+ def public(record: dict) -> dict:
441
+ """The record without the payload text — what a caller wants to look at.
442
+
443
+ The file contents are the bulk of a record and are already on the user's
444
+ screen from the dry-run. Repeating them in every ``show`` turns a status
445
+ check into a wall of Markdown.
446
+ """
447
+ out = {k: v for k, v in record.items() if k != "payload"}
448
+ payload = record.get("payload") or {}
449
+ out["files"] = [
450
+ {"path": d["path"], "action": d["action"], "date": d["date"],
451
+ "sha256": d["content_sha256"]}
452
+ for d in payload.get("days", [])
453
+ ]
454
+ index = payload.get("index")
455
+ if index:
456
+ out["files"].append({"path": index["path"], "action": index["action"],
457
+ "sha256": index["content_sha256"]})
458
+ return out
459
+
460
+
461
+ # --------------------------------------------------------------------------
462
+ # The state machine
463
+ # --------------------------------------------------------------------------
464
+
465
+ # Compared between build-time and apply-time, with a label a human can act on.
466
+ # Each entry is a way the world can move underneath a stored payload; nothing
467
+ # the caller re-supplies is listed, because apply takes no input to re-supply it
468
+ # with -- that is the difference this record makes.
469
+ _CONSISTENCY_KEYS = [
470
+ ("repository", "identity", "repository identity"),
471
+ ("repository", "root", "repository"),
472
+ ("repository", "git_dir", "git directory"),
473
+ ("repository", "branch", "branch"),
474
+ ("repository", "head", "HEAD"),
475
+ ("repository", "submodule_fingerprint", "submodules"),
476
+ ("repository", "worktree_fingerprint", "working tree"),
477
+ ("worklog", "index_sha256", "index.md content"),
478
+ ("worklog", "day_files", "day files"),
479
+ ("worklog", "dir_fingerprint", "worklog directory listing"),
480
+ ("run", "tasks_fingerprint", "analysis tasks"),
481
+ ("run", "results_fingerprint", "analysis results"),
482
+ # §6.2.10: a project that changed its language between preview and apply is
483
+ # asking for a different worklog, not the same one rendered differently.
484
+ ("config", "language", "project language setting"),
485
+ ("config", "index_language", "project index language setting"),
486
+ ]
487
+
488
+
489
+ def snapshot(record: dict) -> dict:
490
+ """Re-read the world, using only what the record itself says to look at.
491
+
492
+ Apply passes a preview id and nothing else, so the record has to be enough
493
+ to find everything it depends on. That is the property being relied on here.
494
+ """
495
+ params = record["params"]
496
+ repo = record["repository"]["root"]
497
+ worklog_dir = params["worklog_dir"]
498
+ return {
499
+ "repository": repository_fingerprint(repo, params["include_uncommitted"]),
500
+ "worklog": worklog_fingerprint(worklog_dir, params["fingerprint_dates"]),
501
+ "run": run_fingerprint(record["run"]["run_dir"]),
502
+ "config": config_fingerprint(worklog_dir),
503
+ }
504
+
505
+
506
+ def _nested(data: dict, group: str, key: str):
507
+ return (data.get(group) or {}).get(key)
508
+
509
+
510
+ def evaluate(record: dict, current: "dict | None" = None,
511
+ now: "datetime | None" = None) -> dict:
512
+ """The record's state right now, and why.
513
+
514
+ ``current`` is a :func:`snapshot`; omit it to check only the things that need
515
+ no look at the world (TTL and the stored state), which is what ``show``
516
+ wants and what a caller with no repository access can still answer.
517
+ """
518
+ now = now or now_utc()
519
+ stored = record.get("state", PREVIEWED)
520
+ expires = datetime.fromisoformat(record["expires_at"])
521
+ expired = now > expires
522
+
523
+ mismatches = []
524
+ if current is not None:
525
+ for group, key, label in _CONSISTENCY_KEYS:
526
+ expected = _nested(record, group, key)
527
+ actual = _nested(current, group, key)
528
+ if expected != actual:
529
+ mismatches.append({"field": label, "expected": expected,
530
+ "actual": actual})
531
+
532
+ # A decision the tool already recorded outranks anything about the world:
533
+ # an applied preview is applied whether or not it also expired since.
534
+ if stored in TERMINAL or stored == CONFIRMED:
535
+ state = stored
536
+ elif expired:
537
+ state = EXPIRED
538
+ elif mismatches:
539
+ state = STALE
540
+ else:
541
+ state = PREVIEWED
542
+
543
+ return {
544
+ "preview_id": record["preview_id"],
545
+ "state": state,
546
+ "stored_state": stored,
547
+ "applicable": state == PREVIEWED,
548
+ "mismatches": mismatches,
549
+ "expired": expired,
550
+ "expires_at": record["expires_at"],
551
+ "age_seconds": int((now - datetime.fromisoformat(record["created_at"]))
552
+ .total_seconds()),
553
+ "reason": _reason(state, mismatches),
554
+ }
555
+
556
+
557
+ _REASONS = {
558
+ PREVIEWED: None,
559
+ APPLIED: "This preview has already been applied.",
560
+ CANCELLED: "This preview was cancelled.",
561
+ FAILED: "A previous apply of this preview failed and was rolled back. "
562
+ "Build a fresh preview rather than retrying this one.",
563
+ CONFIRMED: "A previous apply of this preview was interrupted after it was "
564
+ "confirmed, so whether it wrote anything is unknown. Check the "
565
+ "worklog directory, then build a fresh preview.",
566
+ EXPIRED: "This preview has expired.",
567
+ }
568
+
569
+
570
+ def _reason(state: str, mismatches: "list[dict]") -> "str | None":
571
+ if state == STALE:
572
+ fields = ", ".join(m["field"] for m in mismatches)
573
+ return f"The state changed since the preview was built: {fields}."
574
+ return _REASONS.get(state)
575
+
576
+
577
+ def cancel(record: dict, now: "datetime | None" = None) -> dict:
578
+ """Retire a preview the user decided against."""
579
+ now = now or now_utc()
580
+ stored = record.get("state", PREVIEWED)
581
+ if stored in TERMINAL:
582
+ raise PreviewError("PREVIEW_NOT_OPEN",
583
+ f"Preview {record['preview_id']} is already {stored}; "
584
+ f"there is nothing to cancel.",
585
+ preview_id=record["preview_id"], state=stored)
586
+ record["state"] = CANCELLED
587
+ record["cancelled_at"] = now.isoformat()
588
+ save(record)
589
+ return record
590
+
591
+
592
+ # --------------------------------------------------------------------------
593
+ # Concurrent apply lock
594
+ # --------------------------------------------------------------------------
595
+
596
+ class ApplyLock:
597
+ """One writer at a time per worklog directory.
598
+
599
+ Scoped to the directory rather than to the preview because that is where the
600
+ damage would be: two *different* previews applying at once would interleave
601
+ their day-file transactions and rebuild the index from a directory that is
602
+ halfway through someone else's write. Two applies of the *same* preview are
603
+ caught by the state machine, but only if they do not race it.
604
+
605
+ The lock lives under ``~/.git-worklog/tmp/`` -- never in the repository,
606
+ which is project content. A lock left behind by a process that died is
607
+ broken only when it is provably dead: same host, and a pid that is gone.
608
+ Across hosts there is nothing to check, so it is left alone and reported.
609
+ """
610
+
611
+ def __init__(self, worklog_dir: str, now: "datetime | None" = None):
612
+ key = hashlib.sha256(
613
+ os.path.realpath(worklog_dir).encode("utf-8")).hexdigest()[:16]
614
+ self.worklog_dir = worklog_dir
615
+ self.path = os.path.join(paths.tmp_dir(), f"apply-{key}.lock")
616
+ self._now = now or now_utc()
617
+ self.broke_stale = False
618
+
619
+ def __enter__(self) -> "ApplyLock":
620
+ paths.ensure_dir(paths.tmp_dir())
621
+ try:
622
+ self._create()
623
+ except FileExistsError:
624
+ holder = self._read()
625
+ if not _holder_alive(holder):
626
+ self.broke_stale = True
627
+ os.unlink(self.path)
628
+ self._create()
629
+ else:
630
+ raise PreviewError(
631
+ "APPLY_LOCKED",
632
+ f"Another apply is writing to {self.worklog_dir} "
633
+ f"(pid {holder.get('pid')} on {holder.get('host')}, since "
634
+ f"{holder.get('acquired_at')}). Wait for it to finish.",
635
+ lock_path=self.path, holder=holder)
636
+ return self
637
+
638
+ def _create(self) -> None:
639
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
640
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
641
+ json.dump({"pid": os.getpid(), "host": socket.gethostname(),
642
+ "acquired_at": self._now.isoformat(),
643
+ "worklog_dir": self.worklog_dir}, fh)
644
+
645
+ def _read(self) -> dict:
646
+ try:
647
+ with open(self.path, "r", encoding="utf-8") as fh:
648
+ return json.load(fh)
649
+ except (OSError, ValueError):
650
+ # An unreadable lock names no holder, so it can never be proven
651
+ # dead and will never be broken. That is the safe direction: it
652
+ # blocks writes rather than allowing a racing one.
653
+ return {}
654
+
655
+ def __exit__(self, *exc) -> None:
656
+ try:
657
+ os.unlink(self.path)
658
+ except OSError:
659
+ pass
660
+
661
+
662
+ def _holder_alive(holder: dict) -> bool:
663
+ """Whether the lock's owner still exists. Unknown counts as alive."""
664
+ pid, host = holder.get("pid"), holder.get("host")
665
+ if not isinstance(pid, int) or host != socket.gethostname():
666
+ return True
667
+ try:
668
+ os.kill(pid, 0)
669
+ except OSError as exc:
670
+ # ESRCH: no such process. EPERM: it exists and is someone else's.
671
+ return exc.errno != errno.ESRCH
672
+ return True
673
+
674
+
675
+ # --------------------------------------------------------------------------
676
+ # Apply
677
+ # --------------------------------------------------------------------------
678
+
679
+ def apply(record: dict, now: "datetime | None" = None) -> dict:
680
+ """Write the stored payload, or refuse. Nothing is re-rendered here.
681
+
682
+ The record moves to ``confirmed`` on disk *before* the first byte is written,
683
+ so a crash mid-apply leaves evidence that a write was in flight rather than a
684
+ record that still claims nothing happened.
685
+ """
686
+ now = now or now_utc()
687
+ verdict = evaluate(record, snapshot(record), now)
688
+ if not verdict["applicable"]:
689
+ # The field list only belongs to a stale verdict. An applied preview
690
+ # also "mismatches" -- against the files it wrote itself -- and printing
691
+ # that next to "already applied" reads as though somebody else had been
692
+ # editing, sending the reader to look for a problem that is not there.
693
+ extra = ({"mismatches": verdict["mismatches"]}
694
+ if verdict["state"] == STALE else {})
695
+ raise PreviewError(
696
+ _refusal_code(verdict["state"]), verdict["reason"],
697
+ preview_id=record["preview_id"], state=verdict["state"],
698
+ instruction="Build a fresh preview before applying.", **extra)
699
+
700
+ params = record["params"]
701
+ payload = record["payload"]
702
+ with ApplyLock(params["worklog_dir"], now) as lock:
703
+ record["state"] = CONFIRMED
704
+ record["confirmed_at"] = now.isoformat()
705
+ save(record)
706
+
707
+ # writer.apply_days wants the shape plan_days returns, and `original`
708
+ # with it -- it is what a rollback restores. Reading it from disk now is
709
+ # sound precisely because the fingerprint check above just proved every
710
+ # target file is byte-identical to what the preview recorded.
711
+ writes = [{**d, "original": _current_text(d["path"])}
712
+ for d in payload["days"]]
713
+ try:
714
+ writer.apply_days(params["worklog_dir"], writes, params["timezone"])
715
+ except Exception as exc: # noqa: BLE001 — any write failure ends the same way
716
+ record["state"] = FAILED
717
+ record["failure"] = f"{type(exc).__name__}: {exc}"
718
+ save(record)
719
+ raise PreviewError(
720
+ "WRITE_FAILED",
721
+ f"Transactional write failed and was rolled back: {exc}",
722
+ preview_id=record["preview_id"],
723
+ worklog_dir=params["worklog_dir"])
724
+
725
+ # The day files are the worklog; index.md is navigation derived from
726
+ # them. Once the day transaction lands, the preview *has* been applied,
727
+ # and the index failing afterwards is a repairable inconsistency rather
728
+ # than a reason to claim nothing happened -- or to tear the day files
729
+ # back out (worklog-format.md §7). So the state is recorded first and
730
+ # the index failure is reported on top of it, with the repair.
731
+ record["state"] = APPLIED
732
+ record["applied_at"] = now.isoformat()
733
+ save(record)
734
+
735
+ try:
736
+ writer.apply_index(payload["index"]["path"], payload["index"]["content"])
737
+ except Exception as exc: # noqa: BLE001
738
+ record["failure"] = f"index write failed: {type(exc).__name__}: {exc}"
739
+ save(record)
740
+ raise PreviewError(
741
+ "INDEX_WRITE_FAILED",
742
+ f"The day files were written, but index.md was not: {exc}. No "
743
+ f"day data is lost — the index is rebuilt from the day files, so "
744
+ f"run `rebuild_worklog_index.py --dir {params['worklog_dir']} "
745
+ f"--apply` to repair it.",
746
+ preview_id=record["preview_id"],
747
+ worklog_dir=params["worklog_dir"], state=APPLIED)
748
+
749
+ written = [d["date"] for d in payload["days"] if d["action"] != "no_change"]
750
+ return {
751
+ "preview_id": record["preview_id"],
752
+ "state": APPLIED,
753
+ "worklog_dir": params["worklog_dir"],
754
+ "written_dates": written,
755
+ "index_action": payload["index"]["action"],
756
+ "index_path": payload["index"]["path"],
757
+ "preserved_manual_dates": sorted(d["date"] for d in payload["days"]
758
+ if d["action"] == "overwrite"
759
+ and d["manual_preserved"]),
760
+ "broke_stale_lock": lock.broke_stale,
761
+ }
762
+
763
+
764
+ def _current_text(path: str) -> "str | None":
765
+ try:
766
+ with open(path, "r", encoding="utf-8") as fh:
767
+ return fh.read()
768
+ except OSError:
769
+ return None
770
+
771
+
772
+ def _refusal_code(state: str) -> str:
773
+ return {STALE: "PREVIEW_STALE", EXPIRED: "PREVIEW_EXPIRED",
774
+ APPLIED: "PREVIEW_ALREADY_APPLIED", CANCELLED: "PREVIEW_CANCELLED",
775
+ FAILED: "PREVIEW_FAILED",
776
+ CONFIRMED: "PREVIEW_INTERRUPTED"}.get(state, "PREVIEW_NOT_APPLICABLE")