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,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)
|
git_worklog/cli/apply.py
ADDED
|
@@ -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)
|