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,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"]