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.
@@ -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()