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,178 @@
|
|
|
1
|
+
"""Resolve a tag/ref range into its authoritative commit set for report mode.
|
|
2
|
+
|
|
3
|
+
Used when the user asks about a *version* ("整理 v1.0.1 CHANGELOG") rather than
|
|
4
|
+
a *date range*.
|
|
5
|
+
|
|
6
|
+
Why this exists, and why the commit set is authoritative
|
|
7
|
+
-------------------------------------------------------
|
|
8
|
+
The worklog is indexed by calendar date; a version is bounded by a commit set.
|
|
9
|
+
The two do not map onto each other, so a report must not simply convert a tag
|
|
10
|
+
into a date span and read those day files -- that is wrong in both directions:
|
|
11
|
+
|
|
12
|
+
* **Over-inclusive:** a day file describes *everything* committed that day,
|
|
13
|
+
including commits outside the tag range (work on another branch, or commits
|
|
14
|
+
landed after the tag was cut that afternoon).
|
|
15
|
+
* **Under-inclusive:** a cherry-picked commit keeps its original author date, so
|
|
16
|
+
it belongs to the range while sitting on a day outside the span.
|
|
17
|
+
|
|
18
|
+
So this emits the commit set as the authority, and the ``dates`` it derives are
|
|
19
|
+
only an index for locating the day files worth reading. The caller reconciles the
|
|
20
|
+
two by matching the short hashes the day files already carry in their
|
|
21
|
+
``相關 commits`` bullets.
|
|
22
|
+
|
|
23
|
+
Day attribution uses the same rule as :mod:`~git_worklog.analysis.history`: the
|
|
24
|
+
committer date by default, in the resolved local timezone, so a commit lands on
|
|
25
|
+
the same calendar day both would file it under.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
32
|
+
|
|
33
|
+
from git_worklog.analysis import AnalysisError
|
|
34
|
+
# The Git plumbing comes from history rather than being copied a third time.
|
|
35
|
+
# worktree.py holds its own copy of these plus the diff parser; merging those is
|
|
36
|
+
# issue #20's job, and whatever shared home it lands on, this import moves with
|
|
37
|
+
# the others.
|
|
38
|
+
from git_worklog.analysis.history import UNIT_SEP, GitError, _git, _git_ok
|
|
39
|
+
|
|
40
|
+
_COMMIT_FORMAT = UNIT_SEP.join(["%H", "%h", "%an", "%cI", "%aI", "%s"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_tags(repo: str) -> "list[str]":
|
|
44
|
+
"""Tags newest-first by the date the tag itself was created.
|
|
45
|
+
|
|
46
|
+
``creatordate`` reads an annotated tag's own timestamp and falls back to the
|
|
47
|
+
commit date for a lightweight tag, so both kinds sort by when the release was
|
|
48
|
+
actually cut. Sorting by version number instead would misorder any project
|
|
49
|
+
that backports (v1.0.2 cut after v1.1.0).
|
|
50
|
+
"""
|
|
51
|
+
out = _git(repo, ["tag", "--sort=-creatordate", "--format=%(refname:short)"])
|
|
52
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_previous_tag(repo: str, tag: str) -> "str | None":
|
|
56
|
+
"""The tag cut immediately before ``tag``, or None if it is the first."""
|
|
57
|
+
tags = list_tags(repo)
|
|
58
|
+
if tag not in tags:
|
|
59
|
+
return None
|
|
60
|
+
idx = tags.index(tag)
|
|
61
|
+
return tags[idx + 1] if idx + 1 < len(tags) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _local_date(iso_ts: str, tz: ZoneInfo) -> str:
|
|
65
|
+
return datetime.fromisoformat(iso_ts).astimezone(tz).date().isoformat()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def collect_range(repo: str, from_ref: "str | None", to_ref: str,
|
|
69
|
+
tz: ZoneInfo, date_field: str) -> "list[dict]":
|
|
70
|
+
"""Commits reachable from ``to_ref`` but not ``from_ref`` (exclusive start).
|
|
71
|
+
|
|
72
|
+
``from_ref`` of None means "since the root commit" -- the first release.
|
|
73
|
+
"""
|
|
74
|
+
spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
75
|
+
out = _git(repo, ["log", spec, "--date=iso-strict",
|
|
76
|
+
f"--pretty=format:{_COMMIT_FORMAT}"])
|
|
77
|
+
commits = []
|
|
78
|
+
for line in out.splitlines():
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
fields = line.split(UNIT_SEP)
|
|
82
|
+
if len(fields) < 6:
|
|
83
|
+
continue
|
|
84
|
+
full, short, author, cdate, adate, subject = fields[:6]
|
|
85
|
+
chosen = adate if date_field == "author" else cdate
|
|
86
|
+
commits.append({
|
|
87
|
+
"full_hash": full,
|
|
88
|
+
"short_hash": short,
|
|
89
|
+
"author_name": author,
|
|
90
|
+
"date": _local_date(chosen, tz),
|
|
91
|
+
"subject": subject,
|
|
92
|
+
})
|
|
93
|
+
commits.reverse() # oldest first, so a CHANGELOG reads chronologically
|
|
94
|
+
return commits
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_spec(tag: "str | None", to_ref: "str | None",
|
|
98
|
+
from_ref: "str | None", list_tags_only: bool) -> None:
|
|
99
|
+
"""Check the ref arguments make a coherent request. Raises AnalysisError.
|
|
100
|
+
|
|
101
|
+
Separate from :func:`resolve` so it runs before any Git call: "you asked for
|
|
102
|
+
nothing" should not depend on whether the directory is a repository.
|
|
103
|
+
"""
|
|
104
|
+
if not list_tags_only and not tag and not to_ref:
|
|
105
|
+
raise AnalysisError("NO_REF_SPEC",
|
|
106
|
+
"Provide --tag, or --to-ref (optionally with "
|
|
107
|
+
"--from-ref), or --list-tags.")
|
|
108
|
+
if tag and to_ref:
|
|
109
|
+
raise AnalysisError("ARG_CONFLICT", "--tag and --to-ref are mutually exclusive.")
|
|
110
|
+
if from_ref and not to_ref:
|
|
111
|
+
raise AnalysisError("FROM_REF_WITHOUT_TO_REF", "--from-ref requires --to-ref.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve(repo: str = ".", tag: "str | None" = None,
|
|
115
|
+
from_ref: "str | None" = None, to_ref: "str | None" = None,
|
|
116
|
+
list_tags_only: bool = False, timezone: str = "UTC",
|
|
117
|
+
date_field: str = "committer") -> dict:
|
|
118
|
+
"""Resolve the requested range. Raises AnalysisError or GitError."""
|
|
119
|
+
validate_spec(tag, to_ref, from_ref, list_tags_only)
|
|
120
|
+
|
|
121
|
+
if not _git_ok(repo, ["rev-parse", "--is-inside-work-tree"]):
|
|
122
|
+
raise AnalysisError("NOT_A_GIT_REPO",
|
|
123
|
+
"The target directory is not inside a Git repository.",
|
|
124
|
+
path=repo)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
tz = ZoneInfo(timezone)
|
|
128
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
129
|
+
raise AnalysisError("INVALID_TIMEZONE", f"Unknown IANA timezone: {timezone}.")
|
|
130
|
+
|
|
131
|
+
if list_tags_only:
|
|
132
|
+
return {"ok": True, "tags": list_tags(repo)}
|
|
133
|
+
|
|
134
|
+
if to_ref:
|
|
135
|
+
for ref in (r for r in (to_ref, from_ref) if r):
|
|
136
|
+
if not _git_ok(repo, ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"]):
|
|
137
|
+
raise AnalysisError("UNKNOWN_REF",
|
|
138
|
+
f"Ref not found in this repository: {ref}.", ref=ref)
|
|
139
|
+
resolved_tag, prev_tag, first_release = to_ref, from_ref, from_ref is None
|
|
140
|
+
else:
|
|
141
|
+
tags = list_tags(repo)
|
|
142
|
+
if not tags:
|
|
143
|
+
raise AnalysisError("NO_TAGS",
|
|
144
|
+
"This repository has no tags, so no version range "
|
|
145
|
+
"can be resolved. Ask the user for an explicit date "
|
|
146
|
+
"range instead.")
|
|
147
|
+
if tag not in tags:
|
|
148
|
+
raise AnalysisError("UNKNOWN_TAG", f"Tag not found: {tag}.",
|
|
149
|
+
tag=tag, available_tags=tags)
|
|
150
|
+
resolved_tag = tag
|
|
151
|
+
prev_tag = _resolve_previous_tag(repo, tag)
|
|
152
|
+
# The oldest tag has no predecessor: the range runs from the root commit.
|
|
153
|
+
# Reported rather than silently assumed -- "everything ever" is a very
|
|
154
|
+
# different answer from "changes since the last release".
|
|
155
|
+
first_release = prev_tag is None
|
|
156
|
+
|
|
157
|
+
commits = collect_range(repo, prev_tag, resolved_tag, tz, date_field)
|
|
158
|
+
dates = sorted({c["date"] for c in commits})
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"ok": True,
|
|
162
|
+
"tag": resolved_tag,
|
|
163
|
+
"prev_tag": prev_tag,
|
|
164
|
+
"first_release": first_release,
|
|
165
|
+
"commit_range": f"{prev_tag}..{resolved_tag}" if prev_tag else resolved_tag,
|
|
166
|
+
"date_field": date_field,
|
|
167
|
+
"timezone": timezone,
|
|
168
|
+
"commit_count": len(commits),
|
|
169
|
+
"commits": commits,
|
|
170
|
+
"dates": dates,
|
|
171
|
+
"date_span": {"from": dates[0], "to": dates[-1]} if dates else None,
|
|
172
|
+
"note": "commits[] is authoritative; dates[] only locates the day files "
|
|
173
|
+
"worth reading. A day file may describe commits outside this "
|
|
174
|
+
"range -- reconcile by short_hash.",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = ["GitError", "collect_range", "list_tags", "resolve", "validate_spec"]
|