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,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"]
|