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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- 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-")
|