esys-gitlog 0.1.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.
- esys_gitlog/__init__.py +3 -0
- esys_gitlog/__main__.py +6 -0
- esys_gitlog/app.py +215 -0
- esys_gitlog/cli.py +323 -0
- esys_gitlog/config.py +202 -0
- esys_gitlog/detection.py +145 -0
- esys_gitlog/display.py +82 -0
- esys_gitlog/git_adapter.py +370 -0
- esys_gitlog/identifiers.py +46 -0
- esys_gitlog/interpret.py +308 -0
- esys_gitlog/links.py +87 -0
- esys_gitlog/model.py +138 -0
- esys_gitlog/pager.py +106 -0
- esys_gitlog/pipeline.py +44 -0
- esys_gitlog/preferences.py +380 -0
- esys_gitlog/qa.py +196 -0
- esys_gitlog/render.py +372 -0
- esys_gitlog/report.py +117 -0
- esys_gitlog/tool_paths.py +62 -0
- esys_gitlog-0.1.0.dist-info/METADATA +77 -0
- esys_gitlog-0.1.0.dist-info/RECORD +25 -0
- esys_gitlog-0.1.0.dist-info/WHEEL +5 -0
- esys_gitlog-0.1.0.dist-info/entry_points.txt +2 -0
- esys_gitlog-0.1.0.dist-info/licenses/LICENSE.txt +18 -0
- esys_gitlog-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Git process adapter: argument vector, no shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitError(Exception):
|
|
13
|
+
"""Operational Git failure. Not an empty history and not a QA diagnostic."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, *, stderr: str = "") -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.stderr = stderr
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Fixed framing for parse_git_log_output / iter_parse_git_log_stream.
|
|
21
|
+
PRETTY_FORMAT = "--pretty=format:%H%x00%an%x00%ae%x00%aI%x00%B%x00"
|
|
22
|
+
|
|
23
|
+
# Options that replace or break null-framed pretty output (or flood with diffs).
|
|
24
|
+
# These are stripped with a warning; elog keeps its own pretty format.
|
|
25
|
+
_IGNORED_GIT_LOG_OPTIONS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"--oneline",
|
|
28
|
+
"--graph",
|
|
29
|
+
"--patch",
|
|
30
|
+
"-p",
|
|
31
|
+
"-u",
|
|
32
|
+
"--name-only",
|
|
33
|
+
"--name-status",
|
|
34
|
+
"--stat",
|
|
35
|
+
"--numstat",
|
|
36
|
+
"--shortstat",
|
|
37
|
+
"--dirstat",
|
|
38
|
+
"--summary",
|
|
39
|
+
"--raw",
|
|
40
|
+
"--compact-summary",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_ignored_git_log_option(arg: str) -> bool:
|
|
46
|
+
if arg in _IGNORED_GIT_LOG_OPTIONS:
|
|
47
|
+
return True
|
|
48
|
+
if arg.startswith("--pretty") or arg.startswith("--format"):
|
|
49
|
+
return True
|
|
50
|
+
if arg.startswith("-") and not arg.startswith("--") and "p" in arg[1:]:
|
|
51
|
+
letters = arg[1:]
|
|
52
|
+
if letters.isdigit() or letters.startswith("n"):
|
|
53
|
+
return False
|
|
54
|
+
return "p" in letters
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def filter_git_log_args(args: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
59
|
+
"""Split passthrough args into forwarded tokens and ignore-warnings.
|
|
60
|
+
|
|
61
|
+
Pretty/diff format options are dropped (elog owns framing). Other ``git log``
|
|
62
|
+
filters and revisions are kept. Each ignored option yields one warning line.
|
|
63
|
+
"""
|
|
64
|
+
forwarded: list[str] = []
|
|
65
|
+
warnings: list[str] = []
|
|
66
|
+
for arg in args:
|
|
67
|
+
if arg == "--":
|
|
68
|
+
forwarded.append(arg)
|
|
69
|
+
continue
|
|
70
|
+
if _is_ignored_git_log_option(arg):
|
|
71
|
+
warnings.append(
|
|
72
|
+
f"warning: ignoring {arg} "
|
|
73
|
+
"(git-elog owns pretty/diff format; not forwarded to git log)"
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
forwarded.append(arg)
|
|
77
|
+
return forwarded, warnings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_git_log_args(args: Sequence[str]) -> list[str]:
|
|
81
|
+
"""Return forwarded args (ignored pretty/diff options stripped).
|
|
82
|
+
|
|
83
|
+
Prefer :func:`filter_git_log_args` when callers need the warning texts.
|
|
84
|
+
"""
|
|
85
|
+
forwarded, _warnings = filter_git_log_args(args)
|
|
86
|
+
return forwarded
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_git_log_argv(
|
|
90
|
+
git_executable: str,
|
|
91
|
+
git_log_args: Sequence[str] | None = None,
|
|
92
|
+
) -> tuple[list[str], list[str]]:
|
|
93
|
+
"""Build ``git log`` argv with elog pretty format first, then user args.
|
|
94
|
+
|
|
95
|
+
Returns ``(argv, warnings)`` where warnings describe ignored options.
|
|
96
|
+
"""
|
|
97
|
+
extra, warnings = filter_git_log_args(git_log_args or ())
|
|
98
|
+
return [git_executable, "log", PRETTY_FORMAT, *extra], warnings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class RawCommit:
|
|
103
|
+
"""One commit as returned by Git, before semantic analysis."""
|
|
104
|
+
|
|
105
|
+
id: str
|
|
106
|
+
subject: str
|
|
107
|
+
body: str
|
|
108
|
+
message: str = ""
|
|
109
|
+
author_name: str = ""
|
|
110
|
+
author_email: str = ""
|
|
111
|
+
authored_date: str = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_commit_message(raw: str) -> tuple[str, str]:
|
|
115
|
+
"""Split ``%B`` into subject and body."""
|
|
116
|
+
if not raw:
|
|
117
|
+
return "", ""
|
|
118
|
+
text = raw[:-1] if raw.endswith("\n") else raw
|
|
119
|
+
lines = text.splitlines()
|
|
120
|
+
subject = lines[0] if lines else ""
|
|
121
|
+
if len(lines) <= 1:
|
|
122
|
+
return subject, ""
|
|
123
|
+
rest = lines[1:]
|
|
124
|
+
if rest and rest[0] == "":
|
|
125
|
+
rest = rest[1:]
|
|
126
|
+
return subject, "\n".join(rest)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _raw_from_width2(commit_id: str, message: str) -> RawCommit | None:
|
|
130
|
+
commit_id = commit_id.strip()
|
|
131
|
+
if not commit_id:
|
|
132
|
+
return None
|
|
133
|
+
subject, body = split_commit_message(message)
|
|
134
|
+
return RawCommit(
|
|
135
|
+
id=commit_id,
|
|
136
|
+
subject=subject,
|
|
137
|
+
body=body,
|
|
138
|
+
message=message[:-1] if message.endswith("\n") else message,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _raw_from_width5(
|
|
143
|
+
commit_id: str,
|
|
144
|
+
author_name: str,
|
|
145
|
+
author_email: str,
|
|
146
|
+
authored_date: str,
|
|
147
|
+
message: str,
|
|
148
|
+
) -> RawCommit | None:
|
|
149
|
+
commit_id = commit_id.strip()
|
|
150
|
+
if not commit_id:
|
|
151
|
+
return None
|
|
152
|
+
subject, body = split_commit_message(message)
|
|
153
|
+
return RawCommit(
|
|
154
|
+
id=commit_id,
|
|
155
|
+
subject=subject,
|
|
156
|
+
body=body,
|
|
157
|
+
message=message[:-1] if message.endswith("\n") else message,
|
|
158
|
+
author_name=author_name,
|
|
159
|
+
author_email=author_email,
|
|
160
|
+
authored_date=authored_date,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_git_log_output(data: bytes | str) -> list[RawCommit]:
|
|
165
|
+
"""Parse null-framed ``%H %an %ae %aI %B`` records from ``git log``."""
|
|
166
|
+
if isinstance(data, bytes):
|
|
167
|
+
text = data.decode("utf-8", errors="surrogateescape")
|
|
168
|
+
else:
|
|
169
|
+
text = data
|
|
170
|
+
if not text:
|
|
171
|
+
return []
|
|
172
|
+
parts = text.split("\0")
|
|
173
|
+
while parts and parts[-1] == "":
|
|
174
|
+
parts.pop()
|
|
175
|
+
# New framing: id, author, email, date, message (5 fields)
|
|
176
|
+
if len(parts) % 5 == 0:
|
|
177
|
+
width = 5
|
|
178
|
+
elif len(parts) % 2 == 0:
|
|
179
|
+
# Backward-compatible %H%x00%B%x00 framing used by older tests
|
|
180
|
+
width = 2
|
|
181
|
+
else:
|
|
182
|
+
raise GitError("git log framing is incomplete")
|
|
183
|
+
|
|
184
|
+
commits: list[RawCommit] = []
|
|
185
|
+
for index in range(0, len(parts), width):
|
|
186
|
+
if width == 2:
|
|
187
|
+
raw = _raw_from_width2(parts[index], parts[index + 1])
|
|
188
|
+
else:
|
|
189
|
+
raw = _raw_from_width5(
|
|
190
|
+
parts[index],
|
|
191
|
+
parts[index + 1],
|
|
192
|
+
parts[index + 2],
|
|
193
|
+
parts[index + 3],
|
|
194
|
+
parts[index + 4],
|
|
195
|
+
)
|
|
196
|
+
if raw is not None:
|
|
197
|
+
commits.append(raw)
|
|
198
|
+
return commits
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def iter_parse_git_log_stream(
|
|
202
|
+
stream: BinaryIO,
|
|
203
|
+
*,
|
|
204
|
+
chunk_size: int = 65536,
|
|
205
|
+
) -> Iterator[RawCommit]:
|
|
206
|
+
"""Yield commits as null-framed records arrive on ``stream`` (5-field framing)."""
|
|
207
|
+
pending = ""
|
|
208
|
+
fields: list[str] = []
|
|
209
|
+
while True:
|
|
210
|
+
chunk = stream.read(chunk_size)
|
|
211
|
+
if not chunk:
|
|
212
|
+
break
|
|
213
|
+
pending += chunk.decode("utf-8", errors="surrogateescape")
|
|
214
|
+
while "\0" in pending:
|
|
215
|
+
field, pending = pending.split("\0", 1)
|
|
216
|
+
fields.append(field)
|
|
217
|
+
if len(fields) < 5:
|
|
218
|
+
continue
|
|
219
|
+
raw = _raw_from_width5(
|
|
220
|
+
fields[0],
|
|
221
|
+
fields[1],
|
|
222
|
+
fields[2],
|
|
223
|
+
fields[3],
|
|
224
|
+
fields[4],
|
|
225
|
+
)
|
|
226
|
+
fields = []
|
|
227
|
+
if raw is not None:
|
|
228
|
+
yield raw
|
|
229
|
+
if fields or pending.strip("\0"):
|
|
230
|
+
raise GitError("git log framing is incomplete")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def resolve_git_executable() -> str:
|
|
234
|
+
"""Return a usable ``git`` binary.
|
|
235
|
+
|
|
236
|
+
Prefer ``PATH``, then common install locations. VS Code's Extension Host on
|
|
237
|
+
Windows sometimes runs with a PATH that omits Git even when it is installed.
|
|
238
|
+
"""
|
|
239
|
+
import os
|
|
240
|
+
import shutil
|
|
241
|
+
|
|
242
|
+
found = shutil.which("git")
|
|
243
|
+
if found:
|
|
244
|
+
return found
|
|
245
|
+
candidates: list[str]
|
|
246
|
+
if os.name == "nt": # pragma: no cover
|
|
247
|
+
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
248
|
+
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
249
|
+
local = os.environ.get("LocalAppData", "")
|
|
250
|
+
candidates = [
|
|
251
|
+
str(Path(pf) / "Git" / "cmd" / "git.exe"),
|
|
252
|
+
str(Path(pf) / "Git" / "bin" / "git.exe"),
|
|
253
|
+
str(Path(pf86) / "Git" / "cmd" / "git.exe"),
|
|
254
|
+
str(Path(pf86) / "Git" / "bin" / "git.exe"),
|
|
255
|
+
]
|
|
256
|
+
if local:
|
|
257
|
+
candidates.append(str(Path(local) / "Programs" / "Git" / "cmd" / "git.exe"))
|
|
258
|
+
else:
|
|
259
|
+
candidates = ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"]
|
|
260
|
+
for candidate in candidates:
|
|
261
|
+
if Path(candidate).is_file():
|
|
262
|
+
return candidate
|
|
263
|
+
raise GitError("git executable not found on PATH")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run_git(args: list[str], *, cwd: Path) -> subprocess.CompletedProcess[bytes]:
|
|
267
|
+
try:
|
|
268
|
+
return subprocess.run(
|
|
269
|
+
args,
|
|
270
|
+
cwd=cwd,
|
|
271
|
+
capture_output=True,
|
|
272
|
+
check=False,
|
|
273
|
+
)
|
|
274
|
+
except FileNotFoundError as exc:
|
|
275
|
+
raise GitError("git executable not found on PATH") from exc
|
|
276
|
+
except NotADirectoryError as exc:
|
|
277
|
+
raise GitError(f"not a directory: {cwd}") from exc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ensure_git_repo(root: Path, *, git_executable: str) -> bool:
|
|
281
|
+
"""Return True when HEAD exists; False when the repo has no commits.
|
|
282
|
+
|
|
283
|
+
Raises ``GitError`` when ``root`` is not a Git work tree.
|
|
284
|
+
"""
|
|
285
|
+
if not root.is_dir():
|
|
286
|
+
raise GitError(f"not a directory: {root}")
|
|
287
|
+
probe = _run_git([git_executable, "rev-parse", "--git-dir"], cwd=root)
|
|
288
|
+
if probe.returncode != 0:
|
|
289
|
+
detail = probe.stderr.decode("utf-8", errors="replace").strip()
|
|
290
|
+
raise GitError(
|
|
291
|
+
detail or "not a git repository",
|
|
292
|
+
stderr=detail,
|
|
293
|
+
)
|
|
294
|
+
head = _run_git([git_executable, "rev-parse", "--verify", "HEAD"], cwd=root)
|
|
295
|
+
return head.returncode == 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def list_commits(
|
|
299
|
+
cwd: str | Path = ".",
|
|
300
|
+
*,
|
|
301
|
+
git_executable: str | None = None,
|
|
302
|
+
git_log_args: Sequence[str] | None = None,
|
|
303
|
+
) -> list[RawCommit]:
|
|
304
|
+
"""Return commits in ``git log`` order from the repository at ``cwd``.
|
|
305
|
+
|
|
306
|
+
``git_log_args`` are forwarded to ``git log`` after the fixed pretty format
|
|
307
|
+
(revisions, pathspecs, ``--since``, ``--all``, ``-n``, …).
|
|
308
|
+
"""
|
|
309
|
+
root = Path(cwd)
|
|
310
|
+
git = git_executable or resolve_git_executable()
|
|
311
|
+
if not _ensure_git_repo(root, git_executable=git):
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
argv, _warnings = build_git_log_argv(git, git_log_args)
|
|
315
|
+
completed = _run_git(argv, cwd=root)
|
|
316
|
+
if completed.returncode != 0:
|
|
317
|
+
detail = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
318
|
+
raise GitError(
|
|
319
|
+
detail or "git log failed",
|
|
320
|
+
stderr=detail,
|
|
321
|
+
)
|
|
322
|
+
return parse_git_log_output(completed.stdout)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def iter_commits(
|
|
326
|
+
cwd: str | Path = ".",
|
|
327
|
+
*,
|
|
328
|
+
git_executable: str | None = None,
|
|
329
|
+
git_log_args: Sequence[str] | None = None,
|
|
330
|
+
) -> Iterator[RawCommit]:
|
|
331
|
+
"""Yield commits in ``git log`` order as Git writes them.
|
|
332
|
+
|
|
333
|
+
``git_log_args`` are forwarded like :func:`list_commits`.
|
|
334
|
+
"""
|
|
335
|
+
root = Path(cwd)
|
|
336
|
+
git = git_executable or resolve_git_executable()
|
|
337
|
+
if not _ensure_git_repo(root, git_executable=git):
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
argv, _warnings = build_git_log_argv(git, git_log_args)
|
|
341
|
+
try:
|
|
342
|
+
proc = subprocess.Popen(
|
|
343
|
+
argv,
|
|
344
|
+
cwd=root,
|
|
345
|
+
stdout=subprocess.PIPE,
|
|
346
|
+
stderr=subprocess.PIPE,
|
|
347
|
+
)
|
|
348
|
+
except FileNotFoundError as exc:
|
|
349
|
+
raise GitError("git executable not found on PATH") from exc
|
|
350
|
+
except NotADirectoryError as exc:
|
|
351
|
+
raise GitError(f"not a directory: {cwd}") from exc
|
|
352
|
+
|
|
353
|
+
assert proc.stdout is not None
|
|
354
|
+
stderr = b""
|
|
355
|
+
code = 1
|
|
356
|
+
try:
|
|
357
|
+
yield from iter_parse_git_log_stream(proc.stdout)
|
|
358
|
+
stderr = proc.stderr.read() if proc.stderr is not None else b""
|
|
359
|
+
code = proc.wait()
|
|
360
|
+
finally:
|
|
361
|
+
if proc.poll() is None:
|
|
362
|
+
proc.kill()
|
|
363
|
+
proc.wait()
|
|
364
|
+
|
|
365
|
+
if code != 0:
|
|
366
|
+
detail = stderr.decode("utf-8", errors="replace").strip()
|
|
367
|
+
raise GitError(
|
|
368
|
+
detail or "git log failed",
|
|
369
|
+
stderr=detail,
|
|
370
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Identifier family matchers for Refs tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# Jira key and the same shape used by YouTrack / Linear.
|
|
8
|
+
_JIRA_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_]*-\d+$")
|
|
9
|
+
|
|
10
|
+
# Shapes from references.md that any-id accepts beyond the Jira key.
|
|
11
|
+
_ANY_EXTRA = (
|
|
12
|
+
re.compile(r"^#\d+$"),
|
|
13
|
+
re.compile(r"^GH-\d+$", re.IGNORECASE),
|
|
14
|
+
re.compile(r"^GL-\d+$", re.IGNORECASE),
|
|
15
|
+
re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#\d+$"),
|
|
16
|
+
re.compile(r"^AB#\d+$", re.IGNORECASE),
|
|
17
|
+
re.compile(r"^T\d+$"),
|
|
18
|
+
re.compile(r"^Bug\s+#?\d+$", re.IGNORECASE),
|
|
19
|
+
re.compile(r"^issue\s+#\d+$", re.IGNORECASE),
|
|
20
|
+
re.compile(r"^&\d+$"),
|
|
21
|
+
re.compile(r"^!\d+$"),
|
|
22
|
+
re.compile(r"^\[[a-z_]+:[^\]]+\]$", re.IGNORECASE),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_jira_id(token: str) -> bool:
|
|
27
|
+
return _JIRA_KEY.fullmatch(token) is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_any_id(token: str) -> bool:
|
|
31
|
+
if is_jira_id(token):
|
|
32
|
+
return True
|
|
33
|
+
return any(pattern.fullmatch(token) for pattern in _ANY_EXTRA)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def matches_family(token: str, family: str) -> bool:
|
|
37
|
+
if family == "jira-id":
|
|
38
|
+
return is_jira_id(token)
|
|
39
|
+
if family == "any-id":
|
|
40
|
+
return is_any_id(token)
|
|
41
|
+
raise ValueError(f"unknown identifier family: {family!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ref_identity_key(token: str) -> str:
|
|
45
|
+
"""Letter case does not make a different id."""
|
|
46
|
+
return token.casefold()
|