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
git_worklog/__init__.py
ADDED
|
@@ -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"]
|
git_worklog/__main__.py
ADDED
|
@@ -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
|
+
}
|