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,432 @@
1
+ """Build a per-day analysis manifest for a Day Subagent.
2
+
3
+ A manifest is a *planning aid*: it groups the day's changed files by real work
4
+ area, proposes the context a subagent should read beyond the diff itself, flags
5
+ days large enough to warrant fanning out, and carries the day's authorship as a
6
+ deterministic fact rather than something a model infers from a commit list.
7
+
8
+ It never summarises code and never decides the final worklog wording. Those stay
9
+ with the hosting agent's LLM (roadmap §6.1).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+
17
+ from git_worklog import config, language
18
+ from git_worklog import markers as wm
19
+ from git_worklog.analysis import ( # noqa: F401
20
+ RESULTS_SUBDIR, SCHEMA_VERSION, TASKS_SUBDIR, AnalysisError,
21
+ )
22
+
23
+ # Number of changed files above which a day is flagged as "large" and the Day
24
+ # Subagent is advised to fan out into Code Analysis Subagents.
25
+ LARGE_DAY_FILE_THRESHOLD = 25
26
+
27
+ # Carried on every manifest (roadmap §8) so the contract travels with the task
28
+ # rather than living only in prose the subagent may never have been shown. The
29
+ # skill states all of this at length; a manifest is what actually reaches the
30
+ # model, and a rule that is not in the manifest is a rule that is not enforced.
31
+ ANALYSIS_RULES = [
32
+ "Read the actual patch.",
33
+ "Read historical source code from the target commit.",
34
+ "Do not rely only on commit messages.",
35
+ "Describe the final state after all commits on the same day.",
36
+ "Write all user-facing analysis using the resolved language.",
37
+ "Preserve code symbols, file paths, commit hashes, and identifiers.",
38
+ ]
39
+
40
+ # The categories whose files carry the behaviour a worklog is *about*. A day's
41
+ # analysis may reasonably summarise its docs and config in one line, but source
42
+ # it never mentioned is source it may never have read -- so only these are
43
+ # required to be accounted for (see required_commit_file_pairs below).
44
+ SOURCE_CATEGORIES = ("backend", "frontend", "mobile", "database")
45
+
46
+ # Category priority mirrors references/code-analysis-rules.md (plan section 9.4).
47
+ # Earlier entries win when a path matches more than one rule.
48
+ _CATEGORY_ORDER = [
49
+ "tests",
50
+ "database",
51
+ "configuration",
52
+ "deployment",
53
+ "documentation",
54
+ "frontend",
55
+ "mobile",
56
+ "backend",
57
+ "other",
58
+ ]
59
+
60
+
61
+ def classify(path: str) -> str:
62
+ """Assign a single work category to a file path (priority-ordered)."""
63
+ p = path.lower()
64
+ name = os.path.basename(p)
65
+
66
+ def has(*needles: str) -> bool:
67
+ return any(n in p for n in needles)
68
+
69
+ if has("/test", "test_", "_test.", "/tests/", "__tests__", ".test.",
70
+ ".spec.", "/spec/", "spec_"):
71
+ return "tests"
72
+ if has("migration", "migrations", "/schema", "alembic", "/db/") or p.endswith(".sql"):
73
+ return "database"
74
+ if has(".github/", ".gitlab-ci", "/ci/", "dockerfile", "docker-compose",
75
+ "/config", "makefile", "justfile") or name in (
76
+ "dockerfile", "makefile", "justfile") or p.endswith(
77
+ (".yml", ".yaml", ".toml", ".ini", ".cfg", ".env")):
78
+ return "configuration"
79
+ if has("deploy", "/k8s/", "kubernetes", "helm", "terraform", "/infra",
80
+ "ansible") or p.endswith((".tf", ".tfvars")):
81
+ return "deployment"
82
+ if has("/docs/", "readme", "changelog", "license") or p.endswith((".md", ".rst", ".adoc")):
83
+ return "documentation"
84
+ if has("android/", "ios/", "/mobile/") or p.endswith((".swift", ".kt", ".m", ".mm")):
85
+ return "mobile"
86
+ if has("frontend/", "/web/", "/ui/", "/components/", "/pages/") or p.endswith(
87
+ (".tsx", ".jsx", ".vue", ".css", ".scss", ".sass", ".less", ".html", ".svelte")):
88
+ return "frontend"
89
+ if has("/api/", "controller", "/service", "/routes", "/handlers", "/models",
90
+ "repository") or p.endswith(
91
+ (".py", ".go", ".rb", ".java", ".rs", ".php", ".cs", ".ts", ".js", ".ex", ".exs")):
92
+ return "backend"
93
+ return "other"
94
+
95
+
96
+ def _collect_authors(commits: "list[dict]") -> "list[str]":
97
+ """Distinct author names for the day, ordered by first appearance.
98
+
99
+ Deterministic fact, so it is computed here rather than inferred by a
100
+ subagent from the commit list -- a day with many commits is exactly where a
101
+ model would drop a contributor.
102
+ """
103
+ authors: "list[str]" = []
104
+ for commit in commits:
105
+ name = commit.get("author_name")
106
+ if name and name not in authors:
107
+ authors.append(name)
108
+ return authors
109
+
110
+
111
+ def _top_module(path: str) -> str:
112
+ parts = path.split("/")
113
+ return parts[0] if len(parts) == 1 else "/".join(parts[:2])
114
+
115
+
116
+ def _collect_changed_files(commits: "list[dict]") -> "list[dict]":
117
+ seen: "dict[str, dict]" = {}
118
+ for commit in commits:
119
+ for f in commit.get("files", []):
120
+ path = f.get("path")
121
+ if not path:
122
+ continue
123
+ rec = seen.setdefault(path, {
124
+ "path": path,
125
+ "statuses": set(),
126
+ "is_binary": f.get("is_binary", False),
127
+ "is_submodule": f.get("is_submodule", False),
128
+ "old_path": f.get("old_path"),
129
+ "commits": [],
130
+ })
131
+ rec["statuses"].add(f.get("status"))
132
+ rec["is_binary"] = rec["is_binary"] or f.get("is_binary", False)
133
+ rec["is_submodule"] = rec["is_submodule"] or f.get("is_submodule", False)
134
+ if f.get("old_path"):
135
+ rec["old_path"] = f["old_path"]
136
+ rec["commits"].append(commit.get("short_hash"))
137
+ files = []
138
+ for rec in seen.values():
139
+ rec["statuses"] = sorted(x for x in rec["statuses"] if x)
140
+ rec["category"] = classify(rec["path"])
141
+ files.append(rec)
142
+ files.sort(key=lambda r: r["path"])
143
+ return files
144
+
145
+
146
+ def _build_groups(files: "list[dict]") -> "list[dict]":
147
+ buckets: "dict[tuple[str, str], list[dict]]" = {}
148
+ for f in files:
149
+ key = (f["category"], _top_module(f["path"]))
150
+ buckets.setdefault(key, []).append(f)
151
+ groups = []
152
+ for (category, module), members in buckets.items():
153
+ commits = sorted({c for m in members for c in m["commits"] if c})
154
+ groups.append({
155
+ "group": f"{category}:{module}",
156
+ "category": category,
157
+ "module": module,
158
+ "files": [m["path"] for m in members],
159
+ "commits": commits,
160
+ "has_binary": any(m["is_binary"] for m in members),
161
+ "has_submodule": any(m["is_submodule"] for m in members),
162
+ })
163
+ # Order groups by the documented category priority, then by name.
164
+ order = {c: i for i, c in enumerate(_CATEGORY_ORDER)}
165
+ groups.sort(key=lambda g: (order.get(g["category"], len(order)), g["group"]))
166
+ return groups
167
+
168
+
169
+ def _required_context(groups: "list[dict]") -> "list[dict]":
170
+ """Suggest the reading a subagent must do beyond the diff itself."""
171
+ context = []
172
+ for g in groups:
173
+ if g["category"] in ("documentation", "configuration", "deployment"):
174
+ depth = "surface"
175
+ elif g["category"] in ("backend", "frontend", "mobile", "database"):
176
+ depth = "deep"
177
+ else:
178
+ depth = "standard"
179
+ context.append({
180
+ "group": g["group"],
181
+ "category": g["category"],
182
+ "read": [
183
+ "full enclosing function / class / component for each changed hunk",
184
+ "one layer of direct callers",
185
+ "one layer of direct dependencies",
186
+ "the corresponding tests",
187
+ ],
188
+ "expand_second_layer_if": "public API, schema, or shared core is touched",
189
+ "depth": depth,
190
+ })
191
+ return context
192
+
193
+
194
+ def parse_model(model_json: str) -> "dict | None":
195
+ """Parse the structured model object threaded in from resolve_provider_model.
196
+
197
+ The object is emitted verbatim onto the manifest so every subagent runs on
198
+ the same provider/model. ``reasoning_effort`` is present only when it applies
199
+ (e.g. openai) — never an empty string. Absent input -> ``None``.
200
+ """
201
+ if not model_json:
202
+ return None
203
+ try:
204
+ model = json.loads(model_json)
205
+ except json.JSONDecodeError as exc:
206
+ raise AnalysisError("BAD_MODEL_JSON", f"--model-json is not valid JSON: {exc}")
207
+ if not isinstance(model, dict):
208
+ raise AnalysisError("BAD_MODEL_JSON", "--model-json must be a JSON object.")
209
+ return model
210
+
211
+
212
+ def resolve_language(explicit: "str | None", source: "str | None",
213
+ worklog_dir: "str | None" = None) -> language.Resolution:
214
+ """Decide this run's output language (§6.2.1).
215
+
216
+ ``allow_locale`` is off: a manifest exists to be handed to an agent's LLM,
217
+ which makes this an agent-hosted run by definition, and §6.2.5 is explicit
218
+ that the host OS locale must not decide there — CI and dev containers are
219
+ pinned to C or en_US and say nothing about what the user wants. The tiers
220
+ above config live in the agent, which passes them down explicitly.
221
+ """
222
+ worklog_dir = worklog_dir or os.path.join(".", wm.WORKLOG_DIRNAME)
223
+ return language.resolve(
224
+ explicit=explicit,
225
+ source=source,
226
+ config_value=config.language(config.load(worklog_dir)),
227
+ allow_locale=False,
228
+ )
229
+
230
+
231
+ def _pair_required(f: dict, path: str) -> bool:
232
+ """Must this (commit, file) pair be accounted for by the day's analysis?
233
+
234
+ Deliberately narrower than "everything that changed". Three exclusions, each
235
+ for a different reason:
236
+
237
+ * non-source categories -- docs, config, CI and tests are real work, but a
238
+ day may fairly cover them in a sentence, and demanding a citation for each
239
+ would fail honest days rather than catch dishonest ones.
240
+ * binary and submodule files -- there is no source to read or symbol to cite.
241
+ * deletions -- the file is *gone* from that commit's tree, so any citation of
242
+ it at that commit would be rejected by the evidence check. Requiring one
243
+ would be requiring the impossible.
244
+ """
245
+ if classify(path) not in SOURCE_CATEGORIES:
246
+ return False
247
+ if f.get("is_binary") or f.get("is_submodule"):
248
+ return False
249
+ if f.get("status") == "D":
250
+ return False
251
+ return True
252
+
253
+
254
+ def _required_commit_file_pairs(commits: "list[dict]") -> "list[dict]":
255
+ """Every (commit, file) pair of the day, flagged for whether it is required.
256
+
257
+ All pairs are listed, not only the required ones: the subagent should see
258
+ what it is *not* being held to as well, and a reader of the manifest can
259
+ tell "excluded" from "overlooked".
260
+ """
261
+ pairs = []
262
+ for commit in commits:
263
+ sha = commit.get("short_hash")
264
+ for f in commit.get("files", []):
265
+ path = f.get("path")
266
+ if not path:
267
+ continue
268
+ pairs.append({
269
+ "commit": sha,
270
+ "file": path,
271
+ "category": classify(path),
272
+ "required": _pair_required(f, path),
273
+ })
274
+ pairs.sort(key=lambda p: (p["file"], p["commit"] or ""))
275
+ return pairs
276
+
277
+
278
+ def required_files(manifest: dict) -> "set[str]":
279
+ """The distinct files a manifest requires the day's analysis to account for.
280
+
281
+ File-level, not pair-level, and that is forced rather than chosen: coverage
282
+ is satisfied by `work_items[].files[]` as well as by `evidence[]`, and a
283
+ `files[]` entry names a path without saying which commit it belongs to. Only
284
+ `evidence[]` carries both, so a pair-level rule could be met by evidence
285
+ alone -- which measurement showed rejects days that are actually fine.
286
+ """
287
+ return {p["file"] for p in manifest.get("required_commit_file_pairs") or []
288
+ if p.get("required")}
289
+
290
+
291
+ def load_tasks(run_dir: str) -> dict:
292
+ """Read back what a run asked for, from the manifests it dispatched.
293
+
294
+ The tasks are the run's own record of its scope, which is why both `collect`
295
+ and `preview` read them rather than taking a caller-supplied date list: a day
296
+ could otherwise be dropped from a run simply by leaving it out of the second
297
+ command -- the exact failure `missing` exists to catch.
298
+ """
299
+ tasks_dir = os.path.join(run_dir, TASKS_SUBDIR)
300
+ if not os.path.isdir(tasks_dir):
301
+ raise AnalysisError(
302
+ "RUN_NOT_PREPARED",
303
+ f"{run_dir} has no {TASKS_SUBDIR}/ directory, so there is nothing to "
304
+ f"collect against. Was this run created by `analyze prepare`?",
305
+ run_dir=run_dir)
306
+
307
+ dates, resolved_language = [], None
308
+ required_by_date, commits_by_date, manifests = {}, {}, {}
309
+ for name in sorted(os.listdir(tasks_dir)):
310
+ if not name.endswith(".json"):
311
+ continue
312
+ with open(os.path.join(tasks_dir, name), "r", encoding="utf-8") as fh:
313
+ manifest = json.load(fh)
314
+ date = manifest["date"]
315
+ dates.append(date)
316
+ manifests[date] = manifest
317
+ required_by_date[date] = required_files(manifest)
318
+ commits_by_date[date] = [c.get("short_hash")
319
+ for c in manifest.get("commits") or []
320
+ if c.get("short_hash")]
321
+ block = manifest.get("language") or {}
322
+ if block.get("resolved"):
323
+ resolved_language = block["resolved"]
324
+ if not dates:
325
+ raise AnalysisError("RUN_HAS_NO_TASKS",
326
+ f"{tasks_dir} contains no manifests.", run_dir=run_dir)
327
+
328
+ return {
329
+ "run_dir": run_dir,
330
+ "tasks_dir": tasks_dir,
331
+ "results_dir": os.path.join(run_dir, RESULTS_SUBDIR),
332
+ "dates": dates,
333
+ "language": resolved_language,
334
+ "required_by_date": required_by_date,
335
+ "commits_by_date": commits_by_date,
336
+ "manifests": manifests,
337
+ }
338
+
339
+
340
+ def _repository_block(history: "dict | None") -> "dict | None":
341
+ """The §8 repository block, taken from the history payload's own metadata."""
342
+ info = (history or {}).get("repository")
343
+ if not isinstance(info, dict):
344
+ return None
345
+ return {
346
+ "root": info.get("root"),
347
+ "git_dir": info.get("git_dir"),
348
+ "head": info.get("head"),
349
+ "branch": info.get("branch"),
350
+ }
351
+
352
+
353
+ def build(date: str, timezone: str, history: "dict | None" = None,
354
+ worktree: "dict | None" = None, include_uncommitted: bool = False,
355
+ provider: str = "anthropic", model: "dict | None" = None,
356
+ lang: "language.Resolution | None" = None,
357
+ run_id: "str | None" = None,
358
+ result_path: "str | None" = None,
359
+ parts_dir: "str | None" = None) -> dict:
360
+ """Assemble one day's manifest from that day's collected Git facts.
361
+
362
+ ``run_id``, ``result_path`` and ``parts_dir`` are known only to a caller
363
+ that minted a run (``analyze prepare``), so they are optional: a manifest
364
+ built ad hoc for one day is still a valid manifest, it just does not belong
365
+ to a run yet.
366
+ """
367
+ if history and not history.get("ok", True):
368
+ raise AnalysisError("BAD_HISTORY_INPUT",
369
+ "collect_git_history reported an error.",
370
+ upstream=history.get("errors"))
371
+ commits = history.get("commits", []) if history else []
372
+
373
+ uncommitted = []
374
+ if include_uncommitted and worktree:
375
+ for bucket in ("staged", "unstaged", "untracked"):
376
+ for f in worktree.get(bucket, []):
377
+ uncommitted.append({
378
+ "path": f.get("path"),
379
+ "state": bucket,
380
+ "status": f.get("status"),
381
+ "is_binary": f.get("is_binary", False),
382
+ "category": classify(f.get("path", "")),
383
+ })
384
+
385
+ files = _collect_changed_files(commits)
386
+ groups = _build_groups(files)
387
+ is_large = len(files) > LARGE_DAY_FILE_THRESHOLD or len(groups) > 8
388
+
389
+ return {
390
+ "ok": True,
391
+ "schema_version": SCHEMA_VERSION,
392
+ "run_id": run_id,
393
+ "date": date,
394
+ "timezone": timezone,
395
+ "repository": _repository_block(history),
396
+ "language": lang.as_manifest() if lang else None,
397
+ "warnings": lang.warnings if lang else [],
398
+ "include_uncommitted": bool(include_uncommitted),
399
+ "provider": provider,
400
+ "model": model,
401
+ "has_changes": bool(commits) or bool(uncommitted),
402
+ "commit_count": len(commits),
403
+ "authors": _collect_authors(commits),
404
+ "commits": [{
405
+ "short_hash": c.get("short_hash"),
406
+ "full_hash": c.get("full_hash"),
407
+ "author_name": c.get("author_name"),
408
+ "subject": c.get("subject"),
409
+ "is_merge": c.get("is_merge"),
410
+ "is_revert_candidate": c.get("is_revert_candidate"),
411
+ } for c in commits],
412
+ "changed_files": [{
413
+ "path": f["path"], "statuses": f["statuses"], "category": f["category"],
414
+ "is_binary": f["is_binary"], "is_submodule": f["is_submodule"],
415
+ "old_path": f["old_path"], "commits": f["commits"],
416
+ } for f in files],
417
+ "file_groups": groups,
418
+ "required_context": _required_context(groups),
419
+ "required_commit_file_pairs": _required_commit_file_pairs(commits),
420
+ "analysis_rules": list(ANALYSIS_RULES),
421
+ "uncommitted_changes": uncommitted,
422
+ "large_day": is_large,
423
+ "recommended_code_analysis_subagents": len(groups) if is_large else 0,
424
+ "result_path": result_path,
425
+ # Where a fan-out's per-group parts go. It is on the manifest because a
426
+ # Day Subagent is handed a manifest and a result_path and nothing else:
427
+ # left to derive a sibling path from result_path, it lands in results/,
428
+ # where every extra file is an `unknown` that fails the whole run. The
429
+ # day the contract most wants split up is exactly the day that then
430
+ # cannot be written -- observed on the first real large-day run.
431
+ "parts_dir": parts_dir,
432
+ }
@@ -0,0 +1,211 @@
1
+ """Reconcile a ref scope's authoritative commit set against the day files.
2
+
3
+ Why this is code rather than an instruction
4
+ -------------------------------------------
5
+ :mod:`git_worklog.analysis.refs` establishes that a version is bounded by a
6
+ commit set while the worklog is indexed by calendar date, so a report on a tag
7
+ must read the day files its commits landed on *without* describing everything
8
+ else those days contain. Until now that reconciliation was a sentence in
9
+ ``references/report-mode.md`` addressed to the model:
10
+
11
+ Describe only work whose commits appear in ``commits[]``. When a day file
12
+ describes work whose hashes are absent from the range, leave it out.
13
+
14
+ The day files carry each commit's short hash, so that instruction was always
15
+ checkable -- the v0.4.0 design said as much, and deferred the check until there
16
+ was evidence the model needed it. Issue #19 supplied the evidence: day subagents
17
+ fabricate citations and report themselves as verified. A rule the model is asked
18
+ to keep, with nothing checking it, is not a contract. This module turns it into
19
+ data the caller can act on.
20
+
21
+ Language independence
22
+ ---------------------
23
+ Hashes are found by scanning the GENERATED region for Git object names, **not**
24
+ by parsing the ``相關 commits`` bullet label. The label is zh-TW; a day file
25
+ written in any other language (which §6.2 explicitly allows) carries a
26
+ translated one. Keying off the label would make this silently find zero hashes
27
+ in an English day file and report a clean reconciliation -- a check that passes
28
+ because it looked at nothing is worse than no check, and that exact bug already
29
+ shipped once, in the index summary (fixed in PR 4 by adding a marker).
30
+
31
+ Only the GENERATED region is scanned. MANUAL is a human's own note, never
32
+ analysis material, and the file's meta block carries a ``HEAD`` hash that is not
33
+ a citation at all -- both sit outside GENERATED, so both are excluded by
34
+ construction rather than by a filter that could rot.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import re
41
+ import subprocess
42
+
43
+ from git_worklog import markers as wm
44
+ from git_worklog.analysis.history import UNIT_SEP, GitError
45
+
46
+ # A Git object name as it appears in prose: 7-40 hex characters on a word
47
+ # boundary. Case-insensitive deliberately. Git writes lowercase and every hash
48
+ # in a day file arrives from this tool's own output, so an uppercase hash means
49
+ # the model reformatted one -- and missing a real hash would under-report
50
+ # out-of-range work, which is the failure this whole module exists to prevent.
51
+ # The cost is that a rare all-hex English word ("defaced", "acceded") becomes a
52
+ # candidate; those land in `unresolved`, which is why that list is a hint to
53
+ # look rather than a verdict.
54
+ _HASH_RE = re.compile(r"\b[0-9a-fA-F]{7,40}\b")
55
+
56
+
57
+ def cited_hashes(generated: str) -> "set[str]":
58
+ """Every Git object name cited in a day file's GENERATED region, lowercased."""
59
+ return {m.group(0).lower() for m in _HASH_RE.finditer(generated)}
60
+
61
+
62
+ def _read_day(worklog_dir: str, date: str) -> "str | None":
63
+ """A day file's GENERATED region, or None when there is nothing to read.
64
+
65
+ A malformed day file yields None rather than raising: reconciliation is a
66
+ read-only question about coverage, and `validate` is the command that has an
67
+ opinion about structure. The day still shows up as citing nothing, so its
68
+ commits are reported unbacked -- the honest answer.
69
+ """
70
+ path = wm.day_path(worklog_dir, date)
71
+ if not os.path.isfile(path):
72
+ return None
73
+ with open(path, encoding="utf-8") as fh:
74
+ text = fh.read()
75
+ day, _issues = wm.scan_day(text, date)
76
+ return day.generated if day else None
77
+
78
+
79
+ def _batch_resolve(repo: str, tokens: "list[str]") -> "dict[str, str]":
80
+ """Map each token to the full SHA of the commit it names, where it names one.
81
+
82
+ One ``cat-file --batch-check`` rather than a ``rev-parse`` per token: a
83
+ 90-day report can cite hundreds of hashes, and this keeps that one process
84
+ instead of hundreds. Tokens that name nothing, name a non-commit (a tree, a
85
+ blob), or are ambiguous are simply absent from the result.
86
+ """
87
+ if not tokens:
88
+ return {}
89
+ proc = subprocess.run(
90
+ ["git", "-C", repo, "cat-file", "--batch-check"],
91
+ input="\n".join(tokens).encode("utf-8"),
92
+ stdout=subprocess.PIPE,
93
+ stderr=subprocess.PIPE,
94
+ )
95
+ if proc.returncode != 0:
96
+ raise GitError(proc.stderr.decode("utf-8", "replace").strip())
97
+
98
+ resolved: "dict[str, str]" = {}
99
+ # --batch-check answers in input order: "<sha> commit <size>" for a hit, and
100
+ # "<token> missing" / "<token> ambiguous" otherwise.
101
+ for token, line in zip(tokens, proc.stdout.decode("utf-8", "replace").splitlines()):
102
+ fields = line.split()
103
+ if len(fields) >= 2 and fields[1] == "commit":
104
+ resolved[token] = fields[0]
105
+ return resolved
106
+
107
+
108
+ def _subjects(repo: str, shas: "list[str]") -> "dict[str, dict]":
109
+ """Subject and short hash for each SHA, in one call."""
110
+ if not shas:
111
+ return {}
112
+ fmt = UNIT_SEP.join(["%H", "%h", "%an", "%s"])
113
+ proc = subprocess.run(
114
+ ["git", "-C", repo, "log", "--no-walk", f"--pretty=format:{fmt}", *shas],
115
+ stdout=subprocess.PIPE,
116
+ stderr=subprocess.PIPE,
117
+ )
118
+ if proc.returncode != 0:
119
+ raise GitError(proc.stderr.decode("utf-8", "replace").strip())
120
+
121
+ out: "dict[str, dict]" = {}
122
+ for line in proc.stdout.decode("utf-8", "replace").splitlines():
123
+ if not line.strip():
124
+ continue
125
+ fields = line.split(UNIT_SEP)
126
+ if len(fields) < 4:
127
+ continue
128
+ full, short, author, subject = fields[:4]
129
+ out[full] = {"full_hash": full, "short_hash": short,
130
+ "author_name": author, "subject": subject}
131
+ return out
132
+
133
+
134
+ def reconcile(repo: str, worklog_dir: str, commits: "list[dict]",
135
+ dates: "list[str]") -> dict:
136
+ """Check what the day files for ``dates`` cite against ``commits``.
137
+
138
+ ``commits`` is the authority (:func:`git_worklog.analysis.refs.resolve`'s
139
+ ``commits[]``); ``dates`` are the days worth reading. Every citation lands in
140
+ exactly one bucket, so nothing goes missing silently:
141
+
142
+ * ``backed`` -- in the range, and some day file cites it. Material exists.
143
+ * ``unbacked`` -- in the range, cited by no day file. Either the day is a
144
+ gap, or its file simply does not mention this commit. A report must not
145
+ claim to describe these.
146
+ * ``out_of_range`` -- cited by a day file, a real commit, **not** in the
147
+ range. The enforcement: these are what a report on this tag must leave out.
148
+ * ``unresolved`` -- cited, but does not name **one** commit here. Usually
149
+ prose that happens to be hex; possibly a fabricated or rebased-away hash;
150
+ possibly a prefix short enough to name several commits, which identifies
151
+ none of them.
152
+ """
153
+ in_range = {c["full_hash"]: c for c in commits}
154
+
155
+ citations: "dict[str, list[str]]" = {} # token -> dates citing it
156
+ for date in dates:
157
+ generated = _read_day(worklog_dir, date)
158
+ if generated is None:
159
+ continue
160
+ for token in cited_hashes(generated):
161
+ citations.setdefault(token, []).append(date)
162
+
163
+ # A token is in range when it prefixes exactly one of the range's commits.
164
+ # Matching by prefix rather than against short_hash means a day file citing a
165
+ # full 40-char hash reconciles the same as one citing the 7-char form.
166
+ #
167
+ # A token prefixing *several* of them backs none: it does not say which one
168
+ # was described, and crediting all of them would let a report claim analysis
169
+ # exists for a commit nothing analysed -- the one direction this module must
170
+ # never fail in. It joins the leftovers, where Git will call it ambiguous for
171
+ # the same reason and it will surface as unresolved.
172
+ cited_in_range: "set[str]" = set()
173
+ leftover: "list[str]" = []
174
+ for token in citations:
175
+ hit = [full for full in in_range if full.startswith(token)]
176
+ if len(hit) == 1:
177
+ cited_in_range.add(hit[0])
178
+ else:
179
+ leftover.append(token)
180
+
181
+ resolved = _batch_resolve(repo, sorted(leftover))
182
+ subjects = _subjects(repo, sorted(set(resolved.values())))
183
+
184
+ out_of_range = []
185
+ for token in sorted(leftover):
186
+ full = resolved.get(token)
187
+ if not full:
188
+ continue
189
+ info = dict(subjects.get(full, {"full_hash": full, "short_hash": token,
190
+ "author_name": "", "subject": ""}))
191
+ info["cited_on"] = sorted(citations[token])
192
+ out_of_range.append(info)
193
+
194
+ unresolved = [{"hash": t, "cited_on": sorted(citations[t])}
195
+ for t in sorted(leftover) if t not in resolved]
196
+
197
+ backed = [c for c in commits if c["full_hash"] in cited_in_range]
198
+ unbacked = [c for c in commits if c["full_hash"] not in cited_in_range]
199
+
200
+ return {
201
+ "backed": backed,
202
+ "unbacked": unbacked,
203
+ "out_of_range": out_of_range,
204
+ "unresolved": unresolved,
205
+ "backed_count": len(backed),
206
+ "unbacked_count": len(unbacked),
207
+ "clean": not out_of_range and not unbacked,
208
+ }
209
+
210
+
211
+ __all__ = ["cited_hashes", "reconcile"]