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,340 @@
1
+ """``git-worklog analyze`` — hand the day's work to the agent, then check it back in.
2
+
3
+ Two halves of one contract (roadmap §7):
4
+
5
+ ``prepare`` mints a run and writes one Analysis Manifest per day. It does not
6
+ analyse anything -- it decides *what must be analysed* and *in which language*,
7
+ both of which are deterministic, and hands each day an output path to write to.
8
+
9
+ ``collect`` reads what came back and refuses to believe it on request. Between
10
+ the two sits the hosting agent's LLM, which is the only part that reads code and
11
+ writes prose. That division is why the CLI needs no model API key (§6.1).
12
+
13
+ Run layout (§7.1)::
14
+
15
+ ~/.git-worklog/analysis/<run_id>/
16
+ tasks/<date>.json the manifest -- what to analyse
17
+ results/<date>.json the result -- what was found
18
+
19
+ The two directories mean ``collect`` can tell a task that was never answered
20
+ from a result nobody asked for, and ``results/`` is just a directory of
21
+ ``<date>.json`` files -- which is exactly what ``results.read_run`` already
22
+ reads, so both front ends share one validator rather than one per layout.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+
30
+ from git_worklog import dates as gwdates
31
+ from git_worklog import language
32
+ from git_worklog import providers
33
+ from git_worklog.analysis import (
34
+ PARTS_SUBDIR, RESULTS_SUBDIR, SCHEMA_VERSION, TASKS_SUBDIR, AnalysisError,
35
+ )
36
+ from git_worklog.analysis import history as ah
37
+ from git_worklog.analysis import manifest as am
38
+ from git_worklog.analysis import results as ar
39
+ from git_worklog.analysis import worktree as aw
40
+
41
+
42
+ def _resolve_model(args) -> "tuple[str, dict, list]":
43
+ """The provider and model for the whole run, plus any warnings to surface.
44
+
45
+ ``--host`` asks the resolver; ``--provider``/``--model-json`` state the answer
46
+ directly. Both stay: the skill knows its host, while a caller reconstructing a
47
+ previous run has the model object and no host to re-derive it from.
48
+
49
+ Returns ``(provider, model, warnings)``. The warnings are not decoration --
50
+ an honoured-but-deprecated env var is reported here or nowhere, and the whole
51
+ point of that warning is that a model must never change under the user in
52
+ silence.
53
+ """
54
+ if not args.host:
55
+ if args.model or args.escalate:
56
+ raise AnalysisError(
57
+ "ARG_CONFLICT",
58
+ "--model and --escalate select a model from the host's config, so "
59
+ "they need --host. With --provider, state the model in "
60
+ "--model-json instead.")
61
+ # Unstated provider keeps its historical default rather than becoming a
62
+ # None on the manifest.
63
+ return (args.provider or "anthropic"), am.parse_model(args.model_json), []
64
+ if args.provider or args.model_json:
65
+ raise AnalysisError(
66
+ "ARG_CONFLICT",
67
+ "--host resolves the provider and model; do not also pass "
68
+ "--provider / --model-json.")
69
+ resolved = providers.resolve(host=args.host, model=args.model,
70
+ escalate=args.escalate)
71
+ return resolved["provider"], resolved["model"], list(resolved.get("warnings", []))
72
+
73
+
74
+ def _model_label(model: "dict | None", provider: str) -> str:
75
+ """Name the resolved model for a human-facing warning.
76
+
77
+ ``model`` is null when no ``--host`` resolved one -- the run uses the
78
+ provider's default, which is exactly the case a large-day warning most needs
79
+ to name, so it says so rather than leaving a blank.
80
+ """
81
+ if isinstance(model, dict) and model.get("model_id"):
82
+ return model["model_id"]
83
+ return f"the default {provider} model"
84
+
85
+
86
+ def _large_day_warning(date: str, manifest: dict, label: str) -> "dict | None":
87
+ """Surface a day too big for one subagent, before it is dispatched.
88
+
89
+ ``large_day`` has been on the manifest all along, but as advice with no
90
+ consequence: nothing sized the day against the model it was about to be given
91
+ to, and ``escalation_recommended`` came back *from* the subagent -- the party
92
+ least able to notice it was overwhelmed (#22). This moves the signal to
93
+ prepare, which knows the commit count, the file count and the model before
94
+ dispatch and needs no one's opinion to see the day is large.
95
+
96
+ It surfaces the counts, not a verdict: a 60-file day and a 26-file day are
97
+ both ``large_day`` yet not the same problem, and the fix for that is to show
98
+ the numbers, not to invent a second threshold. The orchestrator turns this
99
+ into a choice -- fan out, escalate, or proceed (SKILL.md) -- exactly as the
100
+ over-30-day and gap prompts do. Proceeding is allowed; nothing is refused.
101
+ """
102
+ if not manifest["large_day"]:
103
+ return None
104
+ files = len(manifest["changed_files"])
105
+ groups = len(manifest["file_groups"])
106
+ fan = manifest["recommended_code_analysis_subagents"]
107
+ return {
108
+ "code": "LARGE_DAY",
109
+ "date": date,
110
+ "message": (
111
+ f"{date} is a large day: {manifest['commit_count']} commit(s), "
112
+ f"{files} changed file(s) in {groups} group(s), on {label}. One "
113
+ f"subagent may not hold it — consider fanning out into the {fan} "
114
+ f"recommended Code Analysis Subagents, or escalating the model, "
115
+ f"before dispatching. Proceeding as-is is allowed."),
116
+ "commit_count": manifest["commit_count"],
117
+ "changed_file_count": files,
118
+ "group_count": groups,
119
+ "recommended_code_analysis_subagents": fan,
120
+ }
121
+
122
+
123
+ def _prepare(args) -> "tuple[dict, int]":
124
+ # One date contract, not a second one written out here. dates.resolve()
125
+ # accepts every form the user can give (7d / --days / --date / --from+--to),
126
+ # validates them, and hands back each day's half-open window already
127
+ # computed -- so prepare cannot disagree with the rule about where a day
128
+ # starts, because it no longer states it.
129
+ date, days = gwdates.absorb_shortcut(args.shortcut, args.date, args.days)
130
+ resolved = gwdates.resolve(
131
+ date=date, days=days, from_=getattr(args, "from"), to=args.to,
132
+ timezone=args.timezone, today=args.today,
133
+ )
134
+ dates = [d["date"] for d in resolved["dates"]]
135
+ windows = {d["date"]: (d["start"], d["end"]) for d in resolved["dates"]}
136
+ tz_name = resolved["timezone"]
137
+ provider, model, model_warnings = _resolve_model(args)
138
+
139
+ # Resolved once for the whole run, never per day: a manifest's resolved
140
+ # language is what each day's result is checked against, and days that
141
+ # disagree block the run (§6.2.8). Deciding it per day would let the same
142
+ # run ask for two languages and then reject itself for having got them.
143
+ lang = am.resolve_language(args.language, args.language_source, args.dir)
144
+
145
+ info = ah.repo_info(args.repo)
146
+ minted = ar.mint_run(dates, args.run_dir)
147
+ run_dir = minted["run_dir"]
148
+ tasks_dir = os.path.join(run_dir, TASKS_SUBDIR)
149
+ results_dir = os.path.join(run_dir, RESULTS_SUBDIR)
150
+ parts_dir = os.path.join(run_dir, PARTS_SUBDIR)
151
+ for d in (tasks_dir, results_dir, parts_dir):
152
+ os.makedirs(d, mode=0o700, exist_ok=True)
153
+
154
+ # Uncommitted work belongs to today and to no other day: a file's mtime says
155
+ # when it was last written, not when the work happened, so there is nothing
156
+ # to attribute a dirty worktree to on any past date. "Today" is read in the
157
+ # run's timezone, which is the same clock that decides where a day starts.
158
+ today = resolved["today"]
159
+ worktree = aw.inspect(args.repo) if args.include_uncommitted else None
160
+
161
+ model_label = _model_label(model, provider)
162
+ tasks = []
163
+ large_day_warnings = []
164
+ for date in dates:
165
+ start, end = windows[date]
166
+ history = ah.collect(
167
+ repo=args.repo, since=start, until=end,
168
+ date_field=args.date_field, worklog_dir=args.worklog_dir,
169
+ )
170
+ day_worktree = worktree if (worktree and date == today) else None
171
+ result_path = os.path.join(results_dir, f"{date}.json")
172
+ manifest = am.build(
173
+ date=date, timezone=tz_name, history=history,
174
+ worktree=day_worktree, include_uncommitted=bool(day_worktree),
175
+ provider=provider, model=model,
176
+ lang=lang, run_id=minted["run_id"], result_path=result_path,
177
+ parts_dir=parts_dir,
178
+ )
179
+ manifest_path = os.path.join(tasks_dir, f"{date}.json")
180
+ with open(manifest_path, "w", encoding="utf-8") as fh:
181
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
182
+ tasks.append({
183
+ "date": date,
184
+ "manifest_path": manifest_path,
185
+ "result_path": result_path,
186
+ "has_changes": manifest["has_changes"],
187
+ "commit_count": manifest["commit_count"],
188
+ # The counts, not just the boolean: a 60-file day and a 26-file day
189
+ # are both large_day and are not the same dispatch decision (#22).
190
+ # The recommended fan-out count is a large-day mechanic, so it rides
191
+ # the LARGE_DAY warning and the manifest, not this per-day summary.
192
+ "changed_file_count": len(manifest["changed_files"]),
193
+ "group_count": len(manifest["file_groups"]),
194
+ "large_day": manifest["large_day"],
195
+ "include_uncommitted": bool(day_worktree),
196
+ })
197
+ warning = _large_day_warning(date, manifest, model_label)
198
+ if warning:
199
+ large_day_warnings.append(warning)
200
+
201
+ payload = {
202
+ "ok": True,
203
+ "schema_version": SCHEMA_VERSION,
204
+ "run_id": minted["run_id"],
205
+ "run_dir": run_dir,
206
+ # The whole of repo_info, not a chosen subset: this is the only place
207
+ # the caller learns the repository state now, and the previous
208
+ # `collect_git_history --info-only` step it replaces reported all of it.
209
+ "repository": info,
210
+ # What the date spec actually resolved to. Reported rather than assumed:
211
+ # the caller may have passed "7d" and is entitled to see which seven days
212
+ # that turned out to be, and in which zone, before any of it is written.
213
+ "range": {"mode": resolved["mode"], "from": dates[0], "to": dates[-1],
214
+ "days_count": resolved["days_count"], "today": today},
215
+ "timezone": {"resolved": tz_name, "source": resolved["timezone_source"]},
216
+ "provider": provider,
217
+ "model": model,
218
+ "language": lang.as_manifest(),
219
+ "tasks": tasks,
220
+ }
221
+ if worktree:
222
+ payload["worktree_fingerprint"] = worktree["worktree_fingerprint"]
223
+ warnings = list(lang.warnings) + model_warnings + large_day_warnings
224
+ if worktree and today not in dates:
225
+ # Asked for uncommitted work on a range that does not contain today, so
226
+ # there is nowhere to put it. Silence here would read as "the worktree
227
+ # was clean", which is a different and wrong statement.
228
+ warnings.append({
229
+ "code": "UNCOMMITTED_NOT_IN_RANGE",
230
+ "message": f"--include-uncommitted was requested but {today} is not "
231
+ f"in the range, and uncommitted work can only be "
232
+ f"attributed to today. It has been left out.",
233
+ })
234
+ if warnings:
235
+ payload["warnings"] = warnings
236
+ return payload, 0
237
+
238
+
239
+ def _run_dir_for(args) -> str:
240
+ if args.run_dir:
241
+ return args.run_dir
242
+ if not args.run_id:
243
+ raise AnalysisError("NO_RUN",
244
+ "Pass --run-id (from `analyze prepare`) or --run-dir.")
245
+ return os.path.join(ar.analysis_dir(), args.run_id)
246
+
247
+
248
+ def _collect(args) -> "tuple[dict, int]":
249
+ run_dir = _run_dir_for(args)
250
+ tasks = am.load_tasks(run_dir)
251
+ dates, results_dir = tasks["dates"], tasks["results_dir"]
252
+ expected_language = tasks["language"]
253
+
254
+ payload = ar.read_run(results_dir, dates, args.repo, expected_language,
255
+ tasks["required_by_date"], tasks["commits_by_date"])
256
+
257
+ # A result nobody asked for is not a bonus: it means the run directory holds
258
+ # analysis of a day this run never dispatched, and merging it would put a
259
+ # day into the worklog that was never prepared or language-checked.
260
+ unknown = sorted(
261
+ name[:-len(".json")]
262
+ for name in os.listdir(results_dir)
263
+ if name.endswith(".json") and name[:-len(".json")] not in dates
264
+ ) if os.path.isdir(results_dir) else []
265
+
266
+ payload.update({
267
+ "run_id": args.run_id,
268
+ "run_dir": run_dir,
269
+ "expected_language": expected_language,
270
+ "unknown": unknown,
271
+ })
272
+ if unknown:
273
+ payload["partial_run"] = True
274
+ return payload, (1 if payload["partial_run"] else 0)
275
+
276
+
277
+ def run(args) -> "tuple[dict, int]":
278
+ try:
279
+ if args.analyze_command == "prepare":
280
+ return _prepare(args)
281
+ return _collect(args)
282
+ except (AnalysisError, gwdates.DateError, providers.ProviderError,
283
+ language.LanguageError) as exc:
284
+ # Four engines, one wire shape. They each carry their own code because
285
+ # the caller acts on the code, not on which module raised it.
286
+ return {"ok": False, "errors": [
287
+ {"code": exc.code, "message": exc.message, **exc.extra}]}, 2
288
+ except ah.GitError as exc:
289
+ return {"ok": False, "errors": [
290
+ {"code": "GIT_ERROR", "message": str(exc)}]}, 2
291
+
292
+
293
+ def render_text(p: dict) -> str:
294
+ if not p.get("ok"):
295
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
296
+
297
+ lines = []
298
+ if "tasks" in p:
299
+ lang = p["language"]
300
+ lines.append(f"run {p['run_id']} ({lang['resolved']}, via {lang['source']})\n")
301
+ lines.append(f" {p['run_dir']}\n")
302
+ rng, tz = p["range"], p["timezone"]
303
+ # A shortcut is only as good as the range it turned into, so say which
304
+ # days those were before listing them.
305
+ lines.append(f" {rng['from']} .. {rng['to']} ({rng['days_count']} day(s), "
306
+ f"{tz['resolved']} via {tz['source']})\n")
307
+ lines.append(f" model: {p['model']['model_id']} ({p['provider']})\n\n")
308
+ for t in p["tasks"]:
309
+ note = "no changes" if not t["has_changes"] else (
310
+ f"{t['commit_count']} commit(s)"
311
+ + (", large day" if t["large_day"] else ""))
312
+ lines.append(f" {t['date']} {note}\n")
313
+ lines.append(f"\n{len(p['tasks'])} task(s) written. "
314
+ f"Each day's subagent writes to its result_path.\n")
315
+ return "".join(lines)
316
+
317
+ lines.append(f"run {p.get('run_id')}\n")
318
+ lines.append(f" complete : {len(p['complete'])}\n")
319
+ for label in ("degraded", "missing", "unknown"):
320
+ if p.get(label):
321
+ lines.append(f" {label:9}: {', '.join(p[label])}\n")
322
+ for item in p.get("invalid", []):
323
+ lines.append(f" invalid : {item['date']} — {item['message']}\n")
324
+ for item in p.get("unverified", []):
325
+ for issue in item["issues"]:
326
+ lines.append(f" unchecked: {item['date']} — {issue['message']}\n")
327
+ if p.get("language_inconsistent"):
328
+ lines.append(f" languages: {', '.join(p['languages_seen'])} — a run "
329
+ f"must use one\n")
330
+ unchecked = sum(len(i["issues"]) for i in p.get("unverified", []))
331
+ if p["partial_run"]:
332
+ tail = "partial run: this cannot go to preview as-is\n"
333
+ elif unchecked:
334
+ # Complete, but say so honestly: some checks could not be run, and a
335
+ # bare "run complete" would claim more than was verified.
336
+ tail = f"run complete — {unchecked} check(s) could not be run\n"
337
+ else:
338
+ tail = "run complete\n"
339
+ lines.append("\n" + tail)
340
+ return "".join(lines)
@@ -0,0 +1,61 @@
1
+ """``git-worklog apply`` — write the preview, and nothing else (roadmap §10.3).
2
+
3
+ The whole command is one argument: a preview id. That is the point. There is no
4
+ ``--entries``, no stdin, no way to hand it content — so there is no way for what
5
+ lands on disk to differ from what the user approved. Everything else it needs to
6
+ re-check the world is in the record.
7
+
8
+ It refuses rather than adapts. A moved HEAD, an edited day file, a changed
9
+ analysis result, an expired or already-applied preview: each ends the same way,
10
+ with a code, a reason, and an instruction to build a fresh preview. Applying
11
+ "close enough" is how a worklog nobody reviewed gets committed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from git_worklog import preview as pv
17
+ from git_worklog import writer
18
+ from git_worklog.analysis import AnalysisError
19
+ from git_worklog.analysis import history as ah
20
+
21
+
22
+ def run(args) -> "tuple[dict, int]":
23
+ try:
24
+ record = pv.load(args.preview_id)
25
+ payload = pv.apply(record, pv.now_utc(args.now))
26
+ payload.update({
27
+ "ok": True,
28
+ "note": ("Day files written atomically as one transaction, then "
29
+ "index.md rebuilt. No git add / commit / push was "
30
+ "performed."),
31
+ })
32
+ return payload, 0
33
+ except (pv.PreviewError, writer.WriterError, AnalysisError) as exc:
34
+ return {"ok": False, "errors": [
35
+ {"code": exc.code, "message": exc.message, **exc.extra}]}, 2
36
+ except ah.GitError as exc:
37
+ return {"ok": False, "errors": [
38
+ {"code": "GIT_ERROR", "message": str(exc)}]}, 2
39
+
40
+
41
+ def render_text(p: dict) -> str:
42
+ if not p.get("ok"):
43
+ lines = []
44
+ for e in p.get("errors", []):
45
+ lines.append(f"error: {e['message']}\n")
46
+ for m in e.get("mismatches", []):
47
+ lines.append(f" changed: {m['field']}\n")
48
+ if e.get("instruction"):
49
+ lines.append(f" {e['instruction']}\n")
50
+ return "".join(lines)
51
+
52
+ lines = [f"applied {p['preview_id']}\n"]
53
+ if p.get("broke_stale_lock"):
54
+ lines.append(" note : broke a lock left behind by a dead process\n")
55
+ written = p.get("written_dates") or []
56
+ lines.append(f" days : {', '.join(written) if written else 'none changed'}\n")
57
+ lines.append(f" index : {p['index_action']}\n")
58
+ if p.get("preserved_manual_dates"):
59
+ lines.append(f" MANUAL kept: {', '.join(p['preserved_manual_dates'])}\n")
60
+ lines.append(f"\n{p['note']}\n")
61
+ return "".join(lines)
@@ -0,0 +1,62 @@
1
+ """``git-worklog coverage`` — which of these dates have an analysis behind them.
2
+
3
+ Report mode answers from the day files already on disk, so before it answers it
4
+ has to know which requested dates actually have one. Without this it degrades
5
+ into summarising commit messages, which is the one thing this tool exists not to
6
+ do.
7
+
8
+ A date with no day file is **not** automatically a gap: a day with no commits is
9
+ deliberately given no file. The engine (:mod:`git_worklog.analysis.coverage`)
10
+ keeps ``gap`` and ``no-commits`` apart, because conflating them sends the user
11
+ off to backfill days that can never produce a file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from git_worklog import dates as gwdates
17
+ from git_worklog.analysis import AnalysisError
18
+ from git_worklog.analysis import coverage as engine
19
+ from git_worklog.analysis import history as ah
20
+
21
+
22
+ def run(args) -> "tuple[dict, int]":
23
+ try:
24
+ payload = engine.check(repo=args.repo, dir=args.dir, dates=args.dates,
25
+ timezone=args.timezone, date_field=args.date_field,
26
+ worklog_dir=args.worklog_dir,
27
+ shortcut=args.shortcut, date=args.date,
28
+ days=args.days, from_=getattr(args, "from"),
29
+ to=args.to, today=args.today,
30
+ max_days=args.max_days)
31
+ # Exit 1, not 0: a gap means the answer to "can I report on this?" is no.
32
+ # It ran fine, and it found a problem — which is what 1 means here.
33
+ return payload, (0 if payload["fully_covered"] else 1)
34
+ except (AnalysisError, gwdates.DateError) as exc:
35
+ return {"ok": False, "errors": [
36
+ {"code": exc.code, "message": exc.message, **exc.extra}]}, 2
37
+ except ah.GitError as exc:
38
+ return {"ok": False, "errors": [
39
+ {"code": "GIT_ERROR", "message": str(exc)}]}, 2
40
+
41
+
42
+ def render_text(p: dict) -> str:
43
+ if not p.get("ok"):
44
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
45
+
46
+ tz = p["timezone"]
47
+ lines = [f"git-worklog coverage — {p['worklog_dir']}\n"]
48
+ lines.append(f" ({tz['resolved']} via {tz['source']})\n\n")
49
+ symbol = {"covered": "✓", "gap": "✗", "no-commits": "·"}
50
+ for row in p["dates"]:
51
+ note = (f"{row['commit_count']} commit(s)" if row["commit_count"]
52
+ else "no commits, no file expected")
53
+ lines.append(f" {symbol[row['status']]} {row['date']} "
54
+ f"{row['status']:11} {note}\n")
55
+ lines.append("\n")
56
+ if p["fully_covered"]:
57
+ lines.append("Every date with commits has a worklog.\n")
58
+ else:
59
+ lines.append(f"{len(p['gaps'])} gap(s) covering {p['gap_commit_count']} "
60
+ f"commit(s): {', '.join(p['gaps'])}\n")
61
+ lines.append("Real work exists on those days that nothing has analysed.\n")
62
+ return "".join(lines)