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,114 @@
|
|
|
1
|
+
"""Inspect the Git working tree for uncommitted changes.
|
|
2
|
+
|
|
3
|
+
Only used when ``include_uncommitted`` is on. It classifies changes into
|
|
4
|
+
staged / unstaged / untracked, flags binary and submodule entries, and computes
|
|
5
|
+
a stable working-tree fingerprint the preview state uses to detect drift between
|
|
6
|
+
dry-run and apply.
|
|
7
|
+
|
|
8
|
+
Uncommitted content may only ever be attributed to *today*: this module does not
|
|
9
|
+
guess historical dates from filesystem mtimes, because a file's mtime says when
|
|
10
|
+
it was last written, not when the work happened.
|
|
11
|
+
|
|
12
|
+
The git helpers and diff parser come from :mod:`~git_worklog.analysis.history`.
|
|
13
|
+
They were duplicated here while these were two standalone scripts that had to stay
|
|
14
|
+
individually portable; now that both ship in the package that rationale is gone,
|
|
15
|
+
so this module imports them rather than carrying its own copy (#20).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
|
|
22
|
+
from git_worklog.analysis import AnalysisError
|
|
23
|
+
# The git helpers and diff parser live in history. They were duplicated here only
|
|
24
|
+
# while these two modules were standalone scripts that had to stay individually
|
|
25
|
+
# portable (#20); now that both ship in the package, that rationale is gone.
|
|
26
|
+
# history's `_parse_raw` returns a fuller entry — it also needs old/new mode and
|
|
27
|
+
# sha for submodule and mode-change detection — and `_diff_files` below reads the
|
|
28
|
+
# subset it needs, so sharing the parser leaves this module's output unchanged.
|
|
29
|
+
# GitError is now the one class, so a working-tree git failure surfaces as
|
|
30
|
+
# GIT_ERROR through the same CLI catch as every other Git call.
|
|
31
|
+
from git_worklog.analysis.history import ( # noqa: F401
|
|
32
|
+
GitError, _git, _git_ok, _parse_numstat, _parse_raw,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _diff_files(repo: str, cached: bool) -> "list[dict]":
|
|
37
|
+
base = ["diff", "-M", "-C", "-z"]
|
|
38
|
+
if cached:
|
|
39
|
+
base.append("--cached")
|
|
40
|
+
raw = _parse_raw(_git(repo, [*base, "--raw"]))
|
|
41
|
+
nums = _parse_numstat(_git(repo, [*base, "--numstat"]))
|
|
42
|
+
files = []
|
|
43
|
+
for idx, entry in enumerate(raw):
|
|
44
|
+
add, dele = nums[idx] if idx < len(nums) else ("-", "-")
|
|
45
|
+
is_binary = add == "-" and dele == "-"
|
|
46
|
+
files.append({
|
|
47
|
+
"status": entry["status"],
|
|
48
|
+
"path": entry["path"],
|
|
49
|
+
"old_path": entry["old_path"],
|
|
50
|
+
"similarity": entry["similarity"],
|
|
51
|
+
"additions": None if is_binary else int(add or 0),
|
|
52
|
+
"deletions": None if is_binary else int(dele or 0),
|
|
53
|
+
"is_binary": is_binary,
|
|
54
|
+
"is_submodule": entry["is_submodule"],
|
|
55
|
+
})
|
|
56
|
+
return files
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _untracked(repo: str) -> "list[dict]":
|
|
60
|
+
out = _git(repo, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
61
|
+
paths = [p for p in out.split("\x00") if p]
|
|
62
|
+
result = []
|
|
63
|
+
for path in paths:
|
|
64
|
+
# Sniff for a NUL byte in the first 8 KB, the same heuristic Git uses.
|
|
65
|
+
try:
|
|
66
|
+
with open(f"{repo}/{path}", "rb") as fh:
|
|
67
|
+
is_binary = b"\x00" in fh.read(8000)
|
|
68
|
+
except OSError:
|
|
69
|
+
is_binary = False
|
|
70
|
+
result.append({"status": "A", "path": path, "is_binary": is_binary})
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fingerprint(repo: str) -> str:
|
|
75
|
+
h = hashlib.sha256()
|
|
76
|
+
h.update(_git(repo, ["status", "--porcelain=v2", "-z"], binary=True))
|
|
77
|
+
h.update(b"\x00STAGED\x00")
|
|
78
|
+
h.update(_git(repo, ["diff", "--cached"], binary=True))
|
|
79
|
+
h.update(b"\x00UNSTAGED\x00")
|
|
80
|
+
h.update(_git(repo, ["diff"], binary=True))
|
|
81
|
+
h.update(b"\x00UNTRACKED\x00")
|
|
82
|
+
for entry in _untracked(repo):
|
|
83
|
+
h.update(entry["path"].encode("utf-8", "replace"))
|
|
84
|
+
try:
|
|
85
|
+
with open(f"{repo}/{entry['path']}", "rb") as fh:
|
|
86
|
+
h.update(fh.read())
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
h.update(b"\x00")
|
|
90
|
+
return h.hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def inspect(repo: str) -> dict:
|
|
94
|
+
"""Everything uncommitted in ``repo``, plus a fingerprint of that state."""
|
|
95
|
+
if not _git_ok(repo, ["rev-parse", "--is-inside-work-tree"]):
|
|
96
|
+
raise AnalysisError("NOT_A_GIT_REPO",
|
|
97
|
+
"The target directory is not inside a Git repository.",
|
|
98
|
+
path=repo)
|
|
99
|
+
staged = _diff_files(repo, cached=True)
|
|
100
|
+
unstaged = _diff_files(repo, cached=False)
|
|
101
|
+
untracked = _untracked(repo)
|
|
102
|
+
return {
|
|
103
|
+
"ok": True,
|
|
104
|
+
"staged": staged,
|
|
105
|
+
"unstaged": unstaged,
|
|
106
|
+
"untracked": untracked,
|
|
107
|
+
"counts": {
|
|
108
|
+
"staged": len(staged),
|
|
109
|
+
"unstaged": len(unstaged),
|
|
110
|
+
"untracked": len(untracked),
|
|
111
|
+
},
|
|
112
|
+
"has_uncommitted": bool(staged or unstaged or untracked),
|
|
113
|
+
"worktree_fingerprint": _fingerprint(repo),
|
|
114
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""The ``git-worklog`` command.
|
|
2
|
+
|
|
3
|
+
``version``, ``doctor`` and ``validate`` came first (roadmap §12.1): the things
|
|
4
|
+
you want *before* trusting anything else, none of which need the analysis
|
|
5
|
+
pipeline. ``analyze prepare``/``collect`` (§7) then bracket the pipeline — they
|
|
6
|
+
decide what must be analysed and check what came back, while the analysis itself
|
|
7
|
+
stays with the hosting agent's LLM. The CLI does not replace it and needs no
|
|
8
|
+
model API key (§6.1). ``preview`` and ``apply`` (§10) close the loop: the first
|
|
9
|
+
turns a collected run plus the agent's prose into a frozen payload, the second
|
|
10
|
+
writes that payload and takes no other input. ``coverage`` and ``refs`` serve
|
|
11
|
+
report mode's two questions — "is there an analysis behind these dates?" and
|
|
12
|
+
"which commits is this version actually made of?" — and ``migrate`` (§2.4) moves
|
|
13
|
+
a legacy worklog into the current layout. ``view`` is for reading what all of
|
|
14
|
+
that produced: the worklog as one self-contained page in a browser, read-only.
|
|
15
|
+
|
|
16
|
+
Every subcommand prints one JSON object to stdout, matching the scripts'
|
|
17
|
+
contract, so the same parsing works everywhere. ``--text`` switches to a
|
|
18
|
+
human-readable rendering for terminal use.
|
|
19
|
+
|
|
20
|
+
Exit codes:
|
|
21
|
+
0 ok
|
|
22
|
+
1 ran fine, but the answer is "no" (doctor found a problem, validate failed)
|
|
23
|
+
2 the command itself could not run
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
from git_worklog import __version__, language
|
|
33
|
+
from git_worklog import markers as wm
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit(payload: dict, as_text: bool, render) -> None:
|
|
37
|
+
if as_text:
|
|
38
|
+
sys.stdout.write(render(payload))
|
|
39
|
+
else:
|
|
40
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
41
|
+
sys.stdout.write("\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(
|
|
46
|
+
prog="git-worklog",
|
|
47
|
+
description="Engineering worklogs from real Git history and code.",
|
|
48
|
+
)
|
|
49
|
+
p.add_argument("--version", action="version", version=f"git-worklog {__version__}")
|
|
50
|
+
p.add_argument("--text", action="store_true",
|
|
51
|
+
help="Human-readable output instead of JSON.")
|
|
52
|
+
p.add_argument("--interface-language", default=None, metavar="TAG",
|
|
53
|
+
help="Language for this command's own messages (BCP 47). "
|
|
54
|
+
"English only for now; anything else is reported and "
|
|
55
|
+
"falls back. Separate from the worklog's content "
|
|
56
|
+
"language, which these commands do not set.")
|
|
57
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
58
|
+
|
|
59
|
+
sub.add_parser("version", help="Report the CLI, layout and schema versions.")
|
|
60
|
+
|
|
61
|
+
d = sub.add_parser("doctor", help="Check that this environment can run the tool.")
|
|
62
|
+
d.add_argument("--repo", default=".", help="Repository to check (default: cwd).")
|
|
63
|
+
d.add_argument("--dir", help="Worklog directory (default: <repo>/.git-worklog).")
|
|
64
|
+
|
|
65
|
+
v = sub.add_parser("validate", help="Validate a worklog directory.")
|
|
66
|
+
v.add_argument("--repo", default=".", help="Repository to check (default: cwd).")
|
|
67
|
+
v.add_argument("--dir", help="Worklog directory (default: <repo>/.git-worklog).")
|
|
68
|
+
|
|
69
|
+
a = sub.add_parser("analyze", help="Prepare per-day analysis tasks, and collect them back.")
|
|
70
|
+
asub = a.add_subparsers(dest="analyze_command", metavar="<prepare|collect>",
|
|
71
|
+
required=True)
|
|
72
|
+
|
|
73
|
+
prep = asub.add_parser("prepare", help="Mint a run and write one manifest per day.")
|
|
74
|
+
prep.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
|
|
75
|
+
help="Date shortcut: NNd (last N days, e.g. 7d) or a bare "
|
|
76
|
+
"YYYY-MM-DD. Alternative to the flags below.")
|
|
77
|
+
prep.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
|
|
78
|
+
prep.add_argument("--days", type=int, metavar="N",
|
|
79
|
+
help="The last N calendar days, including today.")
|
|
80
|
+
prep.add_argument("--from", metavar="DATE",
|
|
81
|
+
help="First day to analyse (YYYY-MM-DD, inclusive). Needs --to.")
|
|
82
|
+
prep.add_argument("--to", metavar="DATE",
|
|
83
|
+
help="Last day to analyse (YYYY-MM-DD, inclusive). Needs --from.")
|
|
84
|
+
prep.add_argument("--timezone", metavar="TZ",
|
|
85
|
+
help="IANA timezone deciding where each day starts. Detected "
|
|
86
|
+
"from the environment when omitted; the run reports "
|
|
87
|
+
"which zone it used and where that came from.")
|
|
88
|
+
prep.add_argument("--today", metavar="DATE",
|
|
89
|
+
help="Override today's date (YYYY-MM-DD) for deterministic "
|
|
90
|
+
"runs. Affects NNd/--days and where uncommitted work lands.")
|
|
91
|
+
prep.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
|
|
92
|
+
prep.add_argument("--dir", help="Worklog directory, read for its config.json "
|
|
93
|
+
f"language setting (default: ./{wm.WORKLOG_DIRNAME}).")
|
|
94
|
+
prep.add_argument("--run-dir", help="Override the run directory (default: "
|
|
95
|
+
"~/.git-worklog/analysis/<run_id>).")
|
|
96
|
+
prep.add_argument("--date-field", choices=["committer", "author"],
|
|
97
|
+
default="committer",
|
|
98
|
+
help="Which date decides day attribution (default: committer).")
|
|
99
|
+
prep.add_argument("--worklog-dir", default=wm.WORKLOG_DIRNAME,
|
|
100
|
+
help="Worklog output directory; commits touching only this "
|
|
101
|
+
"directory are excluded as self-referential.")
|
|
102
|
+
prep.add_argument("--include-uncommitted", action="store_true",
|
|
103
|
+
help="Also hand the subagent the working tree's uncommitted "
|
|
104
|
+
"changes. They are attributed to today and to no other "
|
|
105
|
+
"day; if today is outside --from/--to they are left out "
|
|
106
|
+
"and the run says so.")
|
|
107
|
+
prep.add_argument("--host", metavar="KEY",
|
|
108
|
+
help="Detected agent host (anthropic / openai / google). "
|
|
109
|
+
"Resolves the subagent model from the packaged config, "
|
|
110
|
+
"honouring $GIT_WORKLOG_<HOST>_MODEL and --model. The "
|
|
111
|
+
"host is never guessed: unknown or unconfigured is an "
|
|
112
|
+
"error, not a fallback to the first provider.")
|
|
113
|
+
prep.add_argument("--model", default="",
|
|
114
|
+
help="Explicit model id, the highest-precedence override. "
|
|
115
|
+
"Needs --host.")
|
|
116
|
+
prep.add_argument("--escalate", action="store_true",
|
|
117
|
+
help="Use the host's escalation_model_id. Opt-in only, after "
|
|
118
|
+
"the user approves an escalation re-run. Needs --host.")
|
|
119
|
+
prep.add_argument("--provider", default=None,
|
|
120
|
+
help="Subagent provider key, stated rather than resolved "
|
|
121
|
+
"(default: anthropic). Use --host instead unless you "
|
|
122
|
+
"already hold the model object.")
|
|
123
|
+
prep.add_argument("--model-json", default="",
|
|
124
|
+
help="Structured model object (JSON: {display_name, "
|
|
125
|
+
"model_id[, reasoning_effort]}). Goes with --provider.")
|
|
126
|
+
prep.add_argument("--language", default="auto",
|
|
127
|
+
help="Content language for the worklog as a BCP 47 tag "
|
|
128
|
+
"(zh-TW, en, ja), or 'auto' to fall through to project "
|
|
129
|
+
"config and GIT_WORKLOG_LANGUAGE. Resolved once and "
|
|
130
|
+
"stamped on every manifest in the run.")
|
|
131
|
+
prep.add_argument("--language-source", default=None,
|
|
132
|
+
help="Where --language came from, so the manifest records "
|
|
133
|
+
"why and not just what: user-request, agent-host, "
|
|
134
|
+
"conversation, cli-argument, project-config, "
|
|
135
|
+
"environment, system-locale, fallback.")
|
|
136
|
+
|
|
137
|
+
coll = asub.add_parser("collect", help="Read and validate a prepared run's results.")
|
|
138
|
+
coll.add_argument("--run-id", help="The run to collect (from `analyze prepare`).")
|
|
139
|
+
coll.add_argument("--run-dir", help="The run directory, if it is not "
|
|
140
|
+
"~/.git-worklog/analysis/<run-id>.")
|
|
141
|
+
coll.add_argument("--repo", default=".",
|
|
142
|
+
help="Repository the evidence cites. Every entry is checked "
|
|
143
|
+
"against the tree of the commit it names — not the "
|
|
144
|
+
"checkout, which holds everything changed since.")
|
|
145
|
+
|
|
146
|
+
pv = sub.add_parser("preview", help="Freeze what an apply would write.")
|
|
147
|
+
pv.add_argument("--run-id", help="The collected run to preview (from "
|
|
148
|
+
"`analyze prepare`).")
|
|
149
|
+
pv.add_argument("--run-dir", help="The run directory, if it is not "
|
|
150
|
+
"~/.git-worklog/analysis/<run-id>.")
|
|
151
|
+
pv.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
|
|
152
|
+
pv.add_argument("--dir", help=f"Worklog directory (default: <repo>/{wm.WORKLOG_DIRNAME}).")
|
|
153
|
+
pv.add_argument("--input", help="Rendered day files as JSON, or '-' / omit "
|
|
154
|
+
"for stdin: {\"entries\": {\"<date>\": "
|
|
155
|
+
"{\"generated_markdown\": \"...\"}}}.")
|
|
156
|
+
# Defaulted by the engine, not here: reading the real default would mean
|
|
157
|
+
# importing git_worklog.preview (and the writer, and Git) on every run,
|
|
158
|
+
# including `version`, which this parser is built for too.
|
|
159
|
+
pv.add_argument("--ttl-seconds", type=int, default=None,
|
|
160
|
+
help="How long the preview stays applicable (default: 24h).")
|
|
161
|
+
pv.add_argument("--show", metavar="PREVIEW_ID",
|
|
162
|
+
help="Print a stored preview instead of creating one.")
|
|
163
|
+
pv.add_argument("--check", action="store_true",
|
|
164
|
+
help="With --show, also re-read the repository and worklog "
|
|
165
|
+
"to report whether the preview is still applicable.")
|
|
166
|
+
pv.add_argument("--cancel", metavar="PREVIEW_ID",
|
|
167
|
+
help="Retire a preview instead of creating one.")
|
|
168
|
+
pv.add_argument("--now", help="Override current time (ISO 8601) for "
|
|
169
|
+
"deterministic runs.")
|
|
170
|
+
|
|
171
|
+
ap = sub.add_parser("apply", help="Write a preview's stored payload.")
|
|
172
|
+
ap.add_argument("--preview-id", required=True,
|
|
173
|
+
help="The preview to apply. This is the only input: the "
|
|
174
|
+
"content comes from the record, never from the caller.")
|
|
175
|
+
ap.add_argument("--now", help="Override current time (ISO 8601) for "
|
|
176
|
+
"deterministic runs.")
|
|
177
|
+
|
|
178
|
+
cov = sub.add_parser("coverage",
|
|
179
|
+
help="Which dates have a worklog behind them (report mode).")
|
|
180
|
+
# Two ways in, because report mode has two scopes: a date scope is a range,
|
|
181
|
+
# a ref scope is whatever days a tag's commits landed on — an arbitrary set.
|
|
182
|
+
cov.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
|
|
183
|
+
help="Date shortcut: NNd or a bare YYYY-MM-DD.")
|
|
184
|
+
cov.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
|
|
185
|
+
cov.add_argument("--days", type=int, metavar="N",
|
|
186
|
+
help="The last N calendar days, including today.")
|
|
187
|
+
cov.add_argument("--from", metavar="DATE", help="Range start (inclusive).")
|
|
188
|
+
cov.add_argument("--to", metavar="DATE", help="Range end (inclusive).")
|
|
189
|
+
cov.add_argument("--dates", metavar="LIST",
|
|
190
|
+
help="Exact days as a comma-separated list, e.g. "
|
|
191
|
+
"2026-07-01,2026-07-02. For a ref scope, whose dates "
|
|
192
|
+
"are a set with gaps rather than a span.")
|
|
193
|
+
cov.add_argument("--today", metavar="DATE",
|
|
194
|
+
help="Override today's date for deterministic runs.")
|
|
195
|
+
# Defaulted by the engine, not here: naming the number would mean importing
|
|
196
|
+
# analysis.coverage (and history, and Git) to build a parser that `version`
|
|
197
|
+
# also uses. Same reason as --ttl-seconds below.
|
|
198
|
+
cov.add_argument("--max-days", type=int, default=None,
|
|
199
|
+
help="Maximum span in calendar days (default: 90). Higher "
|
|
200
|
+
"than generation's 30: report mode reads day files and "
|
|
201
|
+
"spawns no subagents, so the cost that cap bounds is "
|
|
202
|
+
"not at stake.")
|
|
203
|
+
cov.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
|
|
204
|
+
cov.add_argument("--dir", default=wm.WORKLOG_DIRNAME,
|
|
205
|
+
help="Worklog directory, absolute or relative to the repo "
|
|
206
|
+
f"root (default: {wm.WORKLOG_DIRNAME}).")
|
|
207
|
+
cov.add_argument("--timezone", default=None,
|
|
208
|
+
help="IANA timezone deciding each day's bounds. Detected "
|
|
209
|
+
"from the environment when omitted; with --dates it "
|
|
210
|
+
"defaults to UTC.")
|
|
211
|
+
cov.add_argument("--date-field", choices=["committer", "author"],
|
|
212
|
+
default="committer",
|
|
213
|
+
help="Which date decides day attribution (default: committer).")
|
|
214
|
+
cov.add_argument("--worklog-dir", default=None,
|
|
215
|
+
help="Repo-relative worklog path whose commits count as "
|
|
216
|
+
"self-referential and are excluded from the counts.")
|
|
217
|
+
|
|
218
|
+
rf = sub.add_parser("refs",
|
|
219
|
+
help="Resolve a tag into its commit set (report mode).")
|
|
220
|
+
rf.add_argument("--tag", help="Tag to report on; its predecessor is found "
|
|
221
|
+
"automatically.")
|
|
222
|
+
rf.add_argument("--from-ref", help="Explicit range start, exclusive (any ref).")
|
|
223
|
+
rf.add_argument("--to-ref", help="Explicit range end, inclusive (any ref).")
|
|
224
|
+
rf.add_argument("--list-tags", action="store_true",
|
|
225
|
+
help="List the repository's tags, newest-first.")
|
|
226
|
+
rf.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
|
|
227
|
+
rf.add_argument("--timezone", default="UTC",
|
|
228
|
+
help="IANA timezone deciding each commit's calendar day "
|
|
229
|
+
"(default: UTC).")
|
|
230
|
+
rf.add_argument("--date-field", choices=["committer", "author"],
|
|
231
|
+
default="committer",
|
|
232
|
+
help="Which date decides day attribution (default: committer).")
|
|
233
|
+
|
|
234
|
+
rp = sub.add_parser("report",
|
|
235
|
+
help="Settle a report's scope, coverage and language "
|
|
236
|
+
"before it is written (report mode).")
|
|
237
|
+
# The same two scopes as `coverage`, for the same reason, plus the ref flags
|
|
238
|
+
# of `refs`: this is the one call that needs both halves at once, because
|
|
239
|
+
# reconciling a tag's commits against the day files is impossible with
|
|
240
|
+
# either alone.
|
|
241
|
+
rp.add_argument("shortcut", nargs="?", metavar="SHORTCUT",
|
|
242
|
+
help="Date shortcut: NNd or a bare YYYY-MM-DD.")
|
|
243
|
+
rp.add_argument("--date", metavar="DATE", help="A single day (YYYY-MM-DD).")
|
|
244
|
+
rp.add_argument("--days", type=int, metavar="N",
|
|
245
|
+
help="The last N calendar days, including today.")
|
|
246
|
+
rp.add_argument("--from", metavar="DATE", help="Range start (inclusive).")
|
|
247
|
+
rp.add_argument("--to", metavar="DATE", help="Range end (inclusive).")
|
|
248
|
+
rp.add_argument("--tag", help="Report on a tag; its predecessor is found "
|
|
249
|
+
"automatically. The commit set is the scope, "
|
|
250
|
+
"not the dates it spans.")
|
|
251
|
+
rp.add_argument("--from-ref", help="Explicit range start, exclusive (any ref).")
|
|
252
|
+
rp.add_argument("--to-ref", help="Explicit range end, inclusive (any ref).")
|
|
253
|
+
rp.add_argument("--language", default="auto",
|
|
254
|
+
help="Language to write the report in, as a BCP 47 tag. "
|
|
255
|
+
"Independent of the language the day files are written "
|
|
256
|
+
"in: zh-TW worklogs can produce an English release "
|
|
257
|
+
"report (default: auto).")
|
|
258
|
+
rp.add_argument("--language-source", default=None,
|
|
259
|
+
help="Where --language came from, so the payload records "
|
|
260
|
+
"whether a user asked or a default applied.")
|
|
261
|
+
rp.add_argument("--today", metavar="DATE",
|
|
262
|
+
help="Override today's date for deterministic runs.")
|
|
263
|
+
rp.add_argument("--max-days", type=int, default=None,
|
|
264
|
+
help="Maximum span in calendar days (default: 90), as "
|
|
265
|
+
"`coverage`. Date scope only.")
|
|
266
|
+
rp.add_argument("--repo", default=".", help="Repository to read (default: cwd).")
|
|
267
|
+
rp.add_argument("--dir", default=wm.WORKLOG_DIRNAME,
|
|
268
|
+
help="Worklog directory, absolute or relative to the repo "
|
|
269
|
+
f"root (default: {wm.WORKLOG_DIRNAME}).")
|
|
270
|
+
rp.add_argument("--timezone", default=None,
|
|
271
|
+
help="IANA timezone deciding each day's bounds. Detected "
|
|
272
|
+
"from the environment when omitted; for a ref scope it "
|
|
273
|
+
"defaults to UTC.")
|
|
274
|
+
rp.add_argument("--date-field", choices=["committer", "author"],
|
|
275
|
+
default="committer",
|
|
276
|
+
help="Which date decides day attribution (default: committer).")
|
|
277
|
+
rp.add_argument("--worklog-dir", default=None,
|
|
278
|
+
help="Repo-relative worklog path whose commits count as "
|
|
279
|
+
"self-referential and are excluded from the counts.")
|
|
280
|
+
|
|
281
|
+
mg = sub.add_parser("migrate",
|
|
282
|
+
help="Move a legacy worklog into .git-worklog/ (dry-run "
|
|
283
|
+
"by default).")
|
|
284
|
+
mg.add_argument("--from-dir", dest="from_dir",
|
|
285
|
+
help="Flat legacy worklog directory (v0.2-v0.5).")
|
|
286
|
+
mg.add_argument("--from-file", dest="from_file",
|
|
287
|
+
help="Single-file legacy worklog (pre-v0.2).")
|
|
288
|
+
mg.add_argument("--dir", help=f"Target worklog directory (default: "
|
|
289
|
+
f"{wm.WORKLOG_DIRNAME}).")
|
|
290
|
+
mg.add_argument("--timezone",
|
|
291
|
+
help="Timezone recorded in config.json, and in each day file's "
|
|
292
|
+
"header when migrating from a single file. Ignored for "
|
|
293
|
+
"--from-dir, whose day files already record their own.")
|
|
294
|
+
mg.add_argument("--apply", action="store_true",
|
|
295
|
+
help="Write the migration. Without this the run is a dry-run.")
|
|
296
|
+
|
|
297
|
+
ri = sub.add_parser("reindex",
|
|
298
|
+
help="Rebuild index.md from the day files (dry-run by "
|
|
299
|
+
"default). The repair for INDEX_WRITE_FAILED.")
|
|
300
|
+
ri.add_argument("--dir", help=f"Worklog directory (default: "
|
|
301
|
+
f"{wm.WORKLOG_DIRNAME}).")
|
|
302
|
+
ri.add_argument("--language", default=None,
|
|
303
|
+
help="Language for the index, used only when it does not "
|
|
304
|
+
"exist yet. An index that already has one keeps it.")
|
|
305
|
+
ri.add_argument("--apply", action="store_true",
|
|
306
|
+
help="Write index.md. Without this the run is a dry-run.")
|
|
307
|
+
|
|
308
|
+
md = sub.add_parser("models",
|
|
309
|
+
help="Show the user's subagent model table "
|
|
310
|
+
"(~/.git-worklog/provider_models.json), or change "
|
|
311
|
+
"one host's models in it (dry-run by default).")
|
|
312
|
+
md.add_argument("--host", help="The host whose models to change: "
|
|
313
|
+
"anthropic | openai | google.")
|
|
314
|
+
md.add_argument("--model", help="The host's default subagent model id.")
|
|
315
|
+
md.add_argument("--display-name", dest="display_name",
|
|
316
|
+
help="Human label for --model (default: the model id).")
|
|
317
|
+
md.add_argument("--reasoning-effort", dest="reasoning_effort",
|
|
318
|
+
help="Reasoning effort for --model (openai).")
|
|
319
|
+
md.add_argument("--escalation-model", dest="escalation_model",
|
|
320
|
+
help="The host's escalation model id.")
|
|
321
|
+
md.add_argument("--escalation-reasoning-effort", dest="escalation_reasoning_effort",
|
|
322
|
+
help="Reasoning effort for --escalation-model (openai).")
|
|
323
|
+
md.add_argument("--apply", action="store_true",
|
|
324
|
+
help="Write the change. Without this the run is a dry-run.")
|
|
325
|
+
|
|
326
|
+
vw = sub.add_parser("view",
|
|
327
|
+
help="Show the worklog as one HTML page in a browser "
|
|
328
|
+
"(read-only).")
|
|
329
|
+
vw.add_argument("--repo", default=".", help="Repository to show (default: cwd).")
|
|
330
|
+
vw.add_argument("--dir", help=f"Worklog directory (default: <repo>/{wm.WORKLOG_DIRNAME}).")
|
|
331
|
+
where = vw.add_mutually_exclusive_group()
|
|
332
|
+
where.add_argument("--output", metavar="FILE",
|
|
333
|
+
help="Where to write the page (default: ~/.git-worklog/view/"
|
|
334
|
+
"<project>-<hash>.html, the same file every run). "
|
|
335
|
+
"Refused inside the worklog directory.")
|
|
336
|
+
where.add_argument("--serve", action="store_true",
|
|
337
|
+
help="Serve the page on 127.0.0.1 instead of writing it, "
|
|
338
|
+
"so that it can show each commit's diff. Runs until "
|
|
339
|
+
"interrupted (Ctrl+C).")
|
|
340
|
+
vw.add_argument("--no-open", action="store_true",
|
|
341
|
+
help="Write the page without opening a browser.")
|
|
342
|
+
return p
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
346
|
+
parser = build_parser()
|
|
347
|
+
args = parser.parse_args(argv)
|
|
348
|
+
if not args.command:
|
|
349
|
+
parser.print_help()
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
# Imported lazily so `git-worklog version` stays fast and cannot be broken
|
|
353
|
+
# by an unrelated subcommand's import. Spelled out rather than defaulted:
|
|
354
|
+
# argparse already rejects an unknown command, so a name reaching here with
|
|
355
|
+
# no module is a wiring mistake, and it should say so instead of quietly
|
|
356
|
+
# running whichever subcommand happened to be the fallback.
|
|
357
|
+
if args.command == "version":
|
|
358
|
+
from git_worklog.cli import version as cmd
|
|
359
|
+
elif args.command == "doctor":
|
|
360
|
+
from git_worklog.cli import doctor as cmd
|
|
361
|
+
elif args.command == "validate":
|
|
362
|
+
from git_worklog.cli import validate as cmd
|
|
363
|
+
elif args.command == "analyze":
|
|
364
|
+
from git_worklog.cli import analyze as cmd
|
|
365
|
+
elif args.command == "preview":
|
|
366
|
+
from git_worklog.cli import preview as cmd
|
|
367
|
+
elif args.command == "apply":
|
|
368
|
+
from git_worklog.cli import apply as cmd
|
|
369
|
+
elif args.command == "coverage":
|
|
370
|
+
from git_worklog.cli import coverage as cmd
|
|
371
|
+
elif args.command == "refs":
|
|
372
|
+
from git_worklog.cli import refs as cmd
|
|
373
|
+
elif args.command == "report":
|
|
374
|
+
from git_worklog.cli import report as cmd
|
|
375
|
+
elif args.command == "migrate":
|
|
376
|
+
from git_worklog.cli import migrate as cmd
|
|
377
|
+
elif args.command == "reindex":
|
|
378
|
+
from git_worklog.cli import reindex as cmd
|
|
379
|
+
elif args.command == "models":
|
|
380
|
+
from git_worklog.cli import models as cmd
|
|
381
|
+
elif args.command == "view":
|
|
382
|
+
from git_worklog.cli import view as cmd
|
|
383
|
+
else:
|
|
384
|
+
raise AssertionError(f"no module wired for command {args.command!r}")
|
|
385
|
+
|
|
386
|
+
# §6.2.13 keeps interface language and content language apart. Phase one
|
|
387
|
+
# ships English messages only -- which the roadmap allows -- but an
|
|
388
|
+
# unsupported request is answered rather than ignored: silence would look
|
|
389
|
+
# like it worked.
|
|
390
|
+
try:
|
|
391
|
+
interface = language.resolve_interface(args.interface_language)
|
|
392
|
+
except language.LanguageError as exc:
|
|
393
|
+
_emit({"ok": False, "errors": [{"code": exc.code, "message": exc.message}]},
|
|
394
|
+
args.text, lambda p: f"error: {exc.message}\n")
|
|
395
|
+
return 2
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
payload, code = cmd.run(args)
|
|
399
|
+
except Exception as exc: # never let a traceback replace the JSON contract
|
|
400
|
+
_emit({"ok": False, "errors": [{
|
|
401
|
+
"code": "UNEXPECTED_ERROR",
|
|
402
|
+
"message": f"{type(exc).__name__}: {exc}",
|
|
403
|
+
}]}, args.text, lambda p: f"error: {exc}\n")
|
|
404
|
+
return 2
|
|
405
|
+
|
|
406
|
+
if interface.warnings:
|
|
407
|
+
payload.setdefault("warnings", []).extend(interface.warnings)
|
|
408
|
+
payload.setdefault("interface_language", interface.resolved)
|
|
409
|
+
|
|
410
|
+
_emit(payload, args.text, cmd.render_text)
|
|
411
|
+
# `view --serve` answers first and then stays: the one command that outlives
|
|
412
|
+
# its own output.
|
|
413
|
+
after = getattr(cmd, "after_emit", None)
|
|
414
|
+
return after(args, payload, code) if after else code
|