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,339 @@
|
|
|
1
|
+
"""Repository metadata and per-day commit facts.
|
|
2
|
+
|
|
3
|
+
This is the *raw material* stage: it reports what Git says happened and nothing
|
|
4
|
+
more. It never writes summaries and never filters by author identity -- the
|
|
5
|
+
worklog is a project log, not a personal report.
|
|
6
|
+
|
|
7
|
+
Patches are deliberately not read here. A Day Subagent reads them with
|
|
8
|
+
``git show``, which keeps this stage's output bounded while still handing the
|
|
9
|
+
analyst an exact file/commit index.
|
|
10
|
+
|
|
11
|
+
Commits whose changed files fall entirely inside the worklog output directory
|
|
12
|
+
are the worklog's own self-referential output and are dropped from the commit
|
|
13
|
+
list entirely -- not counted, not reported. A commit touching both the worklog
|
|
14
|
+
directory and real files is kept, with only its worklog-directory files removed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
|
|
24
|
+
from git_worklog import markers as wm
|
|
25
|
+
from git_worklog.analysis import AnalysisError
|
|
26
|
+
|
|
27
|
+
RECORD_SEP = "\x1e"
|
|
28
|
+
UNIT_SEP = "\x1f"
|
|
29
|
+
|
|
30
|
+
_COMMIT_FORMAT = UNIT_SEP.join([
|
|
31
|
+
"%H", "%h", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%P", "%s", "%b",
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
# Commits whose changed files fall entirely inside this directory are the
|
|
35
|
+
# worklog's own output (e.g. "chore(docs): 補充 XX 專案工作日誌") and are
|
|
36
|
+
# excluded as self-referential -- see collect_commits().
|
|
37
|
+
DEFAULT_WORKLOG_DIR = wm.WORKLOG_DIRNAME
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git(repo: str, args: "list[str]", binary: bool = False):
|
|
45
|
+
proc = subprocess.run(
|
|
46
|
+
["git", "-C", repo, *args],
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.PIPE,
|
|
49
|
+
)
|
|
50
|
+
if proc.returncode != 0:
|
|
51
|
+
raise GitError(proc.stderr.decode("utf-8", "replace").strip())
|
|
52
|
+
return proc.stdout if binary else proc.stdout.decode("utf-8", "replace")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _git_ok(repo: str, args: "list[str]") -> bool:
|
|
56
|
+
proc = subprocess.run(
|
|
57
|
+
["git", "-C", repo, *args],
|
|
58
|
+
stdout=subprocess.DEVNULL,
|
|
59
|
+
stderr=subprocess.DEVNULL,
|
|
60
|
+
)
|
|
61
|
+
return proc.returncode == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def repo_info(repo: str) -> dict:
|
|
65
|
+
"""Repository state. Raises :class:`AnalysisError` if ``repo`` is not one."""
|
|
66
|
+
if not _git_ok(repo, ["rev-parse", "--is-inside-work-tree"]):
|
|
67
|
+
raise AnalysisError("NOT_A_GIT_REPO",
|
|
68
|
+
"The target directory is not inside a Git repository.",
|
|
69
|
+
path=repo)
|
|
70
|
+
root = _git(repo, ["rev-parse", "--show-toplevel"]).strip()
|
|
71
|
+
has_commits = _git_ok(repo, ["rev-parse", "--verify", "--quiet", "HEAD"])
|
|
72
|
+
# symbolic-ref resolves the branch even on an unborn branch (empty repo);
|
|
73
|
+
# it fails only on a detached HEAD, which implies there are commits.
|
|
74
|
+
sym = subprocess.run(
|
|
75
|
+
["git", "-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
76
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
77
|
+
)
|
|
78
|
+
if sym.returncode == 0:
|
|
79
|
+
branch = sym.stdout.decode("utf-8", "replace").strip()
|
|
80
|
+
detached = False
|
|
81
|
+
else:
|
|
82
|
+
branch = None
|
|
83
|
+
detached = True
|
|
84
|
+
head = _git(repo, ["rev-parse", "HEAD"]).strip() if has_commits else None
|
|
85
|
+
short_head = _git(repo, ["rev-parse", "--short", "HEAD"]).strip() if has_commits else None
|
|
86
|
+
dirty = bool(_git(repo, ["status", "--porcelain"]).strip())
|
|
87
|
+
return {
|
|
88
|
+
"root": root,
|
|
89
|
+
# A manifest records this so a reader can tell which checkout an
|
|
90
|
+
# analysis came from -- a worktree's git_dir is not root/.git, and that
|
|
91
|
+
# is exactly when "which repo was this?" stops being obvious (§8).
|
|
92
|
+
"git_dir": _git(repo, ["rev-parse", "--absolute-git-dir"]).strip(),
|
|
93
|
+
"branch": branch,
|
|
94
|
+
"detached_head": detached,
|
|
95
|
+
"head": head,
|
|
96
|
+
"short_head": short_head,
|
|
97
|
+
"has_commits": has_commits,
|
|
98
|
+
"dirty_worktree": dirty,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def identity(repo: str) -> str:
|
|
103
|
+
"""A stable answer to "is this still the same repository?" (roadmap §10.1).
|
|
104
|
+
|
|
105
|
+
Two facts, because either alone is forgeable by accident. The git directory's
|
|
106
|
+
real path pins *which checkout*: a clone, a second worktree, or a different
|
|
107
|
+
project at another path all differ here. The root commits pin *which
|
|
108
|
+
history*: re-``git init``-ing over the same path keeps the path and loses the
|
|
109
|
+
lineage, and a preview built against the old repository must not apply to the
|
|
110
|
+
new one just because it moved into the same folder.
|
|
111
|
+
|
|
112
|
+
An empty repository has no root commit, so its identity is its path alone.
|
|
113
|
+
That is as much as exists to know about it.
|
|
114
|
+
"""
|
|
115
|
+
info = repo_info(repo)
|
|
116
|
+
roots = (_git(repo, ["rev-list", "--max-parents=0", "HEAD"]).split()
|
|
117
|
+
if info["has_commits"] else [])
|
|
118
|
+
basis = "\x00".join([os.path.realpath(info["git_dir"]), *sorted(roots)])
|
|
119
|
+
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def submodule_fingerprint(repo: str) -> str:
|
|
123
|
+
"""Hash the recursive submodule state (roadmap §10.1).
|
|
124
|
+
|
|
125
|
+
A submodule pointer moving changes what the working tree actually contains
|
|
126
|
+
without touching a single tracked file in this repository, so nothing else
|
|
127
|
+
in the fingerprint would notice. Repositories without submodules hash the
|
|
128
|
+
empty string, which is stable and costs one Git call.
|
|
129
|
+
"""
|
|
130
|
+
out = _git(repo, ["submodule", "status", "--recursive"], binary=True)
|
|
131
|
+
return hashlib.sha256(out).hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_raw(blob: str) -> "list[dict]":
|
|
135
|
+
"""Parse ``git diff-tree --raw -z`` output into ordered file entries."""
|
|
136
|
+
tokens = blob.split("\x00")
|
|
137
|
+
tokens = [t for t in tokens if t != ""]
|
|
138
|
+
entries: "list[dict]" = []
|
|
139
|
+
i = 0
|
|
140
|
+
while i < len(tokens):
|
|
141
|
+
meta = tokens[i]
|
|
142
|
+
if not meta.startswith(":"):
|
|
143
|
+
# Defensive: unexpected token, stop to avoid mis-parsing.
|
|
144
|
+
break
|
|
145
|
+
fields = meta[1:].split(" ")
|
|
146
|
+
# :<oldmode> <newmode> <oldsha> <newsha> <status>
|
|
147
|
+
oldmode, newmode, oldsha, newsha, status = fields[:5]
|
|
148
|
+
code = status[0]
|
|
149
|
+
similarity = status[1:] if len(status) > 1 else None
|
|
150
|
+
if code in ("R", "C"):
|
|
151
|
+
old_path = tokens[i + 1]
|
|
152
|
+
new_path = tokens[i + 2]
|
|
153
|
+
i += 3
|
|
154
|
+
else:
|
|
155
|
+
old_path = None
|
|
156
|
+
new_path = tokens[i + 1]
|
|
157
|
+
i += 2
|
|
158
|
+
is_submodule = "160000" in (oldmode, newmode)
|
|
159
|
+
entries.append({
|
|
160
|
+
"status": code,
|
|
161
|
+
"similarity": int(similarity) if similarity and similarity.isdigit() else None,
|
|
162
|
+
"path": new_path,
|
|
163
|
+
"old_path": old_path,
|
|
164
|
+
"old_mode": oldmode,
|
|
165
|
+
"new_mode": newmode,
|
|
166
|
+
"old_sha": oldsha,
|
|
167
|
+
"new_sha": newsha,
|
|
168
|
+
"is_submodule": is_submodule,
|
|
169
|
+
})
|
|
170
|
+
return entries
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_numstat(blob: str) -> "list[tuple[str, str]]":
|
|
174
|
+
"""Parse ``git diff-tree --numstat -z`` output into ordered (add, del)."""
|
|
175
|
+
tokens = blob.split("\x00")
|
|
176
|
+
counts: "list[tuple[str, str]]" = []
|
|
177
|
+
i = 0
|
|
178
|
+
while i < len(tokens):
|
|
179
|
+
head = tokens[i]
|
|
180
|
+
if head == "":
|
|
181
|
+
i += 1
|
|
182
|
+
continue
|
|
183
|
+
parts = head.split("\t")
|
|
184
|
+
if len(parts) < 3:
|
|
185
|
+
i += 1
|
|
186
|
+
continue
|
|
187
|
+
add, dele, first_path = parts[0], parts[1], parts[2]
|
|
188
|
+
if first_path == "":
|
|
189
|
+
# rename/copy: the two paths follow as separate NUL tokens.
|
|
190
|
+
i += 3
|
|
191
|
+
else:
|
|
192
|
+
i += 1
|
|
193
|
+
counts.append((add, dele))
|
|
194
|
+
return counts
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def commit_files(repo: str, sha: str) -> "list[dict]":
|
|
198
|
+
"""Changed files for a non-merge commit, rename/copy aware."""
|
|
199
|
+
base = ["diff-tree", "--no-commit-id", "-r", "--root", "-M", "-C", "-z"]
|
|
200
|
+
raw = _parse_raw(_git(repo, [*base, "--raw", sha]))
|
|
201
|
+
nums = _parse_numstat(_git(repo, [*base, "--numstat", sha]))
|
|
202
|
+
files = []
|
|
203
|
+
for idx, entry in enumerate(raw):
|
|
204
|
+
add, dele = (nums[idx] if idx < len(nums) else ("-", "-"))
|
|
205
|
+
is_binary = add == "-" and dele == "-"
|
|
206
|
+
files.append({
|
|
207
|
+
"status": entry["status"],
|
|
208
|
+
"path": entry["path"],
|
|
209
|
+
"old_path": entry["old_path"],
|
|
210
|
+
"similarity": entry["similarity"],
|
|
211
|
+
"additions": None if is_binary else int(add or 0),
|
|
212
|
+
"deletions": None if is_binary else int(dele or 0),
|
|
213
|
+
"is_binary": is_binary,
|
|
214
|
+
"is_submodule": entry["is_submodule"],
|
|
215
|
+
"old_sha": entry["old_sha"] if entry["is_submodule"] else None,
|
|
216
|
+
"new_sha": entry["new_sha"] if entry["is_submodule"] else None,
|
|
217
|
+
})
|
|
218
|
+
return files
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_revert(subject: str, body: str) -> bool:
|
|
222
|
+
return subject.startswith("Revert ") or "This reverts commit" in body
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _in_worklog_dir(path: str, worklog_dir: str) -> bool:
|
|
226
|
+
"""True if ``path`` lives inside the worklog output directory.
|
|
227
|
+
|
|
228
|
+
The pre-v0.6 directory counts too, whatever ``worklog_dir`` is now. History
|
|
229
|
+
predating the migration still touches it, and a commit that only ever wrote
|
|
230
|
+
the worklog does not become real project work because the directory was
|
|
231
|
+
later renamed.
|
|
232
|
+
"""
|
|
233
|
+
for d in {worklog_dir, wm.LEGACY_WORKLOG_DIRNAME}:
|
|
234
|
+
prefix = d.rstrip("/") + "/"
|
|
235
|
+
if path == d or path.startswith(prefix):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def collect_commits(repo: str, since: datetime, until: datetime,
|
|
241
|
+
date_field: str, worklog_dir: "str | None" = None) -> "list[dict]":
|
|
242
|
+
# Coarse git-side window padded by 2 days; precise half-open filtering is
|
|
243
|
+
# done in Python so the chosen date field and DST boundaries are exact.
|
|
244
|
+
git_since = (since - timedelta(days=2)).isoformat()
|
|
245
|
+
git_until = (until + timedelta(days=2)).isoformat()
|
|
246
|
+
log = _git(repo, [
|
|
247
|
+
"log",
|
|
248
|
+
f"--since-as-filter={git_since}",
|
|
249
|
+
f"--until={git_until}",
|
|
250
|
+
"--date=iso-strict",
|
|
251
|
+
f"--pretty=format:{RECORD_SEP}{_COMMIT_FORMAT}",
|
|
252
|
+
])
|
|
253
|
+
commits = []
|
|
254
|
+
for record in log.split(RECORD_SEP):
|
|
255
|
+
if not record.strip("\n"):
|
|
256
|
+
continue
|
|
257
|
+
fields = record.lstrip("\n").split(UNIT_SEP)
|
|
258
|
+
if len(fields) < 11:
|
|
259
|
+
continue
|
|
260
|
+
(full, short, an, ae, ad, cn, ce, cd, parents, subject, body) = fields[:11]
|
|
261
|
+
chosen = ad if date_field == "author" else cd
|
|
262
|
+
chosen_dt = datetime.fromisoformat(chosen)
|
|
263
|
+
if not (since <= chosen_dt < until):
|
|
264
|
+
continue
|
|
265
|
+
parent_list = parents.split() if parents.strip() else []
|
|
266
|
+
is_merge = len(parent_list) > 1
|
|
267
|
+
files = [] if is_merge else commit_files(repo, full)
|
|
268
|
+
if worklog_dir is not None and files:
|
|
269
|
+
non_worklog_files = [f for f in files
|
|
270
|
+
if not _in_worklog_dir(f["path"], worklog_dir)]
|
|
271
|
+
if not non_worklog_files:
|
|
272
|
+
# Self-referential commit (touches only the worklog's own
|
|
273
|
+
# output) -- excluded entirely: not counted, not reported.
|
|
274
|
+
continue
|
|
275
|
+
files = non_worklog_files
|
|
276
|
+
additions = sum(f["additions"] or 0 for f in files)
|
|
277
|
+
deletions = sum(f["deletions"] or 0 for f in files)
|
|
278
|
+
commits.append({
|
|
279
|
+
"full_hash": full,
|
|
280
|
+
"short_hash": short,
|
|
281
|
+
"author_name": an,
|
|
282
|
+
"author_email": ae,
|
|
283
|
+
"author_date": ad,
|
|
284
|
+
"committer_name": cn,
|
|
285
|
+
"committer_email": ce,
|
|
286
|
+
"committer_date": cd,
|
|
287
|
+
"subject": subject,
|
|
288
|
+
"body": body.rstrip("\n"),
|
|
289
|
+
"parents": parent_list,
|
|
290
|
+
"is_merge": is_merge,
|
|
291
|
+
"is_revert_candidate": _is_revert(subject, body),
|
|
292
|
+
"files": files,
|
|
293
|
+
"diffstat": {
|
|
294
|
+
"files_changed": len(files),
|
|
295
|
+
"additions": additions,
|
|
296
|
+
"deletions": deletions,
|
|
297
|
+
"has_binary": any(f["is_binary"] for f in files),
|
|
298
|
+
"has_submodule": any(f["is_submodule"] for f in files),
|
|
299
|
+
"note": "combined merge diff omitted to avoid double counting"
|
|
300
|
+
if is_merge else None,
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
# committer/author dates ascending within the day for readability.
|
|
304
|
+
commits.sort(key=lambda c: c["committer_date" if date_field != "author" else "author_date"])
|
|
305
|
+
return commits
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def collect(repo: str, since: "str | None" = None, until: "str | None" = None,
|
|
309
|
+
date_field: str = "committer", info_only: bool = False,
|
|
310
|
+
worklog_dir: "str | None" = DEFAULT_WORKLOG_DIR) -> dict:
|
|
311
|
+
"""The whole stage: repository state plus, unless ``info_only``, the commits.
|
|
312
|
+
|
|
313
|
+
Raises :class:`AnalysisError` for a caller mistake (not a repo, no window)
|
|
314
|
+
and :class:`GitError` when Git itself refuses.
|
|
315
|
+
"""
|
|
316
|
+
info = repo_info(repo)
|
|
317
|
+
payload = {"ok": True, "repository": info, "date_field": date_field}
|
|
318
|
+
if info_only:
|
|
319
|
+
return payload
|
|
320
|
+
if not since or not until:
|
|
321
|
+
raise AnalysisError(
|
|
322
|
+
"MISSING_WINDOW",
|
|
323
|
+
"Both --since and --until are required unless --info-only is set.")
|
|
324
|
+
if not info["has_commits"]:
|
|
325
|
+
payload.update({
|
|
326
|
+
"window": {"since": since, "until": until},
|
|
327
|
+
"commits": [],
|
|
328
|
+
"note": "Repository has no commits yet.",
|
|
329
|
+
})
|
|
330
|
+
return payload
|
|
331
|
+
commits = collect_commits(repo, datetime.fromisoformat(since),
|
|
332
|
+
datetime.fromisoformat(until), date_field,
|
|
333
|
+
worklog_dir)
|
|
334
|
+
payload.update({
|
|
335
|
+
"window": {"since": since, "until": until},
|
|
336
|
+
"commit_count": len(commits),
|
|
337
|
+
"commits": commits,
|
|
338
|
+
})
|
|
339
|
+
return payload
|