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