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/mdhtml.py ADDED
@@ -0,0 +1,266 @@
1
+ """Worklog Markdown to HTML, safe by construction (``git-worklog view``).
2
+
3
+ A day file is Markdown an LLM wrote after reading the repository, plus whatever
4
+ a human put in its MANUAL region. Either can hold text shaped like HTML: a
5
+ worklog that describes an XSS fix quotes ``<script>``, and a repository can
6
+ carry text written to steer the model that summarised it. A general Markdown
7
+ library passes inline HTML through by design. This renderer is built the other
8
+ way round: **nothing the input says is ever emitted as markup.** Every
9
+ character is escaped first, and the only tags in the output are the ones this
10
+ module adds itself.
11
+
12
+ It supports what the worklog format actually uses (worklog-format.md §3): ATX
13
+ headings, paragraphs, bullet and ordered lists nested by indentation, fenced
14
+ code, inline code, ``**bold**``, ``*italic*`` and http(s) links. Anything else
15
+ -- tables, block quotes, footnotes, raw HTML -- is shown as the text it is, line
16
+ breaks kept. That is the safe way to be incomplete: an unsupported construct
17
+ reads a little plainer, it never runs.
18
+
19
+ Commit hashes become badges, because a worklog is mostly read to get from a
20
+ sentence to the commit behind it: a ``<button>`` the page's script opens the
21
+ commit from -- or copies to the clipboard, when Git did not know the hash -- or
22
+ a plain ``<span>`` inside something that is itself a link. A
23
+ token counts when it mixes digits and letters, or when it is written as the
24
+ format's own commit reference, ``<hash> (<author>)`` (worklog-format.md §3). The
25
+ first rule keeps numbers and all-hex words ("defaced") plain; the second catches
26
+ the hashes that happen to be all digits, four of 91 in this repository's own
27
+ worklog.
28
+
29
+ Two omissions are deliberate:
30
+
31
+ - ``_underscore_`` emphasis. Worklogs name identifiers such as
32
+ REPO_WORKLOG_ANTHROPIC_MODEL outside backticks, and reading their underscores
33
+ as emphasis would mangle exactly the names a reader searches for.
34
+ - Links other than http(s) and the worklog's own day files. ``javascript:`` and
35
+ ``data:`` are the obvious ones, but ``//host`` matters too: on a ``file://``
36
+ page it resolves to ``file://host/``, which Windows opens as a network share.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import html
42
+ import re
43
+
44
+ _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
45
+ _HEADING_RE = re.compile(r"^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$")
46
+ _LIST_RE = re.compile(r"^( *)([-*+]|\d{1,9}[.)])(?:\s+(.*))?$")
47
+ _HR_RE = re.compile(r"^ {0,3}([-*_])(?:\s*\1){2,}\s*$")
48
+ # A whole-line HTML comment -- in a day file, a GIT_WORKLOG marker. It is
49
+ # structure, not text: dropped, and it ends the paragraph it interrupts, so a
50
+ # summary bracketed by SUMMARY markers never runs into the line after them.
51
+ COMMENT_RE = re.compile(r"^\s*<!--.*-->\s*$")
52
+
53
+ _CODE_RE = re.compile(r"(`+)(.+?)\1")
54
+ _IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^()\s]*)(?:\s+\"[^\"]*\")?\)")
55
+ _LINK_RE = re.compile(r"\[([^\]]+)\]\(([^()\s]*)(?:\s+\"[^\"]*\")?\)")
56
+ _AUTOLINK_RE = re.compile(r"<(https?://[^\s<>]+)>", re.IGNORECASE)
57
+ _STRONG_RE = re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*")
58
+ _EM_RE = re.compile(r"(?<!\*)\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*(?!\*)")
59
+ _DAY_LINK_RE = re.compile(r"^(?:\.{1,2}/)*(?:days/)?(\d{4}-\d{2}-\d{2})\.md$")
60
+ # 7-40 lowercase hex characters not glued to a path, an identifier or a colour.
61
+ # ASCII boundaries rather than \b, which treats CJK as word characters and would
62
+ # miss a hash written directly against Chinese text.
63
+ HASH_RE = re.compile(r"(?<![0-9A-Za-z_./#-])[0-9a-f]{7,40}(?![0-9A-Za-z_-])")
64
+ _AUTHOR_AFTER_RE = re.compile(r"\s*[((][^()()\n]{1,80}[))]")
65
+ # Placeholders are NUL-delimited, and NUL is stripped from every input first,
66
+ # so text can never forge one.
67
+ _SLOT_RE = re.compile("\x00(\\d+)\x00")
68
+
69
+ _EXTERNAL = ' rel="noopener noreferrer" target="_blank"'
70
+
71
+
72
+ def _clean(text: str) -> str:
73
+ return (text.replace("\x00", "").replace("\r\n", "\n").replace("\r", "\n")
74
+ .expandtabs(4))
75
+
76
+
77
+ def _href(target: str, dates) -> "str | None":
78
+ """Where a link may point, or None to keep only its text."""
79
+ t = target.strip()
80
+ low = t.lower()
81
+ if low.startswith("http://") or low.startswith("https://"):
82
+ return t
83
+ m = _DAY_LINK_RE.match(t)
84
+ if m and m.group(1) in dates:
85
+ return "#d-" + m.group(1)
86
+ return None
87
+
88
+
89
+ def inline(text: str, *, dates=frozenset(), links: bool = True) -> str:
90
+ """One line of Markdown as HTML.
91
+
92
+ ``dates`` are the day files a relative ``<date>.md`` link may be rewritten
93
+ to. ``links=False`` keeps link text and drops the link, for places that are
94
+ themselves inside a link; commit hashes there are badges that do nothing.
95
+ """
96
+ slots: "list[str]" = []
97
+
98
+ def slot(fragment: str) -> str:
99
+ slots.append(fragment)
100
+ return f"\x00{len(slots) - 1}\x00"
101
+
102
+ def code(m):
103
+ return slot("<code>" + html.escape(m.group(2), quote=False) + "</code>")
104
+
105
+ def image(m):
106
+ return slot(html.escape(m.group(1), quote=False))
107
+
108
+ def link(m):
109
+ label = _emphasis(html.escape(m.group(1), quote=False))
110
+ href = _href(m.group(2), dates) if links else None
111
+ if href is None:
112
+ return slot(label)
113
+ extra = "" if href.startswith("#") else _EXTERNAL
114
+ return slot(f'<a href="{html.escape(href)}"{extra}>{label}</a>')
115
+
116
+ def autolink(m):
117
+ shown = html.escape(m.group(1), quote=False)
118
+ if not links:
119
+ return slot(shown)
120
+ return slot(f'<a href="{html.escape(m.group(1))}"{_EXTERNAL}>{shown}</a>')
121
+
122
+ def commit(m):
123
+ token = m.group(0)
124
+ mixed = any(c.isdigit() for c in token) and any(c.isalpha() for c in token)
125
+ if not mixed and not _AUTHOR_AFTER_RE.match(m.string, m.end()):
126
+ return token # a number, or a word such as "defaced"
127
+ if not links:
128
+ return slot(f'<span class="hash">{token}</span>')
129
+ return slot(f'<button type="button" class="hash" data-hash="{token}" '
130
+ f'title="Copy {token}">{token}</button>')
131
+
132
+ text = _CODE_RE.sub(code, _clean(text))
133
+ text = _IMAGE_RE.sub(image, text)
134
+ text = _LINK_RE.sub(link, text)
135
+ text = _AUTOLINK_RE.sub(autolink, text)
136
+ text = HASH_RE.sub(commit, text) # last: never inside code, a link or its URL
137
+ text = _emphasis(html.escape(text, quote=False))
138
+ # A link label can hold a code span, so a slot can hold a slot.
139
+ while _SLOT_RE.search(text):
140
+ text = _SLOT_RE.sub(lambda m: slots[int(m.group(1))], text)
141
+ return text
142
+
143
+
144
+ def _emphasis(escaped: str) -> str:
145
+ escaped = _STRONG_RE.sub(r"<strong>\1</strong>", escaped)
146
+ return _EM_RE.sub(r"<em>\1</em>", escaped)
147
+
148
+
149
+ def _indent(line: str) -> int:
150
+ return len(line) - len(line.lstrip(" "))
151
+
152
+
153
+ def _fenced(lines: "list[str]", i: int, opener: str, indent: int,
154
+ out: "list[str]") -> int:
155
+ """Emit the fenced block opening at ``lines[i]``; return the next index."""
156
+ close = re.compile(r"^\s*" + re.escape(opener[0]) + "{" + str(len(opener)) + r",}\s*$")
157
+ body = []
158
+ i += 1
159
+ while i < len(lines) and not close.match(lines[i]):
160
+ line = lines[i]
161
+ body.append(line[min(indent, _indent(line)):])
162
+ i += 1
163
+ out.append("<pre><code>" + html.escape("\n".join(body), quote=False)
164
+ + "</code></pre>")
165
+ return i + 1 # past the closing fence -- or the end, if it never closed
166
+
167
+
168
+ def _list(lines: "list[str]", i: int, out: "list[str]", dates) -> int:
169
+ """Emit the list starting at ``lines[i]``; return the next index."""
170
+ base = _indent(lines[i])
171
+ ordered = _LIST_RE.match(lines[i]).group(2)[0].isdigit()
172
+ items: "list[dict]" = []
173
+ n = len(lines)
174
+ while i < n:
175
+ line = lines[i]
176
+ if not line.strip():
177
+ # A blank line ends the list unless the list, or its last item,
178
+ # carries on after it.
179
+ j = i + 1
180
+ while j < n and not lines[j].strip():
181
+ j += 1
182
+ if j < n and items and (_indent(lines[j]) > base or (
183
+ _indent(lines[j]) == base and _LIST_RE.match(lines[j]))):
184
+ i = j
185
+ continue
186
+ break
187
+ if COMMENT_RE.match(line):
188
+ break
189
+ ind = _indent(line)
190
+ m = _LIST_RE.match(line)
191
+ if m and ind == base:
192
+ items.append({"text": [m.group(3) or ""], "children": []})
193
+ i += 1
194
+ elif m and ind > base and items:
195
+ i = _list(lines, i, items[-1]["children"], dates)
196
+ elif ind > base and items:
197
+ fence = _FENCE_RE.match(line.strip())
198
+ if fence:
199
+ i = _fenced(lines, i, fence.group(1), ind, items[-1]["children"])
200
+ else:
201
+ items[-1]["text"].append(line.strip())
202
+ i += 1
203
+ else:
204
+ break
205
+ tag = "ol" if ordered else "ul"
206
+ parts = [f"<{tag}>"]
207
+ for item in items:
208
+ # An item that opens with bold text is a labelled field -- the shape of
209
+ # every work-item bullet (`- **label:** value`). Marked here, by
210
+ # structure, so the page can style the label without knowing its words.
211
+ opener = '<li class="kv">' if item["text"][0].startswith("**") else "<li>"
212
+ text = "<br>\n".join(inline(t, dates=dates) for t in item["text"])
213
+ parts.append(opener + text + "".join(item["children"]) + "</li>")
214
+ parts.append(f"</{tag}>")
215
+ out.append("\n".join(parts))
216
+ return i
217
+
218
+
219
+ def render(md: str, *, dates=frozenset(), min_heading: int = 4) -> str:
220
+ """A block of Markdown as HTML.
221
+
222
+ Headings are clamped to at least ``min_heading``, because this text always
223
+ sits inside a page that already has its own h1-h3.
224
+ """
225
+ lines = _clean(md).split("\n")
226
+ out: "list[str]" = []
227
+ para: "list[str]" = []
228
+
229
+ def flush():
230
+ if para:
231
+ out.append("<p>" + "<br>\n".join(inline(p, dates=dates) for p in para)
232
+ + "</p>")
233
+ para.clear()
234
+
235
+ i, n = 0, len(lines)
236
+ while i < n:
237
+ line = lines[i]
238
+ fence = _FENCE_RE.match(line)
239
+ if fence:
240
+ flush()
241
+ i = _fenced(lines, i, fence.group(1), _indent(line), out)
242
+ continue
243
+ if not line.strip() or COMMENT_RE.match(line):
244
+ flush()
245
+ i += 1
246
+ continue
247
+ heading = _HEADING_RE.match(line)
248
+ if heading:
249
+ flush()
250
+ level = min(6, max(min_heading, len(heading.group(1))))
251
+ out.append(f"<h{level}>{inline(heading.group(2), dates=dates)}</h{level}>")
252
+ i += 1
253
+ continue
254
+ if _HR_RE.match(line):
255
+ flush()
256
+ out.append("<hr>")
257
+ i += 1
258
+ continue
259
+ if _LIST_RE.match(line):
260
+ flush()
261
+ i = _list(lines, i, out, dates)
262
+ continue
263
+ para.append(line.strip())
264
+ i += 1
265
+ flush()
266
+ return "\n".join(out)
git_worklog/migrate.py ADDED
@@ -0,0 +1,305 @@
1
+ """One-time migration of a legacy worklog into the ``.git-worklog/`` layout.
2
+
3
+ Two legacy shapes are migrated, because the worklog has moved twice:
4
+
5
+ **Single file** (``from_file``, pre-v0.2) — every day lived in one
6
+ ``docs/PROJECT_WORKLOG.md`` delimited by ``REPO_WORKLOG:ENTRIES`` and per-date
7
+ ``START/END`` markers. Each date is split into its own day file, preserving that
8
+ date's GENERATED and MANUAL text.
9
+
10
+ **Flat directory** (``from_dir``, v0.2–v0.5) — ``PROJECT_WORKLOG/<date>.md``
11
+ with the index alongside. Day files move to ``.git-worklog/days/<date>.md`` and
12
+ their markers are re-tagged ``REPO_WORKLOG`` → ``GIT_WORKLOG``. Nothing else in
13
+ a day file changes: the title, meta blockquote, GENERATED prose and MANUAL notes
14
+ are copied byte for byte, so a migration never rewrites a worklog's content or
15
+ its language. The index is rebuilt (its links must now point into ``days/``)
16
+ with its MANUAL region preserved.
17
+
18
+ With neither given the source is auto-detected, directory first.
19
+
20
+ It is **never** invoked by normal runs — only explicitly. Dry-run is the default.
21
+ It never deletes the source, and never overwrites a day file that already exists
22
+ (those are left for the user to reconcile). If the legacy markers are corrupt, it
23
+ refuses to migrate rather than guess.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import os
30
+ import re
31
+
32
+ from git_worklog import markers as wm
33
+ from git_worklog import writer
34
+
35
+ DEFAULT_LEGACY = os.path.join("docs", "PROJECT_WORKLOG.md")
36
+ DEFAULT_LEGACY_DIR = wm.LEGACY_WORKLOG_DIRNAME
37
+ DEFAULT_DIR = wm.WORKLOG_DIRNAME
38
+
39
+ _LEGACY_MARKER_RE = re.compile(
40
+ rf"^<!--\s*{wm.LEGACY_PREFIX}:(\d{{4}}-\d{{2}}-\d{{2}}):(GENERATED|MANUAL):(START|END)\s*-->$"
41
+ )
42
+ _ENTRIES_RE = re.compile(rf"^<!--\s*{wm.LEGACY_PREFIX}:ENTRIES:(START|END)\s*-->$")
43
+
44
+
45
+ class MigrateError(ValueError):
46
+ """A migration that must not proceed, carrying the wire code.
47
+
48
+ Mirrors :class:`git_worklog.dates.DateError` and
49
+ :class:`git_worklog.analysis.AnalysisError`.
50
+ """
51
+
52
+ def __init__(self, code: str, message: str, **extra):
53
+ self.code = code
54
+ self.message = message
55
+ self.extra = extra
56
+ super().__init__(message)
57
+
58
+ def as_error(self) -> dict:
59
+ """The error dict as it goes on the wire."""
60
+ return {"code": self.code, "message": self.message, **self.extra}
61
+
62
+
63
+ def _sha256(text: str) -> str:
64
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
65
+
66
+
67
+ def parse_legacy(text: str) -> "dict[str, dict[str, str]]":
68
+ """Extract ``{date: {"generated": ..., "manual": ...}}`` from the legacy file.
69
+
70
+ Raises ValueError with a reason when the markers are unbalanced, so a corrupt
71
+ legacy file is refused rather than half-migrated.
72
+ """
73
+ lines = text.splitlines(keepends=True)
74
+ has_entries = any(_ENTRIES_RE.match(ln.strip()) for ln in lines)
75
+ if not has_entries:
76
+ raise ValueError("no REPO_WORKLOG:ENTRIES markers found")
77
+
78
+ # Collect marker positions per date.
79
+ marks: "dict[str, dict[str, int]]" = {}
80
+ order: "list[str]" = []
81
+ for idx, raw in enumerate(lines):
82
+ m = _LEGACY_MARKER_RE.match(raw.strip())
83
+ if not m:
84
+ continue
85
+ date, region, edge = m.group(1), m.group(2), m.group(3)
86
+ key = f"{region}_{edge}"
87
+ slot = marks.setdefault(date, {})
88
+ if key in slot:
89
+ raise ValueError(f"duplicate {region}:{edge} marker for {date}")
90
+ slot[key] = idx
91
+ if date not in order:
92
+ order.append(date)
93
+
94
+ result: "dict[str, dict[str, str]]" = {}
95
+ for date in order:
96
+ slot = marks[date]
97
+ for need in ("GENERATED_START", "GENERATED_END", "MANUAL_START", "MANUAL_END"):
98
+ if need not in slot:
99
+ raise ValueError(f"missing {need} marker for {date}")
100
+ gs, ge = slot["GENERATED_START"], slot["GENERATED_END"]
101
+ ms, me = slot["MANUAL_START"], slot["MANUAL_END"]
102
+ if not (gs < ge and ms < me):
103
+ raise ValueError(f"markers out of order for {date}")
104
+ result[date] = {
105
+ "generated": "".join(lines[gs + 1:ge]),
106
+ "manual": "".join(lines[ms + 1:me]),
107
+ }
108
+ return result
109
+
110
+
111
+ def _read_utf8(path: str, what: str) -> str:
112
+ with open(path, "rb") as fh:
113
+ raw = fh.read()
114
+ try:
115
+ return raw.decode("utf-8")
116
+ except UnicodeDecodeError as exc:
117
+ raise MigrateError("NON_UTF8", f"{what} is not valid UTF-8: {exc}", target=path)
118
+
119
+
120
+ def resolve_source(from_dir: "str | None", from_file: "str | None") -> "tuple[str, str]":
121
+ """Decide what we are migrating from. Returns ``(kind, path)``."""
122
+ if from_dir and from_file:
123
+ raise MigrateError("AMBIGUOUS_SOURCE",
124
+ "Pass only one of --from-dir / --from-file; they are "
125
+ "different legacy shapes.")
126
+ if from_dir:
127
+ return "dir", from_dir
128
+ if from_file:
129
+ return "file", from_file
130
+ # Auto-detect: the flat directory is the newer legacy shape, so prefer it.
131
+ if wm.detect_layout(DEFAULT_LEGACY_DIR) == wm.LAYOUT_LEGACY:
132
+ return "dir", DEFAULT_LEGACY_DIR
133
+ if os.path.exists(DEFAULT_LEGACY):
134
+ return "file", DEFAULT_LEGACY
135
+ raise MigrateError("LEGACY_NOT_FOUND",
136
+ f"No legacy worklog found: neither {DEFAULT_LEGACY_DIR}/ (flat "
137
+ f"directory) nor {DEFAULT_LEGACY} (single file). Pass "
138
+ "--from-dir or --from-file.")
139
+
140
+
141
+ def _plan_from_file(legacy_path: str, worklog_dir: str,
142
+ tz: "str | None") -> "tuple[list, list, dict]":
143
+ text = _read_utf8(legacy_path, "Legacy worklog")
144
+ try:
145
+ parsed = parse_legacy(text)
146
+ except ValueError as exc:
147
+ raise MigrateError("LEGACY_CORRUPT",
148
+ f"Legacy worklog markers are corrupt; refusing to migrate: {exc}",
149
+ target=legacy_path)
150
+ if not parsed:
151
+ raise MigrateError("LEGACY_EMPTY", "Legacy worklog has no date blocks to migrate.",
152
+ target=legacy_path)
153
+
154
+ planned: "list[dict]" = []
155
+ to_write: "list[tuple[str, str]]" = []
156
+ summaries: "dict[str, str]" = {}
157
+ for date in sorted(parsed, reverse=True):
158
+ gen, manual = parsed[date]["generated"], parsed[date]["manual"]
159
+ if wm.contains_marker_line(gen):
160
+ raise MigrateError("LEGACY_CONTAINS_MARKER",
161
+ f"Legacy GENERATED content for {date} contains a marker "
162
+ "line; refusing to migrate rather than produce a corrupt "
163
+ "day file.", date=date)
164
+ summaries[date] = wm.summarise_generated(gen)
165
+ path = wm.day_path(worklog_dir, date, wm.LAYOUT_CURRENT)
166
+ if os.path.exists(path):
167
+ planned.append({"date": date, "path": path, "action": "skip-exists"})
168
+ continue
169
+ planned.append({"date": date, "path": path, "action": "create"})
170
+ to_write.append((path, wm.build_day_file(date, gen, manual, timezone=tz)))
171
+ return planned, to_write, summaries
172
+
173
+
174
+ def _plan_from_dir(src_dir: str, worklog_dir: str) -> "tuple[list, list, dict]":
175
+ """Plan a flat-directory migration: move day files and re-tag their markers.
176
+
177
+ Day content is copied verbatim apart from the marker prefix, so the original
178
+ header metadata (branch, HEAD, timezone) and the prose survive untouched.
179
+ ``timezone`` is deliberately ignored here: each day file already records
180
+ the timezone it was written under, and rewriting it would falsify history.
181
+ """
182
+ if os.path.abspath(src_dir) == os.path.abspath(worklog_dir):
183
+ raise MigrateError("SOURCE_IS_TARGET",
184
+ f"--from-dir and --dir are the same directory ({src_dir}); "
185
+ "nothing to migrate.")
186
+ layout = wm.detect_layout(src_dir)
187
+ if layout != wm.LAYOUT_LEGACY:
188
+ raise MigrateError("SOURCE_NOT_LEGACY",
189
+ f"{src_dir} does not hold a flat legacy worklog (no <date>.md "
190
+ f"files at its root). Detected layout: {layout}.", target=src_dir)
191
+
192
+ planned: "list[dict]" = []
193
+ to_write: "list[tuple[str, str]]" = []
194
+ summaries: "dict[str, str]" = {}
195
+ for date in sorted(wm.list_day_dates(src_dir, layout), reverse=True):
196
+ src = wm.day_path(src_dir, date, layout)
197
+ text = _read_utf8(src, f"Day file {date}.md")
198
+ retagged, _ = wm.retag_markers(text)
199
+ try:
200
+ day = wm.parse_day(retagged, date)
201
+ except wm.WorklogFormatError as exc:
202
+ raise MigrateError("DAY_FILE_CORRUPT",
203
+ f"Day file {date}.md has corrupt/missing markers; refusing "
204
+ "to migrate rather than guess a repair.",
205
+ target=src, issues=exc.issues)
206
+ summaries[date] = wm.summarise_generated(day.generated)
207
+ dst = wm.day_path(worklog_dir, date, wm.LAYOUT_CURRENT)
208
+ if os.path.exists(dst):
209
+ planned.append({"date": date, "path": dst, "action": "skip-exists"})
210
+ continue
211
+ planned.append({"date": date, "path": dst, "action": "create", "source": src})
212
+ to_write.append((dst, retagged))
213
+ if not to_write and not planned:
214
+ raise MigrateError("LEGACY_EMPTY", f"{src_dir} has no day files to migrate.",
215
+ target=src_dir)
216
+ return planned, to_write, summaries
217
+
218
+
219
+ def _source_index_manual(kind: str, src: str, worklog_dir: str) -> "str | None":
220
+ """The MANUAL region to carry into the new index.
221
+
222
+ The target's own index wins if it exists; otherwise a flat source's index
223
+ donates its MANUAL so hand-written navigation notes survive the move.
224
+ """
225
+ target_index = wm.index_path(worklog_dir)
226
+ for path, why in ((target_index, "An existing index.md"),
227
+ (wm.index_path(src) if kind == "dir" else None, "The source index.md")):
228
+ if not path or not os.path.exists(path):
229
+ continue
230
+ try:
231
+ return wm.parse_index(_read_utf8(path, "index.md")).manual
232
+ except wm.WorklogFormatError as exc:
233
+ raise MigrateError("INDEX_CORRUPT_MARKERS",
234
+ f"{why} has corrupt markers; refusing to migrate rather "
235
+ f"than discard its MANUAL region: {exc}", target=path)
236
+ return None
237
+
238
+
239
+ def run(from_dir: "str | None" = None, from_file: "str | None" = None,
240
+ dir: "str | None" = None, timezone: "str | None" = None,
241
+ apply: bool = False) -> dict:
242
+ """Plan (and optionally write) the migration. Raises MigrateError."""
243
+ kind, source = resolve_source(from_dir, from_file)
244
+ worklog_dir = dir or DEFAULT_DIR
245
+ if not os.path.exists(source):
246
+ raise MigrateError("LEGACY_NOT_FOUND", f"Legacy worklog not found: {source}",
247
+ target=source)
248
+
249
+ if kind == "dir":
250
+ planned, to_write, summaries = _plan_from_dir(source, worklog_dir)
251
+ else:
252
+ planned, to_write, summaries = _plan_from_file(source, worklog_dir, timezone)
253
+
254
+ # Preview the rebuilt index over all migrated dates (existing + to-create).
255
+ index_path = wm.index_path(worklog_dir)
256
+ existing_manual = _source_index_manual(kind, source, worklog_dir)
257
+ rows = [(d, summaries[d]) for d in sorted(summaries, reverse=True)]
258
+ index_content = wm.render_index(rows, existing_manual, wm.LAYOUT_CURRENT)
259
+
260
+ common = {
261
+ "source": source,
262
+ "source_kind": kind,
263
+ "legacy_path": source, # retained for callers written against the old key
264
+ "worklog_dir": worklog_dir,
265
+ "index_path": index_path,
266
+ "planned_changes": planned,
267
+ "dates": [d for d, _ in rows],
268
+ "index_preview_sha256": _sha256(index_content),
269
+ }
270
+
271
+ if not apply:
272
+ return {"ok": True, "mode": "dry-run", **common,
273
+ "index_preview": index_content,
274
+ "config_preview": wm.render_config(timezone),
275
+ "note": ("No files have been modified. The source is never deleted; "
276
+ "existing day files are never overwritten.")}
277
+
278
+ # Write the new day files (and index) atomically; on any failure remove the
279
+ # files we created so a partial migration is never left behind.
280
+ created: "list[str]" = []
281
+ try:
282
+ for path, content in to_write:
283
+ writer.atomic_write(
284
+ path, content,
285
+ lambda t, d=os.path.basename(path)[:-3]: wm.parse_day(t, d),
286
+ prefix=".rw-mig-")
287
+ created.append(path)
288
+ writer.atomic_write(index_path, index_content, wm.parse_index,
289
+ prefix=".rw-mig-")
290
+ created.extend(wm.ensure_data_dir(worklog_dir, timezone))
291
+ except Exception as exc:
292
+ for path in created:
293
+ try:
294
+ os.unlink(path)
295
+ except OSError:
296
+ pass
297
+ raise MigrateError("MIGRATION_FAILED",
298
+ f"Migration failed and created files were rolled back: {exc}",
299
+ worklog_dir=worklog_dir)
300
+
301
+ return {"ok": True, "mode": "apply", **common,
302
+ "created_dates": [os.path.basename(p)[:-3] for p, _ in to_write],
303
+ "note": (f"Migration written to {worklog_dir}/. The source ({source}) was NOT "
304
+ "deleted — review the result, then remove it yourself if you are "
305
+ "satisfied. No git add / commit / push was performed.")}
git_worklog/paths.py ADDED
@@ -0,0 +1,85 @@
1
+ """User-level state directory resolution.
2
+
3
+ Previews and analysis results are deliberately kept **outside** the target
4
+ repository — they are working state, not project content, and committing them
5
+ would pollute the very history the tool reads. They live under
6
+ ``~/.git-worklog/`` (roadmap §5), overridable with ``GIT_WORKLOG_HOME`` so a
7
+ test, a sandbox, or a shared CI runner can point somewhere else.
8
+
9
+ ``GIT_WORKLOG_HOME`` is a new variable, not a rename: there was never a
10
+ ``REPO_WORKLOG_HOME``. The paths were hard-coded, which is why they moved here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ HOME_ENV = "GIT_WORKLOG_HOME"
18
+ HOME_DIRNAME = ".git-worklog"
19
+
20
+ # Where state lived before v0.7. Nothing is migrated automatically: previews
21
+ # expire in 24h and analysis files are diagnostic leftovers, so a stale copy is
22
+ # noise rather than loss. `doctor` points it out; deleting it is the user's call.
23
+ LEGACY_HOME_DIRNAME = ".repo_worklog"
24
+
25
+ PREVIEWS_SUBDIR = "previews"
26
+ ANALYSIS_SUBDIR = "analysis"
27
+ # Apply locks live here (roadmap §5). They are transient by nature -- a lock
28
+ # outliving its process is a bug to detect, not state to keep -- so they sit in
29
+ # tmp/ rather than earning a directory of their own.
30
+ TMP_SUBDIR = "tmp"
31
+ # Pages written by `git-worklog view`. Derived from a worklog on every run, so
32
+ # deleting them loses nothing: the next `view` writes them again.
33
+ VIEW_SUBDIR = "view"
34
+
35
+ # The user's own copy of the subagent model table. Seeded from the shipped one
36
+ # the first time a model is resolved and never rewritten after that, so a new
37
+ # model id is an edit to this file rather than a release of the tool.
38
+ MODELS_FILENAME = "provider_models.json"
39
+
40
+ # Owner-only: these files quote source code and diffs from private repositories.
41
+ DIR_MODE = 0o700
42
+
43
+
44
+ def home() -> str:
45
+ """The user-level state directory. ``GIT_WORKLOG_HOME`` wins when set."""
46
+ override = os.environ.get(HOME_ENV)
47
+ if override:
48
+ return os.path.abspath(os.path.expanduser(override))
49
+ return os.path.join(os.path.expanduser("~"), HOME_DIRNAME)
50
+
51
+
52
+ def legacy_home() -> str:
53
+ """The pre-v0.7 state directory, whether or not it exists."""
54
+ return os.path.join(os.path.expanduser("~"), LEGACY_HOME_DIRNAME)
55
+
56
+
57
+ def previews_dir() -> str:
58
+ return os.path.join(home(), PREVIEWS_SUBDIR)
59
+
60
+
61
+ def analysis_dir() -> str:
62
+ return os.path.join(home(), ANALYSIS_SUBDIR)
63
+
64
+
65
+ def tmp_dir() -> str:
66
+ return os.path.join(home(), TMP_SUBDIR)
67
+
68
+
69
+ def view_dir() -> str:
70
+ return os.path.join(home(), VIEW_SUBDIR)
71
+
72
+
73
+ def models_path() -> str:
74
+ return os.path.join(home(), MODELS_FILENAME)
75
+
76
+
77
+ def ensure_dir(path: str) -> str:
78
+ """Create ``path`` (and parents) owner-only, and return it.
79
+
80
+ The mode is applied on creation only; an existing directory is left as the
81
+ user set it. On filesystems without POSIX permissions the mode is ignored,
82
+ which is why this is a best-effort hardening rather than a guarantee.
83
+ """
84
+ os.makedirs(path, mode=DIR_MODE, exist_ok=True)
85
+ return path