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,58 @@
1
+ """Git Worklog — engineering worklogs from real Git history and code.
2
+
3
+ This package is the deterministic engine and CLI. It does the work that must be
4
+ exact — date maths, Git collection, marker parsing, transactional writes,
5
+ preview integrity — and leaves the semantic work (reading patches, deciding
6
+ what actually changed) to the agent's LLM. See ``references/`` in the skill for
7
+ that contract.
8
+
9
+ It lives inside the skill directory so the skill stays copy-to-install with no
10
+ dependencies, while ``pip install git-worklog`` puts the same code's CLI on
11
+ PATH.
12
+ """
13
+
14
+ # The single source of truth for the product version. pyproject.toml reads this
15
+ # attribute; anything else that reports a version derives from here. The one copy
16
+ # that cannot import it -- agents/openai.yaml, static YAML -- is pinned to it by a
17
+ # guard test (tests/test_version.py), so the two cannot drift.
18
+ #
19
+ # Held at 0.4.0 through the internal v0.5-v0.9 milestones, then moved here to
20
+ # 1.0.0 for the first public release since v0.4.0. See issue #12.
21
+ #
22
+ # 1.1.0 is a minor bump on a deliberately loose reading: no command, flag or JSON
23
+ # field changed, but the skill stopped answering natural language without
24
+ # /git-worklog and its menu switched to English. That is not an API break, and it
25
+ # is not invisible either -- hence the prominent Changed entry in the CHANGELOG.
26
+ #
27
+ # 1.2.0 on the same reading: no command, flag or JSON field changed, but the
28
+ # subagent contract pinned the write mechanism to a Bash heredoc, banned the
29
+ # Write tool, and gave a Day Subagent a second thing it may reply --
30
+ # FAILED:<date>. For anyone reading the contract that is an interface change.
31
+ #
32
+ # 1.2.1 is a patch: 1.2.0 banned the Write tool but not Edit, so a subagent whose
33
+ # result failed its own parse check reached for Edit to fix the one bad line and
34
+ # stalled there. Nothing in the interface moved -- the ban simply covers the tool
35
+ # it always should have.
36
+ #
37
+ # 1.3.1 is a patch on the reading that `view`'s page is output, not interface: no
38
+ # command, flag or JSON field changed. The light/dark switch and the copy badges
39
+ # are the parts of 1.3.0's page that should have shipped with it.
40
+ #
41
+ # 1.4.0 is a minor bump on the 1.1.0 reading: no command, flag or JSON field
42
+ # changed, but every host's default subagent model did (GPT-6 Luna, Gemini 3.8
43
+ # Flash; Sonnet 5.5 and GPT-6.1 Sol to escalate). Which model reads your code,
44
+ # and what a run costs, is not invisible.
45
+ #
46
+ # 1.5.0 is a minor bump by the plain rule: `view` gained a flag (`--serve`) and
47
+ # its JSON gained fields (`commit_count`, `serving`). The commit drawer alone
48
+ # would have read as page output, like 1.3.1; a server mode does not.
49
+ #
50
+ # 1.6.0 is a minor bump by the plain rule again: a new command (`models`), and
51
+ # the model table a run reads moved from the package to the user's own file.
52
+ __version__ = "1.6.0"
53
+
54
+ # On-disk layout version of `.git-worklog/`, re-exported for convenience. It
55
+ # describes the *data*, not the tool, and bumps only when a migration is needed.
56
+ from git_worklog.markers import LAYOUT_VERSION # noqa: E402
57
+
58
+ __all__ = ["__version__", "LAYOUT_VERSION"]
@@ -0,0 +1,10 @@
1
+ """Entry point for ``python3 -m git_worklog``.
2
+
3
+ The console script (``git-worklog``) only exists after a pip install. This form
4
+ works straight from the skill directory with nothing installed, which is how the
5
+ skill itself runs.
6
+ """
7
+
8
+ from git_worklog.cli import main
9
+
10
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ """The analysis pipeline: Git facts in, validated per-day results out.
2
+
3
+ This is the engine behind ``git-worklog analyze prepare`` and ``analyze
4
+ collect`` (roadmap §7). It lives in the package rather than in ``scripts/``
5
+ because only ``git_worklog*`` is packaged: an installed CLI has no ``scripts/``
6
+ directory to shell out to, so anything the CLI needs has to be importable.
7
+
8
+ The split mirrors the pipeline's three deterministic stages:
9
+
10
+ * :mod:`~git_worklog.analysis.history` — what Git says happened.
11
+ * :mod:`~git_worklog.analysis.manifest` — what a Day Subagent is asked to do.
12
+ * :mod:`~git_worklog.analysis.results` — what came back, and whether it holds up.
13
+
14
+ None of them summarise code or decide wording. That is the hosting agent's LLM's
15
+ job (§6.1), and the CLI deliberately needs no model API key.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ SCHEMA_VERSION = 1
21
+
22
+ # A run's on-disk layout (§7.1). Two directories, not one: `collect` can then
23
+ # tell a task that was never answered from a result nobody asked for. They live
24
+ # here rather than in the CLI because `preview` reads the same run and must not
25
+ # guess at the layout the CLI wrote.
26
+ TASKS_SUBDIR = "tasks"
27
+ RESULTS_SUBDIR = "results"
28
+ # Scratch space for a large day's fan-out. Deliberately *not* inside results/:
29
+ # `collect` treats every file there as a day's answer and fails the run over any
30
+ # it did not ask for, so a Day Subagent's per-group parts need somewhere to land
31
+ # that is not policed. See manifest.build's `parts_dir`.
32
+ PARTS_SUBDIR = "parts"
33
+
34
+
35
+ class AnalysisError(ValueError):
36
+ """A failure that carries the wire code the CLI and scripts report.
37
+
38
+ Mirrors :class:`git_worklog.language.LanguageError`: the pipeline's callers
39
+ are two thin shells (a script and a CLI subcommand) that both owe the user
40
+ one JSON object with a stable ``code``, so the code belongs on the
41
+ exception rather than being re-derived from the message at each call site.
42
+ """
43
+
44
+ def __init__(self, code: str, message: str, **extra):
45
+ self.code = code
46
+ self.message = message
47
+ self.extra = extra
48
+ super().__init__(message)
@@ -0,0 +1,118 @@
1
+ """What Git knows about the commits a worklog cites (``git-worklog view``).
2
+
3
+ Read-only, and narrow on purpose: it answers for the hashes it is handed and
4
+ for nothing else, so a page built from it shows the commits the worklog talks
5
+ about, never the repository's history.
6
+
7
+ **The repository has to be the one the worklog lives in.** Git looks upward for
8
+ a repository, so a worklog kept under an unrelated one -- a dotfiles ``~``, a
9
+ monorepo -- would have its hashes resolved there, and ``view --serve`` would
10
+ then hand out that repository's diffs. :func:`owns` therefore asks for the
11
+ directory to be the top level itself.
12
+
13
+ **Nothing here is an error.** A worklog outside any repository, a shallow clone
14
+ and a hash rebased away are all ordinary; each one just means fewer commits to
15
+ show, and the page falls back to what it did before it knew about Git.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import subprocess
22
+
23
+ from git_worklog.analysis.history import UNIT_SEP, GitError, _git
24
+ from git_worklog.analysis.reconcile import _batch_resolve
25
+
26
+ RECORD_SEP = "\x1e"
27
+ MAX_FILES = 200 # per commit; a vendoring commit lists thousands
28
+ MAX_BODY = 20000 # characters of commit message body
29
+
30
+ # How a commit is diffed -- for the file list here and for the patch the server
31
+ # sends, so the two describe the same files. A merge is shown against its first
32
+ # parent (what it brought in), the root commit against nothing; a signature is
33
+ # never checked, which would start a program named by the repository's config.
34
+ DIFF_FLAGS = ["--root", "--diff-merges=first-parent", "--no-show-signature",
35
+ "--submodule=short", "-M"]
36
+
37
+ _FORMAT = "--format=%x1e" + "%x1f".join(["%H", "%h", "%an", "%aI", "%s", "%b"]) + "%x1f"
38
+
39
+
40
+ def owns(repo: str) -> bool:
41
+ """Whether ``repo`` is itself the top level of a Git work tree."""
42
+ try:
43
+ top = _git(repo, ["rev-parse", "--show-toplevel"]).strip()
44
+ except (GitError, OSError):
45
+ return False
46
+ return os.path.realpath(top) == os.path.realpath(repo)
47
+
48
+
49
+ def _parse(blob: str) -> "dict[str, dict]":
50
+ """``git log -z --numstat`` output, one record per ``RECORD_SEP``.
51
+
52
+ A record is its fields, each closed by ``UNIT_SEP``, then NUL; then, when
53
+ the commit changed files, a newline and NUL-terminated entries:
54
+ ``add\\tdel\\tpath``, or for a rename ``add\\tdel\\t`` followed by the old
55
+ and the new path as two entries of their own. Binary files count ``-``.
56
+ """
57
+ commits: "dict[str, dict]" = {}
58
+ for record in blob.split(RECORD_SEP)[1:]:
59
+ head, _, rest = record.partition(UNIT_SEP + "\0")
60
+ fields = head.split(UNIT_SEP)
61
+ if len(fields) < 6:
62
+ continue
63
+ full, short, author, date, subject = fields[:5]
64
+ body = UNIT_SEP.join(fields[5:]).strip()
65
+ entries = rest.lstrip("\n").split("\0")
66
+ files, adds, dels, i = [], 0, 0, 0
67
+ while i < len(entries):
68
+ parts = entries[i].split("\t", 2)
69
+ i += 1
70
+ if len(parts) < 3:
71
+ continue
72
+ add, dele, path = parts
73
+ old = None
74
+ if not path and i + 1 < len(entries):
75
+ old, path = entries[i], entries[i + 1]
76
+ i += 2
77
+ binary = add == "-"
78
+ files.append({"path": path, "old_path": old,
79
+ "additions": None if binary else int(add),
80
+ "deletions": None if binary else int(dele)})
81
+ if not binary:
82
+ adds += int(add)
83
+ dels += int(dele)
84
+ commits[full] = {
85
+ "sha": full, "short": short, "author": author, "date": date,
86
+ "subject": subject, "body": body[:MAX_BODY],
87
+ "body_truncated": len(body) > MAX_BODY,
88
+ "files": files[:MAX_FILES], "file_count": len(files),
89
+ "additions": adds, "deletions": dels,
90
+ }
91
+ return commits
92
+
93
+
94
+ def collect(repo: str, tokens) -> "tuple[dict[str, dict], dict[str, str]]":
95
+ """(commits by full hash, full hash by token) for the tokens naming a commit.
96
+
97
+ A token that names nothing, names a tree or a blob, or is ambiguous is in
98
+ neither: the caller treats it as text.
99
+ """
100
+ tokens = sorted({t.lower() for t in tokens})
101
+ if not tokens or not owns(repo):
102
+ return {}, {}
103
+ try:
104
+ resolved = _batch_resolve(repo, tokens)
105
+ shas = sorted(set(resolved.values()))
106
+ if not shas:
107
+ return {}, {}
108
+ proc = subprocess.run(
109
+ ["git", "-C", repo, "log", "--no-walk", "--stdin", *DIFF_FLAGS,
110
+ "--numstat", "-z", _FORMAT],
111
+ input="\n".join(shas).encode("ascii"),
112
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
113
+ if proc.returncode != 0:
114
+ return {}, {}
115
+ except (GitError, OSError):
116
+ return {}, {}
117
+ commits = _parse(proc.stdout.decode("utf-8", "replace"))
118
+ return commits, {t: s for t, s in resolved.items() if s in commits}
@@ -0,0 +1,149 @@
1
+ """Report which dates have worklog coverage, for report mode.
2
+
3
+ Report mode answers questions from the day files already on disk. Before it can
4
+ do that honestly it has to know which of the requested dates actually have an
5
+ analysis behind them -- otherwise it quietly degrades into summarising commit
6
+ messages, which is the one thing this skill exists not to do.
7
+
8
+ The distinction that matters
9
+ ----------------------------
10
+ A date with no worklog file is **not** automatically a gap. Per
11
+ ``references/worklog-format.md`` §6, a day with no commits is deliberately given
12
+ no file -- the directory is not padded with empty days. So each date is one of:
13
+
14
+ * ``covered`` -- has commits and has a day file. Usable material.
15
+ * ``gap`` -- has commits but no day file. **A real gap**: real work exists
16
+ that nothing has analysed.
17
+ * ``no-commits`` -- no commits, so no file is expected. Nothing is missing.
18
+
19
+ Conflating ``no-commits`` with ``gap`` would send the user off to backfill days
20
+ that can never produce a file.
21
+
22
+ Commit counts come from :func:`git_worklog.analysis.history.collect_commits`, so
23
+ the self-referential worklog-commit exclusion is inherited: a day whose only
24
+ commits edited the worklog directory counts as ``no-commits``, not as a gap.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from datetime import date as date_cls
31
+
32
+ from git_worklog import dates as gwdates
33
+ from git_worklog import markers as wm
34
+ from git_worklog.analysis import AnalysisError
35
+ from git_worklog.analysis import history as cgh
36
+
37
+ # Report mode's day cap. Generation is bounded at 30 because each day costs a
38
+ # subagent; report mode only reads day files that already exist, so the thing
39
+ # that cap protects is not at stake and a quarter is a reasonable question to
40
+ # ask of a worklog.
41
+ REPORT_MAX_DAYS = 90
42
+
43
+
44
+ def _has_day_file(worklog_dir: str, date_str: str) -> bool:
45
+ # Resolve against the layout actually on disk, so a not-yet-migrated
46
+ # worklog reports its real coverage instead of a repo-wide false gap.
47
+ return os.path.isfile(wm.day_path(worklog_dir, date_str))
48
+
49
+
50
+ def _parse_dates(raw: str) -> "list[str]":
51
+ """An explicit ``a,b,c`` list. Ref scope's dates are a set, not a range."""
52
+ parsed = [d.strip() for d in raw.split(",") if d.strip()]
53
+ if not parsed:
54
+ raise AnalysisError("NO_DATES", "Provide at least one date via --dates.")
55
+ for d in parsed:
56
+ # wm.is_valid_date only checks the YYYY-MM-DD shape, so it accepts
57
+ # impossible dates like 2026-13-99; parse to reject those here rather
58
+ # than let them raise out of the day window as a bare traceback.
59
+ if not wm.is_valid_date(d):
60
+ raise AnalysisError("INVALID_DATE", f"Not an ISO YYYY-MM-DD date: {d}.", date=d)
61
+ try:
62
+ date_cls.fromisoformat(d)
63
+ except ValueError:
64
+ raise AnalysisError("INVALID_DATE", f"Not a real calendar date: {d}.", date=d)
65
+ return parsed
66
+
67
+
68
+ def check(repo: str = ".", dir: "str | None" = None, dates: str = "",
69
+ timezone: "str | None" = None, date_field: str = "committer",
70
+ worklog_dir: "str | None" = None, shortcut: "str | None" = None,
71
+ date: "str | None" = None, days: "int | None" = None,
72
+ from_: "str | None" = None, to: "str | None" = None,
73
+ today: "str | None" = None,
74
+ max_days: "int | None" = None) -> dict:
75
+ """Coverage for the requested dates. Raises AnalysisError or GitError.
76
+
77
+ Two ways to say which dates, because report mode has two scopes and they are
78
+ shaped differently. A *date* scope is a range, so it is given the same way
79
+ `analyze prepare` takes one and resolved by the same contract. A *ref* scope
80
+ is whatever days a tag's commits happen to fall on — an arbitrary set, with
81
+ gaps — so it arrives as an explicit ``dates`` list instead.
82
+ """
83
+ if dates and (shortcut or date or days is not None or from_ or to):
84
+ raise AnalysisError(
85
+ "ARG_CONFLICT",
86
+ "--dates lists exact days; the range flags resolve a span. Pass one.")
87
+
88
+ dir = dir or wm.WORKLOG_DIRNAME
89
+ worklog_dir = worklog_dir or cgh.DEFAULT_WORKLOG_DIR
90
+
91
+ if dates:
92
+ requested = _parse_dates(dates)
93
+ # An explicit list still needs a zone to know where its days start.
94
+ # UTC rather than the machine's: the caller of this form is ref scope,
95
+ # which already resolved these dates under a zone it chose, and guessing
96
+ # a different one here would silently re-cut the day boundaries.
97
+ tz, tz_name, tz_source = gwdates.detect_timezone(timezone or "UTC")
98
+ else:
99
+ # The same date contract generation mode uses, with report mode's own
100
+ # cap: it reads day files already on disk and spawns no subagents, so
101
+ # the 30-day bound on subagent cost does not apply to it.
102
+ d, n = gwdates.absorb_shortcut(shortcut, date, days)
103
+ resolved = gwdates.resolve(
104
+ date=d, days=n, from_=from_, to=to, timezone=timezone, today=today,
105
+ max_days=REPORT_MAX_DAYS if max_days is None else max_days)
106
+ requested = [e["date"] for e in resolved["dates"]]
107
+ tz, tz_name, tz_source = gwdates.detect_timezone(resolved["timezone"])
108
+
109
+ info = cgh.repo_info(repo) # raises NOT_A_GIT_REPO when applicable
110
+ resolved_dir = dir if os.path.isabs(dir) else os.path.join(info["root"], dir)
111
+ dir_exists = os.path.isdir(resolved_dir)
112
+
113
+ results = []
114
+ for date_str in sorted(set(requested)):
115
+ if info["has_commits"]:
116
+ start, end = gwdates.day_window(date_str, tz)
117
+ commits = cgh.collect_commits(repo, start, end, date_field, worklog_dir)
118
+ else:
119
+ commits = []
120
+ has_worklog = dir_exists and _has_day_file(resolved_dir, date_str)
121
+ if not commits:
122
+ status = "no-commits"
123
+ elif has_worklog:
124
+ status = "covered"
125
+ else:
126
+ status = "gap"
127
+ results.append({
128
+ "date": date_str,
129
+ "commit_count": len(commits),
130
+ "has_worklog": has_worklog,
131
+ "status": status,
132
+ })
133
+
134
+ gaps = [r["date"] for r in results if r["status"] == "gap"]
135
+ return {
136
+ "ok": True,
137
+ "repository": {"root": info["root"], "has_commits": info["has_commits"]},
138
+ "worklog_dir": resolved_dir,
139
+ "dir_exists": dir_exists,
140
+ "timezone": {"resolved": tz_name, "source": tz_source},
141
+ "date_field": date_field,
142
+ "dates": results,
143
+ "covered": [r["date"] for r in results if r["status"] == "covered"],
144
+ "gaps": gaps,
145
+ "no_commit_dates": [r["date"] for r in results if r["status"] == "no-commits"],
146
+ "gap_commit_count": sum(r["commit_count"] for r in results
147
+ if r["status"] == "gap"),
148
+ "fully_covered": not gaps,
149
+ }