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,120 @@
1
+ """The worklog page, served, so it can show diffs (``git-worklog view --serve``).
2
+
3
+ The static page carries each cited commit's message and file list, but not its
4
+ diff: that is source code by the megabyte. This server is the other half. It
5
+ serves the same page and, when a commit is opened in it, that commit's patch,
6
+ read from Git at that moment.
7
+
8
+ What it will not do is the design:
9
+
10
+ - **Listen anywhere but 127.0.0.1**, and answer anyone who does not name it by
11
+ that address: a page on another site that points its own hostname here (DNS
12
+ rebinding) sends that hostname as ``Host`` and gets 403.
13
+ - **Answer without the token.** The static page is written owner-only; a port on
14
+ loopback is open to every user of the machine. The URL therefore carries a
15
+ secret made for this run, and anything else is 404.
16
+ - **Show a commit the worklog does not cite.** The diff route answers for the
17
+ commits on the page it last built, not for whatever a caller can name.
18
+ - **Run anything the repository configures.** No external diff, no textconv, no
19
+ signature check (:data:`git_worklog.analysis.commits.DIFF_FLAGS`).
20
+ - **Write.** Not to the repository, not to the worklog, not to a log on stdout,
21
+ which belongs to the command's JSON.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import secrets
29
+ import subprocess
30
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
+
32
+ from git_worklog import viewer
33
+ from git_worklog.analysis.commits import DIFF_FLAGS
34
+
35
+ MAX_DIFF = 2 * 1024 * 1024 # bytes of patch sent for one commit
36
+
37
+ _FILE_START_RE = re.compile(r"(?m)^(?=diff --git )")
38
+
39
+
40
+ def diff(repo: str, sha: str) -> dict:
41
+ """One commit's patch, cut into one piece per file.
42
+
43
+ The cap is applied while reading: a commit that vendors a dependency would
44
+ otherwise be held whole in memory, once per request.
45
+ """
46
+ proc = subprocess.Popen(
47
+ ["git", "-C", repo, "show", "--no-color", "--no-ext-diff", "--no-textconv",
48
+ *DIFF_FLAGS, "--format=", "--patch", sha],
49
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
50
+ try:
51
+ data = proc.stdout.read(MAX_DIFF + 1)
52
+ finally:
53
+ proc.kill()
54
+ proc.stdout.close()
55
+ proc.wait()
56
+ text = data[:MAX_DIFF].decode("utf-8", "replace")
57
+ files = [part for part in _FILE_START_RE.split(text) if part.startswith("diff --git ")]
58
+ return {"files": files, "truncated": len(data) > MAX_DIFF}
59
+
60
+
61
+ class Server(ThreadingHTTPServer):
62
+ daemon_threads = True
63
+
64
+ def __init__(self, worklog_dir: str, repo: str):
65
+ super().__init__(("127.0.0.1", 0), _Handler) # port 0: the system picks
66
+ self.worklog_dir = worklog_dir
67
+ self.repo = repo
68
+ self.token = secrets.token_urlsafe(16)
69
+ self.commits: "frozenset[str]" = frozenset()
70
+
71
+ @property
72
+ def url(self) -> str:
73
+ return f"http://127.0.0.1:{self.server_port}/{self.token}/"
74
+
75
+ def page(self) -> "tuple[str, dict]":
76
+ """Build the page now, and remember which commits it shows."""
77
+ page, info = viewer.build(self.worklog_dir, repo=self.repo, diff_base="diff/")
78
+ self.commits = info["commits"]
79
+ return page, info
80
+
81
+
82
+ class _Handler(BaseHTTPRequestHandler):
83
+ # Only GET exists; the base class answers every other method with 501.
84
+
85
+ def log_message(self, format, *args): # noqa: A002 (the base class's name)
86
+ pass
87
+
88
+ def _send(self, status: int, content_type: str, body: bytes) -> None:
89
+ self.send_response(status)
90
+ self.send_header("Content-Type", content_type)
91
+ self.send_header("Content-Length", str(len(body)))
92
+ self.send_header("Cache-Control", "no-store")
93
+ self.send_header("X-Content-Type-Options", "nosniff")
94
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
95
+ self.end_headers()
96
+ self.wfile.write(body)
97
+
98
+ def _refuse(self, status: int) -> None:
99
+ self._send(status, "text/plain; charset=utf-8", b"")
100
+
101
+ def do_GET(self): # noqa: N802 (the base class's name)
102
+ server = self.server
103
+ if self.headers.get("Host") != f"127.0.0.1:{server.server_port}":
104
+ return self._refuse(403)
105
+ prefix = f"/{server.token}/"
106
+ path = self.path.split("?", 1)[0]
107
+ if not secrets.compare_digest(path[:len(prefix)].encode("utf-8", "replace"),
108
+ prefix.encode("ascii")):
109
+ return self._refuse(404)
110
+ rest = path[len(prefix):]
111
+ try:
112
+ if not rest:
113
+ page, _info = server.page() # rebuilt: reloading the tab refreshes it
114
+ return self._send(200, "text/html; charset=utf-8", page.encode("utf-8"))
115
+ if rest.startswith("diff/") and rest[5:] in server.commits:
116
+ body = json.dumps(diff(server.repo, rest[5:])).encode("ascii")
117
+ return self._send(200, "application/json; charset=utf-8", body)
118
+ except OSError:
119
+ return self._refuse(500)
120
+ return self._refuse(404)
git_worklog/writer.py ADDED
@@ -0,0 +1,420 @@
1
+ """Turning generated prose into the bytes that land in ``.git-worklog/``.
2
+
3
+ :mod:`git_worklog.markers` knows the *format* — where a marker goes, what a day
4
+ file looks like. This module knows the *write*: which files a run would touch,
5
+ what each one's final text is, and how to get all of them onto disk without ever
6
+ leaving half a worklog behind.
7
+
8
+ Two shapes, one rule each:
9
+
10
+ * **Day files** — one per date, planned independently, applied as a single
11
+ transaction. A day's MANUAL region survives byte-for-byte or the write is
12
+ refused; there is no repair path for corrupt markers because guessing which
13
+ text a human wrote is exactly the mistake that loses it.
14
+ * **index.md** — a pure function of the day files on disk, so it is rebuilt
15
+ rather than edited, and its own MANUAL region is likewise preserved.
16
+
17
+ Planning is separated from applying because the plan *is* the preview: the same
18
+ bytes are shown to the user, stored on the preview record, and later written
19
+ (roadmap §10.1). Recomputing them at apply time would mean applying something
20
+ nobody saw.
21
+
22
+ This lives in the package, not in ``scripts/``, because ``git-worklog preview``
23
+ and ``git-worklog apply`` need it and an installed CLI has no ``scripts/``
24
+ directory to shell out to.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import os
31
+ import tempfile
32
+
33
+ from git_worklog import config, language
34
+ from git_worklog import markers as wm
35
+
36
+ DEFAULT_DIR = wm.WORKLOG_DIRNAME
37
+
38
+
39
+ class WriterError(ValueError):
40
+ """A refused write, carrying the wire code its caller reports.
41
+
42
+ Mirrors :class:`git_worklog.analysis.AnalysisError`: the callers are thin
43
+ shells (scripts and CLI subcommands) that each owe the user one JSON object
44
+ with a stable ``code``, so the code rides on the exception rather than being
45
+ re-derived from the message at every call site.
46
+ """
47
+
48
+ def __init__(self, code: str, message: str, **extra):
49
+ self.code = code
50
+ self.message = message
51
+ self.extra = extra
52
+ super().__init__(message)
53
+
54
+
55
+ def sha256(text: str) -> str:
56
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # Day files
61
+ # --------------------------------------------------------------------------
62
+
63
+ def check_layout(worklog_dir: str) -> None:
64
+ """Refuse to write day files into a pre-v0.6 flat directory.
65
+
66
+ Writing would leave the worklog half in each layout. Reads of a legacy
67
+ directory still work; only writes are gated.
68
+ """
69
+ if wm.detect_layout(worklog_dir) == wm.LAYOUT_LEGACY:
70
+ raise WriterError(
71
+ "LEGACY_LAYOUT",
72
+ f"{worklog_dir} still uses the pre-v0.6 flat layout (day files at its "
73
+ f"root, not under {wm.DAYS_SUBDIR}/). Run migrate_legacy_worklog.py "
74
+ "first; this script will not write a mixed-layout directory.",
75
+ worklog_dir=worklog_dir)
76
+
77
+
78
+ def _read_existing(path: str) -> "str | None":
79
+ """Return the file's text, or None if absent. Fail on non-UTF-8."""
80
+ if not os.path.exists(path):
81
+ return None
82
+ with open(path, "rb") as fh:
83
+ raw = fh.read()
84
+ try:
85
+ return raw.decode("utf-8")
86
+ except UnicodeDecodeError as exc:
87
+ raise WriterError("NON_UTF8",
88
+ f"Existing day file is not valid UTF-8: {exc}", target=path)
89
+
90
+
91
+ def plan_days(worklog_dir: str, entries: dict, meta: dict) -> "list[dict]":
92
+ """Compute the intended write for every target date. Fail on corruption.
93
+
94
+ Each returned write carries both ``original`` and ``content`` — the whole
95
+ before/after — because that is what a rollback and a preview each need.
96
+ """
97
+ tz = meta.get("timezone")
98
+ branch = meta.get("branch")
99
+ head = meta.get("head")
100
+ writes: "list[dict]" = []
101
+ for date in sorted(entries.keys(), reverse=True):
102
+ if not wm.is_valid_date(date):
103
+ raise WriterError("INVALID_DATE",
104
+ f"Entry key {date!r} is not a YYYY-MM-DD date.")
105
+ if not isinstance(entries[date], dict):
106
+ raise WriterError(
107
+ "INVALID_ENTRY",
108
+ f"Entry for {date} must be an object with 'generated_markdown'.")
109
+ gen_md = entries[date].get("generated_markdown", "")
110
+ if wm.contains_marker_line(gen_md):
111
+ raise WriterError(
112
+ "GENERATED_CONTAINS_MARKER",
113
+ f"generated_markdown for {date} contains a {wm.PREFIX} marker line, "
114
+ "which would corrupt the file. Rephrase or escape it.", date=date)
115
+ path = wm.day_path(worklog_dir, date, wm.LAYOUT_CURRENT)
116
+ original = _read_existing(path)
117
+ summary = wm.summarise_generated(gen_md)
118
+
119
+ if original is None:
120
+ content = wm.render_new_day_file(date, gen_md, timezone=tz, branch=branch,
121
+ head=head)
122
+ action, manual_preserved = "create", False
123
+ else:
124
+ try:
125
+ existing_day = wm.parse_day(original, date)
126
+ except wm.WorklogFormatError as exc:
127
+ raise WriterError(
128
+ "CORRUPT_MARKERS",
129
+ f"Day file {date}.md has corrupted/missing markers; refusing to "
130
+ "guess a repair.", target=path, issues=exc.issues)
131
+ content = wm.overwrite_day_generated(original, date, gen_md,
132
+ timezone=tz, branch=branch, head=head)
133
+ # MANUAL must survive byte-for-byte.
134
+ new_day = wm.parse_day(content, date)
135
+ if new_day.manual != existing_day.manual:
136
+ raise WriterError(
137
+ "MANUAL_MUTATED",
138
+ f"Refusing to write: MANUAL content for {date} would change.",
139
+ date=date)
140
+ manual_preserved = bool(existing_day.manual.strip())
141
+ action = "no_change" if content == original else "overwrite"
142
+
143
+ writes.append({
144
+ "date": date, "path": path, "action": action,
145
+ "manual_preserved": manual_preserved, "summary": summary,
146
+ "original": original, "content": content,
147
+ })
148
+ return writes
149
+
150
+
151
+ def day_report(writes: "list[dict]") -> dict:
152
+ """The parts of a plan that are safe to hand back as JSON.
153
+
154
+ ``original``/``content`` are the whole file text and stay out of this: the
155
+ callers that want them (dry-run previews, preview records) ask for them by
156
+ name, and the ones that do not should not carry two copies of every day file
157
+ through their output.
158
+ """
159
+ return {
160
+ "planned_changes": [{"date": w["date"], "path": w["path"],
161
+ "action": w["action"],
162
+ "manual_preserved": w["manual_preserved"]}
163
+ for w in writes],
164
+ "summaries": {w["date"]: w["summary"] for w in writes},
165
+ "file_hashes": {w["date"]: {
166
+ "original": sha256(w["original"]) if w["original"] is not None else None,
167
+ "preview": sha256(w["content"]),
168
+ } for w in writes},
169
+ "preserved_manual_dates": sorted(w["date"] for w in writes
170
+ if w["action"] == "overwrite"
171
+ and w["manual_preserved"]),
172
+ }
173
+
174
+
175
+ def apply_days(worklog_dir: str, writes: "list[dict]",
176
+ timezone: "str | None" = None) -> None:
177
+ """Stage, validate, then atomically swap every changed file, with rollback."""
178
+ changed = [w for w in writes if w["action"] != "no_change"]
179
+ if not changed:
180
+ return
181
+ day_dir = wm.days_dir(worklog_dir, wm.LAYOUT_CURRENT)
182
+ os.makedirs(day_dir, exist_ok=True)
183
+ wm.ensure_data_dir(worklog_dir, timezone)
184
+
185
+ # Stage each file to a same-directory temp and validate it before it goes live.
186
+ staged: "list[tuple[str, dict]]" = []
187
+ try:
188
+ for w in changed:
189
+ fd, tmp = tempfile.mkstemp(dir=day_dir, prefix=".rw-", suffix=".tmp")
190
+ staged.append((tmp, w)) # track immediately so cleanup can't miss it
191
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
192
+ fh.write(w["content"])
193
+ fh.flush()
194
+ os.fsync(fh.fileno())
195
+ _, issues = wm.scan_day(w["content"], w["date"])
196
+ if [i for i in issues if i["code"] in wm.FATAL_CODES]:
197
+ raise RuntimeError(f"staged {w['date']}.md failed validation")
198
+ except Exception:
199
+ for tmp, _ in staged:
200
+ _safe_unlink(tmp)
201
+ raise
202
+
203
+ # Swap them in; on any failure restore everything already swapped.
204
+ swapped: "list[dict]" = []
205
+ try:
206
+ for tmp, w in staged:
207
+ os.replace(tmp, w["path"])
208
+ swapped.append(w)
209
+ except Exception:
210
+ _rollback(swapped)
211
+ for tmp, _ in staged:
212
+ _safe_unlink(tmp)
213
+ raise
214
+
215
+ # Confirm the live files parse; roll back the whole batch if not.
216
+ for w in swapped:
217
+ with open(w["path"], "r", encoding="utf-8") as fh:
218
+ live = fh.read()
219
+ _, issues = wm.scan_day(live, w["date"])
220
+ if [i for i in issues if i["code"] in wm.FATAL_CODES]:
221
+ _rollback(swapped)
222
+ raise RuntimeError(f"post-write validation failed for {w['date']}.md")
223
+
224
+
225
+ def _rollback(swapped: "list[dict]") -> None:
226
+ for w in reversed(swapped):
227
+ if w["original"] is not None:
228
+ # Restore atomically so a failed restore can't truncate the original.
229
+ target_dir = os.path.dirname(os.path.abspath(w["path"]))
230
+ fd, tmp = tempfile.mkstemp(dir=target_dir, prefix=".rw-rb-", suffix=".tmp")
231
+ try:
232
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
233
+ fh.write(w["original"])
234
+ fh.flush()
235
+ os.fsync(fh.fileno())
236
+ os.replace(tmp, w["path"])
237
+ finally:
238
+ _safe_unlink(tmp)
239
+ else:
240
+ _safe_unlink(w["path"])
241
+
242
+
243
+ def _safe_unlink(path: str) -> None:
244
+ try:
245
+ os.unlink(path)
246
+ except OSError:
247
+ pass
248
+
249
+
250
+ # --------------------------------------------------------------------------
251
+ # index.md
252
+ # --------------------------------------------------------------------------
253
+
254
+ def _scan_day_summaries(worklog_dir: str,
255
+ layout: str) -> "tuple[dict[str, str], list[dict]]":
256
+ """Map each on-disk ``<date>.md`` to its summary; warn on unreadable files."""
257
+ summaries: "dict[str, str]" = {}
258
+ warnings: "list[dict]" = []
259
+ if not os.path.isdir(worklog_dir):
260
+ return summaries, warnings
261
+ for date in wm.list_day_dates(worklog_dir, layout):
262
+ path = wm.day_path(worklog_dir, date, layout)
263
+ name = os.path.basename(path)
264
+ try:
265
+ with open(path, "rb") as fh:
266
+ text = fh.read().decode("utf-8")
267
+ day = wm.parse_day(text, date)
268
+ summaries[date] = wm.summarise_generated(day.generated)
269
+ except (UnicodeDecodeError, wm.WorklogFormatError, OSError) as exc:
270
+ summaries[date] = ""
271
+ warnings.append({"code": "DAY_FILE_UNREADABLE", "date": date,
272
+ "message": f"Could not read summary from {name}: {exc}"})
273
+ return summaries, warnings
274
+
275
+
276
+ def _read_index_manual(index_path: str) -> "str | None":
277
+ """Return the existing index MANUAL inner text, or None if index is absent.
278
+
279
+ Fail on a corrupt existing index rather than discard its MANUAL region.
280
+ """
281
+ if not os.path.exists(index_path):
282
+ return None
283
+ with open(index_path, "rb") as fh:
284
+ raw = fh.read()
285
+ try:
286
+ text = raw.decode("utf-8")
287
+ except UnicodeDecodeError as exc:
288
+ raise WriterError("NON_UTF8", f"index.md is not valid UTF-8: {exc}",
289
+ target=index_path)
290
+ try:
291
+ doc = wm.parse_index(text)
292
+ except wm.WorklogFormatError as exc:
293
+ raise WriterError(
294
+ "INDEX_CORRUPT_MARKERS",
295
+ "index.md has corrupted/missing markers; refusing to guess a repair.",
296
+ target=index_path, issues=exc.issues)
297
+ return doc.manual
298
+
299
+
300
+ def resolve_index_language(worklog_dir: str, original: "str | None",
301
+ requested: "str | None") -> "tuple[str, str]":
302
+ """Decide the index's language, and say what decided it (§6.2.12).
303
+
304
+ The index is navigation, not content: it is one file that every run
305
+ rewrites, and teams commit it. If each run rendered it in whatever language
306
+ that run happened to use, a repository with a zh-TW developer and an English
307
+ one would rewrite its headings back and forth forever and put that churn in
308
+ every diff. So the language is decided once and then left alone:
309
+
310
+ 1. config's index_language, when the project pinned one -- an explicit
311
+ team decision outranks anything a single run wants.
312
+ 2. the lang= already stamped on the index -- "first build wins", which is
313
+ the recommended default (§6.2.12).
314
+ 3. an index that exists but carries no stamp is zh-TW, because that is the
315
+ only language anything could have written before this contract. Reading
316
+ it as unstamped-therefore-undecided would silently retitle every
317
+ existing index on upgrade.
318
+ 4. otherwise this run's language: the index does not exist yet, so this
319
+ run is the first build and gets to choose.
320
+ """
321
+ pinned = config.index_language(config.load(worklog_dir))
322
+ if pinned:
323
+ return language.normalize(pinned), "project-config"
324
+
325
+ if original is not None:
326
+ stamped = wm.index_language_of(original)
327
+ if stamped:
328
+ return language.normalize(stamped), "existing-index"
329
+ return wm.DEFAULT_INDEX_LANGUAGE, "existing-index-unstamped"
330
+
331
+ if requested:
332
+ return language.normalize(requested), "run"
333
+ return wm.DEFAULT_INDEX_LANGUAGE, "default"
334
+
335
+
336
+ def plan_index(worklog_dir: str, overrides: "dict | None" = None,
337
+ requested_language: "str | None" = None) -> dict:
338
+ """Compute index.md's next text from the day files, plus pending overrides.
339
+
340
+ ``overrides`` carry already-extracted one-line summaries for days that are
341
+ about to be written but are not on disk yet, so a preview reflects the state
342
+ after the day-file apply rather than the state before it.
343
+ """
344
+ index_path = wm.index_path(worklog_dir)
345
+ # The index links to wherever the day files actually are, so a legacy
346
+ # worklog rebuilt before migration still gets working links.
347
+ layout = wm.detect_layout(worklog_dir)
348
+
349
+ summaries, warnings = _scan_day_summaries(worklog_dir, layout)
350
+ for date, summary in (overrides or {}).items():
351
+ if not wm.is_valid_date(date):
352
+ raise WriterError("INVALID_DATE",
353
+ f"Override key {date!r} is not a YYYY-MM-DD date.")
354
+ summaries[date] = wm.clean_summary(summary) if summary else ""
355
+
356
+ rows = [(d, summaries[d]) for d in sorted(summaries, reverse=True)]
357
+ existing_manual = _read_index_manual(index_path)
358
+
359
+ original = None
360
+ if os.path.exists(index_path):
361
+ with open(index_path, "r", encoding="utf-8") as fh:
362
+ original = fh.read()
363
+
364
+ index_language, language_source = resolve_index_language(
365
+ worklog_dir, original, requested_language)
366
+ content = wm.render_index(rows, existing_manual, layout, index_language)
367
+ action = ("no_change" if original == content
368
+ else "create" if original is None else "rebuild")
369
+
370
+ return {
371
+ "worklog_dir": worklog_dir,
372
+ "index_path": index_path,
373
+ "action": action,
374
+ "dates": [d for d, _ in rows],
375
+ "preserved_index_manual": existing_manual is not None,
376
+ "index_language": index_language,
377
+ "index_language_source": language_source,
378
+ "index_hash": {"original": sha256(original) if original is not None else None,
379
+ "preview": sha256(content)},
380
+ "warnings": warnings,
381
+ "original": original,
382
+ "content": content,
383
+ }
384
+
385
+
386
+ def atomic_write(target: str, content: str, validate, *, prefix: str) -> None:
387
+ """Write ``content`` to ``target`` atomically, validating before it goes live.
388
+
389
+ Stage into a temp file beside the target, fsync it, re-read and hand the
390
+ staged bytes to ``validate`` (which raises to reject them), then
391
+ :func:`os.replace` into place -- so a reader never sees a half-written or
392
+ invalid file, and a crash leaves the target untouched.
393
+
394
+ ``prefix`` names the temp file. It is not cosmetic: a crash between fsync and
395
+ replace leaves an orphan, and the prefix (``.rw-index-`` vs ``.rw-mig-``) is
396
+ what tells you which writer left it. The single-file dance was implemented
397
+ three times (#23); this is the one copy, with ``apply_index`` and migration
398
+ as callers. ``apply_days`` stays separate -- its multi-file stage-all /
399
+ swap-all / roll-back-all transaction is a different algorithm, not this one
400
+ in a loop.
401
+ """
402
+ target_dir = os.path.dirname(os.path.abspath(target))
403
+ os.makedirs(target_dir, exist_ok=True)
404
+ fd, tmp = tempfile.mkstemp(dir=target_dir, prefix=prefix, suffix=".tmp")
405
+ try:
406
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
407
+ fh.write(content)
408
+ fh.flush()
409
+ os.fsync(fh.fileno())
410
+ with open(tmp, "r", encoding="utf-8") as fh:
411
+ validate(fh.read())
412
+ os.replace(tmp, target)
413
+ finally:
414
+ if os.path.exists(tmp):
415
+ os.unlink(tmp)
416
+
417
+
418
+ def apply_index(target: str, content: str) -> None:
419
+ """Write index.md atomically, re-parsing the staged text before it goes live."""
420
+ atomic_write(target, content, wm.parse_index, prefix=".rw-index-")