ai-push-hooks 0.3.0 → 0.3.1
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.
- package/CHANGELOG.md +30 -2
- package/README.md +72 -996
- package/SECURITY.md +21 -16
- package/bin/ai-push-hooks.js +6 -6
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +0 -13
- package/src/ai_push_hooks/config.py +19 -5
- package/src/ai_push_hooks/engine.py +2 -1
- package/src/ai_push_hooks/executors/apply.py +3 -3
- package/src/ai_push_hooks/executors/ask.py +0 -523
- package/src/ai_push_hooks/executors/exec.py +17 -801
- package/src/ai_push_hooks/executors/runner_workflow.py +4 -4
- package/src/ai_push_hooks/executors/runners/opencode.py +40 -11
- package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
- package/src/ai_push_hooks/executors/runners/process.py +72 -11
- package/src/ai_push_hooks/executors/runners/registry.py +17 -4
- package/src/ai_push_hooks/executors/step_commands.py +7 -2
- package/src/ai_push_hooks/git_utils.py +834 -0
- package/src/ai_push_hooks/hook.py +1 -1
- package/src/ai_push_hooks/modules/beads.py +1 -1
- package/src/ai_push_hooks/modules/docs.py +129 -89
- package/src/ai_push_hooks/modules/pr.py +1 -1
- package/src/ai_push_hooks/plugin_loader.py +106 -82
- package/src/ai_push_hooks/types.py +1 -0
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from .executors.runners.contracts import (
|
|
14
|
+
RunnerError,
|
|
15
|
+
RunnerExecutableNotFoundError,
|
|
16
|
+
RunnerSignalError,
|
|
17
|
+
RunnerTimeoutError,
|
|
18
|
+
bounded_redacted_diagnostics,
|
|
19
|
+
)
|
|
20
|
+
from .executors.runners.process import (
|
|
21
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
22
|
+
ProcessResult,
|
|
23
|
+
run_process,
|
|
24
|
+
)
|
|
25
|
+
from .paths import (
|
|
26
|
+
ensure_private_directory,
|
|
27
|
+
path_has_symlink,
|
|
28
|
+
path_is_link_or_reparse,
|
|
29
|
+
relative_path_parts,
|
|
30
|
+
resolve_contained_path,
|
|
31
|
+
write_text_no_follow,
|
|
32
|
+
)
|
|
33
|
+
from .types import (
|
|
34
|
+
FEATURE_BRANCH_PREFIXES,
|
|
35
|
+
ZERO_OID_LENGTHS,
|
|
36
|
+
HookError,
|
|
37
|
+
PushRefUpdate,
|
|
38
|
+
PushRevisionRange,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
GIT_ERROR_BYTES = 64 * 1024
|
|
42
|
+
DIFF_TRUNCATION_MARKER = "\n[diff truncated]\n"
|
|
43
|
+
COMMAND_DEFAULT_TIMEOUT_SECONDS = 120
|
|
44
|
+
GITHUB_REPOSITORY_COMPONENT = re.compile(r"[A-Za-z0-9_.-]+\Z")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def env_bool(name: str) -> bool | None:
|
|
48
|
+
value = os.getenv(name)
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
value = value.strip().lower()
|
|
52
|
+
if value in {"1", "true", "yes", "y", "on"}:
|
|
53
|
+
return True
|
|
54
|
+
if value in {"0", "false", "no", "n", "off"}:
|
|
55
|
+
return False
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_command(
|
|
60
|
+
args: list[str],
|
|
61
|
+
cwd: pathlib.Path,
|
|
62
|
+
input_text: str | None = None,
|
|
63
|
+
timeout: float | None = None,
|
|
64
|
+
check: bool = False,
|
|
65
|
+
env: dict[str, str | None] | None = None,
|
|
66
|
+
inherit_env: bool = True,
|
|
67
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
68
|
+
) -> subprocess.CompletedProcess[str]:
|
|
69
|
+
"""Run an argv command with bounded capture and fail-closed cleanup.
|
|
70
|
+
|
|
71
|
+
``timeout=None`` is retained for compatibility with existing internal
|
|
72
|
+
callers, but now means the finite command budget rather than no timeout.
|
|
73
|
+
Output remains text decoded with surrogateescape and non-zero results are
|
|
74
|
+
returned unless ``check`` is true. Capture overflow always fails closed.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
merged_env = os.environ.copy() if inherit_env else {}
|
|
78
|
+
if env is not None:
|
|
79
|
+
for key, value in env.items():
|
|
80
|
+
if value is None:
|
|
81
|
+
merged_env.pop(key, None)
|
|
82
|
+
else:
|
|
83
|
+
merged_env[key] = value
|
|
84
|
+
|
|
85
|
+
effective_timeout = (
|
|
86
|
+
COMMAND_DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout
|
|
87
|
+
)
|
|
88
|
+
result = _run_bounded_text_command(
|
|
89
|
+
args,
|
|
90
|
+
cwd,
|
|
91
|
+
input_text=input_text,
|
|
92
|
+
timeout_seconds=effective_timeout,
|
|
93
|
+
check=check,
|
|
94
|
+
env=merged_env,
|
|
95
|
+
max_bytes=max_output_bytes,
|
|
96
|
+
)
|
|
97
|
+
return subprocess.CompletedProcess(
|
|
98
|
+
list(args), result.returncode, result.stdout, result.stderr
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_bounded_text_command(
|
|
103
|
+
args: list[str],
|
|
104
|
+
cwd: pathlib.Path,
|
|
105
|
+
max_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
106
|
+
*,
|
|
107
|
+
input_text: str | None = None,
|
|
108
|
+
timeout_seconds: float = COMMAND_DEFAULT_TIMEOUT_SECONDS,
|
|
109
|
+
check: bool = False,
|
|
110
|
+
env: dict[str, str] | None = None,
|
|
111
|
+
max_stderr_bytes: int | None = None,
|
|
112
|
+
) -> ProcessResult:
|
|
113
|
+
"""Run a bounded text command and normalize process failures.
|
|
114
|
+
|
|
115
|
+
Most Git commands must not silently consume a partial result. Diff
|
|
116
|
+
collection has separate handling because it deliberately preserves a
|
|
117
|
+
partial result and adds its own marker.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
process_kwargs: dict[str, object] = {
|
|
122
|
+
"cwd": cwd,
|
|
123
|
+
"input_text": input_text,
|
|
124
|
+
"timeout_seconds": timeout_seconds,
|
|
125
|
+
"env": env,
|
|
126
|
+
"max_output_bytes": max_bytes,
|
|
127
|
+
}
|
|
128
|
+
if max_stderr_bytes is not None:
|
|
129
|
+
process_kwargs["max_stderr_bytes"] = max_stderr_bytes
|
|
130
|
+
result = run_process(args, **process_kwargs)
|
|
131
|
+
except RunnerExecutableNotFoundError as exc:
|
|
132
|
+
raise HookError("Command executable was not found") from exc
|
|
133
|
+
except RunnerSignalError as exc:
|
|
134
|
+
process_result = _process_result_from_error(exc)
|
|
135
|
+
if not check and process_result is not None:
|
|
136
|
+
return process_result
|
|
137
|
+
details = _command_diagnostics(
|
|
138
|
+
args,
|
|
139
|
+
process_result.stdout if process_result else "",
|
|
140
|
+
process_result.stderr if process_result else "",
|
|
141
|
+
input_text=input_text,
|
|
142
|
+
env=env,
|
|
143
|
+
)
|
|
144
|
+
suffix = f": {details}" if details else ""
|
|
145
|
+
raise HookError(f"Command terminated by signal{suffix}") from exc
|
|
146
|
+
except RunnerTimeoutError as exc:
|
|
147
|
+
process_result = _process_result_from_error(exc)
|
|
148
|
+
details = _command_diagnostics(
|
|
149
|
+
args,
|
|
150
|
+
process_result.stdout if process_result else "",
|
|
151
|
+
process_result.stderr if process_result else "",
|
|
152
|
+
input_text=input_text,
|
|
153
|
+
env=env,
|
|
154
|
+
)
|
|
155
|
+
suffix = f": {details}" if details else ""
|
|
156
|
+
raise HookError(f"Command timed out{suffix}") from exc
|
|
157
|
+
except RunnerError as exc:
|
|
158
|
+
raise HookError(str(exc)) from exc
|
|
159
|
+
|
|
160
|
+
if result.stdout_truncated or result.stderr_truncated:
|
|
161
|
+
details = _command_diagnostics(
|
|
162
|
+
args,
|
|
163
|
+
result.stdout,
|
|
164
|
+
result.stderr,
|
|
165
|
+
input_text=input_text,
|
|
166
|
+
env=env,
|
|
167
|
+
)
|
|
168
|
+
details = details or "capture limit exceeded"
|
|
169
|
+
raise HookError(f"Command output exceeded capture limit: {details}")
|
|
170
|
+
if check and result.returncode != 0:
|
|
171
|
+
details = _command_diagnostics(
|
|
172
|
+
args,
|
|
173
|
+
result.stdout,
|
|
174
|
+
result.stderr,
|
|
175
|
+
input_text=input_text,
|
|
176
|
+
env=env,
|
|
177
|
+
)
|
|
178
|
+
details = details or f"exit code {result.returncode}"
|
|
179
|
+
raise HookError(f"Command failed: {details}")
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _process_result_from_error(error: BaseException) -> ProcessResult | None:
|
|
184
|
+
result = getattr(error, "_process_result", None)
|
|
185
|
+
return result if isinstance(result, ProcessResult) else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _command_diagnostics(
|
|
189
|
+
args: list[str],
|
|
190
|
+
stdout: str,
|
|
191
|
+
stderr: str,
|
|
192
|
+
*,
|
|
193
|
+
input_text: str | None = None,
|
|
194
|
+
env: dict[str, str] | None = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Build a short diagnostic without echoing argv, prompts, or secrets."""
|
|
197
|
+
|
|
198
|
+
secret_values = [*args]
|
|
199
|
+
if input_text:
|
|
200
|
+
secret_values.append(input_text)
|
|
201
|
+
if env is not None:
|
|
202
|
+
secret_markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
|
|
203
|
+
secret_values.extend(
|
|
204
|
+
value
|
|
205
|
+
for name, value in env.items()
|
|
206
|
+
if any(marker in name.upper() for marker in secret_markers)
|
|
207
|
+
)
|
|
208
|
+
return bounded_redacted_diagnostics(stdout, stderr, secrets=secret_values)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def git(cwd: pathlib.Path, args: list[str], check: bool = True) -> str:
|
|
212
|
+
completed = run_command(["git", *args], cwd=cwd, check=check)
|
|
213
|
+
return completed.stdout.strip()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_repo_root(cwd: pathlib.Path) -> pathlib.Path:
|
|
217
|
+
return pathlib.Path(git(cwd, ["rev-parse", "--show-toplevel"])).resolve()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def resolve_git_dir(repo_root: pathlib.Path) -> pathlib.Path:
|
|
221
|
+
raw = git(repo_root, ["rev-parse", "--git-dir"])
|
|
222
|
+
path = pathlib.Path(raw)
|
|
223
|
+
if path.is_absolute():
|
|
224
|
+
return path
|
|
225
|
+
return (repo_root / path).resolve()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_git_common_dir(repo_root: pathlib.Path) -> pathlib.Path:
|
|
229
|
+
raw = git(repo_root, ["rev-parse", "--git-common-dir"])
|
|
230
|
+
path = pathlib.Path(raw)
|
|
231
|
+
if path.is_absolute():
|
|
232
|
+
return path.resolve()
|
|
233
|
+
return (repo_root / path).resolve()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str) -> pathlib.Path:
|
|
237
|
+
parts = relative_path_parts(raw, "Configured storage path")
|
|
238
|
+
posix_raw = raw.replace("\\", "/")
|
|
239
|
+
if parts[0] == ".git":
|
|
240
|
+
if len(parts) == 1:
|
|
241
|
+
return pathlib.Path(git_dir).resolve(strict=False)
|
|
242
|
+
lexical_path = pathlib.Path(git_dir).joinpath(*parts[1:])
|
|
243
|
+
if path_has_symlink(pathlib.Path(git_dir), lexical_path):
|
|
244
|
+
raise HookError(f"Configured Git storage path must not traverse a symlink: {raw}")
|
|
245
|
+
return resolve_contained_path(
|
|
246
|
+
git_dir,
|
|
247
|
+
"/".join(parts[1:]),
|
|
248
|
+
"Configured Git storage path",
|
|
249
|
+
)
|
|
250
|
+
lexical_path = repo_root.joinpath(*parts)
|
|
251
|
+
if path_has_symlink(repo_root, lexical_path):
|
|
252
|
+
raise HookError(f"Configured repository storage path must not traverse a symlink: {raw}")
|
|
253
|
+
return resolve_contained_path(repo_root, posix_raw, "Configured repository storage path")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def ensure_dir(path: pathlib.Path) -> pathlib.Path | None:
|
|
257
|
+
try:
|
|
258
|
+
return ensure_private_directory(path)
|
|
259
|
+
except Exception: # noqa: BLE001
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def current_branch(repo_root: pathlib.Path) -> str:
|
|
264
|
+
return git(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"], check=False).strip()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_feature_branch(branch_name: str) -> bool:
|
|
268
|
+
return bool(branch_name) and branch_name.startswith(FEATURE_BRANCH_PREFIXES)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def should_skip_for_sync_branch(
|
|
272
|
+
repo_root: pathlib.Path,
|
|
273
|
+
pushed_branches: list[str] | None = None,
|
|
274
|
+
push_updates: list[PushRefUpdate] | None = None,
|
|
275
|
+
) -> tuple[bool, str]:
|
|
276
|
+
sync_branch = os.getenv("BEADS_SYNC_BRANCH", "beads-sync")
|
|
277
|
+
if pushed_branches is None:
|
|
278
|
+
pushed_branches = [current_branch(repo_root)]
|
|
279
|
+
if push_updates is not None:
|
|
280
|
+
only_sync_branch_updates = bool(push_updates) and all(
|
|
281
|
+
update.ref_kind == "branch"
|
|
282
|
+
and update.operation != "delete"
|
|
283
|
+
and update.branch_name == sync_branch
|
|
284
|
+
for update in push_updates
|
|
285
|
+
)
|
|
286
|
+
if push_updates and not only_sync_branch_updates:
|
|
287
|
+
return False, ""
|
|
288
|
+
else:
|
|
289
|
+
only_sync_branch_updates = bool(pushed_branches) and all(
|
|
290
|
+
branch_name == sync_branch for branch_name in pushed_branches
|
|
291
|
+
)
|
|
292
|
+
if "/.beads-sync-worktrees/" in repo_root.as_posix():
|
|
293
|
+
return True, "worktree is inside .beads-sync-worktrees"
|
|
294
|
+
if only_sync_branch_updates:
|
|
295
|
+
return True, f"all pushed branches are {sync_branch}"
|
|
296
|
+
return False, ""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def path_matches(path: str, pattern: str) -> bool:
|
|
300
|
+
path_parts = tuple(path.split("/"))
|
|
301
|
+
if (
|
|
302
|
+
not path_parts
|
|
303
|
+
or path.startswith("/")
|
|
304
|
+
or any(part in {"", ".", ".."} for part in path_parts)
|
|
305
|
+
):
|
|
306
|
+
return False
|
|
307
|
+
try:
|
|
308
|
+
pattern_parts = relative_path_parts(pattern, "Glob pattern")
|
|
309
|
+
except HookError:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
memo: dict[tuple[int, int], bool] = {}
|
|
313
|
+
|
|
314
|
+
def matches(path_index: int, pattern_index: int) -> bool:
|
|
315
|
+
key = (path_index, pattern_index)
|
|
316
|
+
if key in memo:
|
|
317
|
+
return memo[key]
|
|
318
|
+
if pattern_index == len(pattern_parts):
|
|
319
|
+
result = path_index == len(path_parts)
|
|
320
|
+
elif pattern_parts[pattern_index] == "**":
|
|
321
|
+
result = matches(path_index, pattern_index + 1) or (
|
|
322
|
+
path_index < len(path_parts) and matches(path_index + 1, pattern_index)
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
result = path_index < len(path_parts) and fnmatch.fnmatchcase(
|
|
326
|
+
path_parts[path_index], pattern_parts[pattern_index]
|
|
327
|
+
) and matches(path_index + 1, pattern_index + 1)
|
|
328
|
+
memo[key] = result
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
return matches(0, 0)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
|
|
335
|
+
changes: set[str] = set()
|
|
336
|
+
output = run_command(
|
|
337
|
+
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
338
|
+
cwd=repo_root,
|
|
339
|
+
).stdout
|
|
340
|
+
records = output.split("\x00")
|
|
341
|
+
index = 0
|
|
342
|
+
while index < len(records):
|
|
343
|
+
record = records[index]
|
|
344
|
+
index += 1
|
|
345
|
+
if not record:
|
|
346
|
+
continue
|
|
347
|
+
if len(record) < 4 or record[2] != " ":
|
|
348
|
+
raise HookError("Malformed output from `git status --porcelain=v1 -z`")
|
|
349
|
+
status = record[:2]
|
|
350
|
+
changes.add(record[3:])
|
|
351
|
+
if "R" in status or "C" in status:
|
|
352
|
+
if index >= len(records) or not records[index]:
|
|
353
|
+
raise HookError("Malformed rename output from `git status --porcelain=v1 -z`")
|
|
354
|
+
changes.add(records[index])
|
|
355
|
+
index += 1
|
|
356
|
+
return changes
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def parse_push_updates(stdin_lines: list[str]) -> list[PushRefUpdate]:
|
|
360
|
+
updates: list[PushRefUpdate] = []
|
|
361
|
+
oid_pattern = re.compile(r"[0-9a-fA-F]+\Z")
|
|
362
|
+
for line_number, line in enumerate(stdin_lines, start=1):
|
|
363
|
+
if not line.strip():
|
|
364
|
+
continue
|
|
365
|
+
parts = line.split()
|
|
366
|
+
if len(parts) != 4:
|
|
367
|
+
raise HookError(
|
|
368
|
+
f"Malformed pre-push input on line {line_number}: expected four fields"
|
|
369
|
+
)
|
|
370
|
+
local_ref, local_sha, remote_ref, remote_sha = parts
|
|
371
|
+
if (
|
|
372
|
+
len(local_sha) not in ZERO_OID_LENGTHS
|
|
373
|
+
or len(remote_sha) != len(local_sha)
|
|
374
|
+
or oid_pattern.fullmatch(local_sha) is None
|
|
375
|
+
or oid_pattern.fullmatch(remote_sha) is None
|
|
376
|
+
):
|
|
377
|
+
raise HookError(
|
|
378
|
+
f"Malformed pre-push input on line {line_number}: expected full SHA-1 or SHA-256 object IDs"
|
|
379
|
+
)
|
|
380
|
+
updates.append(
|
|
381
|
+
PushRefUpdate(
|
|
382
|
+
local_ref=local_ref,
|
|
383
|
+
local_sha=local_sha.lower(),
|
|
384
|
+
remote_ref=remote_ref,
|
|
385
|
+
remote_sha=remote_sha.lower(),
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
return updates
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _resolve_commit(repo_root: pathlib.Path, oid: str) -> str:
|
|
392
|
+
return git(repo_root, ["rev-parse", "--verify", "--quiet", f"{oid}^{{commit}}"], check=False)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _configured_base_commit(
|
|
396
|
+
repo_root: pathlib.Path, remote_name: str, base_branch: str
|
|
397
|
+
) -> str:
|
|
398
|
+
base_branch = base_branch.strip() or "main"
|
|
399
|
+
candidates: list[str] = []
|
|
400
|
+
if base_branch.startswith("refs/"):
|
|
401
|
+
candidates.append(base_branch)
|
|
402
|
+
else:
|
|
403
|
+
configured_remotes = set(git(repo_root, ["remote"], check=False).splitlines())
|
|
404
|
+
if remote_name in configured_remotes:
|
|
405
|
+
candidates.append(f"refs/remotes/{remote_name}/{base_branch}")
|
|
406
|
+
candidates.append(f"refs/heads/{base_branch}")
|
|
407
|
+
for candidate in candidates:
|
|
408
|
+
commit = _resolve_commit(repo_root, candidate)
|
|
409
|
+
if commit:
|
|
410
|
+
return commit
|
|
411
|
+
return ""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _empty_tree_oid(repo_root: pathlib.Path) -> str:
|
|
415
|
+
completed = run_command(
|
|
416
|
+
["git", "hash-object", "-t", "tree", "--stdin"],
|
|
417
|
+
cwd=repo_root,
|
|
418
|
+
input_text="",
|
|
419
|
+
check=True,
|
|
420
|
+
)
|
|
421
|
+
return (completed.stdout or "").strip()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _fallback_range(
|
|
425
|
+
repo_root: pathlib.Path,
|
|
426
|
+
remote_name: str,
|
|
427
|
+
base_branch: str,
|
|
428
|
+
local_commit: str,
|
|
429
|
+
*,
|
|
430
|
+
reason: str,
|
|
431
|
+
) -> tuple[str, str]:
|
|
432
|
+
base_commit = _configured_base_commit(repo_root, remote_name, base_branch)
|
|
433
|
+
if base_commit:
|
|
434
|
+
merge_base = git(repo_root, ["merge-base", local_commit, base_commit], check=False)
|
|
435
|
+
if merge_base:
|
|
436
|
+
return f"{merge_base}..{local_commit}", f"{reason}:configured-base"
|
|
437
|
+
return f"{_empty_tree_oid(repo_root)}..{local_commit}", f"{reason}:empty-tree"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def collect_revision_ranges(
|
|
441
|
+
repo_root: pathlib.Path,
|
|
442
|
+
remote_name: str,
|
|
443
|
+
updates: list[PushRefUpdate],
|
|
444
|
+
base_branch: str = "main",
|
|
445
|
+
) -> list[PushRevisionRange]:
|
|
446
|
+
ranges: list[PushRevisionRange] = []
|
|
447
|
+
for update in updates:
|
|
448
|
+
if update.operation == "delete":
|
|
449
|
+
continue
|
|
450
|
+
local_commit = _resolve_commit(repo_root, update.local_sha)
|
|
451
|
+
if not local_commit:
|
|
452
|
+
# Tags may legally point to non-commit objects. They still remain in
|
|
453
|
+
# push_updates, but there is no commit/tree diff to collect for them.
|
|
454
|
+
continue
|
|
455
|
+
if update.operation == "update":
|
|
456
|
+
remote_commit = _resolve_commit(repo_root, update.remote_sha)
|
|
457
|
+
if remote_commit:
|
|
458
|
+
expression = f"{remote_commit}..{local_commit}"
|
|
459
|
+
strategy = "remote-object"
|
|
460
|
+
else:
|
|
461
|
+
raise HookError(
|
|
462
|
+
"Advertised remote commit is unavailable locally; refusing to "
|
|
463
|
+
f"approximate push range for {update.remote_ref}: {update.remote_sha}"
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
expression, strategy = _fallback_range(
|
|
467
|
+
repo_root,
|
|
468
|
+
remote_name,
|
|
469
|
+
base_branch,
|
|
470
|
+
local_commit,
|
|
471
|
+
reason="new-ref",
|
|
472
|
+
)
|
|
473
|
+
ranges.append(
|
|
474
|
+
PushRevisionRange(update=update, expression=expression, strategy=strategy)
|
|
475
|
+
)
|
|
476
|
+
return ranges
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def unique_range_expressions(ranges: list[PushRevisionRange]) -> list[str]:
|
|
480
|
+
return list(dict.fromkeys(item.expression for item in ranges))
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def collect_ranges_from_stdin(
|
|
484
|
+
repo_root: pathlib.Path,
|
|
485
|
+
remote_name: str,
|
|
486
|
+
stdin_lines: list[str],
|
|
487
|
+
base_branch: str = "main",
|
|
488
|
+
) -> list[str]:
|
|
489
|
+
updates = parse_push_updates(stdin_lines)
|
|
490
|
+
return unique_range_expressions(
|
|
491
|
+
collect_revision_ranges(repo_root, remote_name, updates, base_branch)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[str]:
|
|
496
|
+
files: set[str] = set()
|
|
497
|
+
for range_expr in ranges:
|
|
498
|
+
output = run_command(
|
|
499
|
+
[
|
|
500
|
+
"git",
|
|
501
|
+
"diff",
|
|
502
|
+
"--name-only",
|
|
503
|
+
"--diff-filter=ACMRD",
|
|
504
|
+
"-z",
|
|
505
|
+
range_expr,
|
|
506
|
+
],
|
|
507
|
+
cwd=repo_root,
|
|
508
|
+
check=True,
|
|
509
|
+
).stdout
|
|
510
|
+
for path in output.split("\x00"):
|
|
511
|
+
if path:
|
|
512
|
+
files.add(path)
|
|
513
|
+
return sorted(files)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _collect_bounded_git_diff(
|
|
517
|
+
repo_root: pathlib.Path, args: list[str], max_bytes: int
|
|
518
|
+
) -> tuple[bytes, bool]:
|
|
519
|
+
limit = max(0, max_bytes)
|
|
520
|
+
# Keep the diff's historical partial-capture behavior: a diff that reaches
|
|
521
|
+
# its caller-provided budget is returned with a marker rather than treated
|
|
522
|
+
# as a failed command. The generic process engine still owns timeout,
|
|
523
|
+
# bounded stream draining, and process-group cleanup. Keep stderr at its
|
|
524
|
+
# historical diagnostic bound even when the diff budget is much larger.
|
|
525
|
+
try:
|
|
526
|
+
result = run_process(
|
|
527
|
+
args,
|
|
528
|
+
cwd=repo_root,
|
|
529
|
+
timeout_seconds=COMMAND_DEFAULT_TIMEOUT_SECONDS,
|
|
530
|
+
max_output_bytes=limit,
|
|
531
|
+
max_stderr_bytes=GIT_ERROR_BYTES,
|
|
532
|
+
)
|
|
533
|
+
except (RunnerTimeoutError, RunnerSignalError) as exc:
|
|
534
|
+
process_result = _process_result_from_error(exc)
|
|
535
|
+
details = _command_diagnostics(
|
|
536
|
+
args,
|
|
537
|
+
process_result.stdout if process_result else "",
|
|
538
|
+
process_result.stderr if process_result else "",
|
|
539
|
+
)
|
|
540
|
+
reason = "timed out" if isinstance(exc, RunnerTimeoutError) else "terminated by signal"
|
|
541
|
+
suffix = f": {details}" if details else ""
|
|
542
|
+
raise HookError(f"Git diff command {reason}{suffix}") from exc
|
|
543
|
+
except RunnerError as exc:
|
|
544
|
+
raise HookError(str(exc)) from exc
|
|
545
|
+
|
|
546
|
+
output = result.stdout_bytes[:limit]
|
|
547
|
+
truncated = result.stdout_truncated
|
|
548
|
+
if result.returncode != 0 and not truncated:
|
|
549
|
+
details = _command_diagnostics(args, result.stdout, result.stderr)
|
|
550
|
+
details = details or f"exit code {result.returncode}"
|
|
551
|
+
raise HookError(f"Command failed: {details}")
|
|
552
|
+
return output, truncated
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _decode_diff_output(output: bytes, max_bytes: int, truncated: bool) -> str:
|
|
556
|
+
if not truncated:
|
|
557
|
+
return output.decode("utf-8", errors="surrogateescape")
|
|
558
|
+
limit = max(0, max_bytes)
|
|
559
|
+
if limit == 0:
|
|
560
|
+
return ""
|
|
561
|
+
marker = DIFF_TRUNCATION_MARKER.encode("utf-8")
|
|
562
|
+
if len(marker) >= limit:
|
|
563
|
+
return marker[:limit].decode("utf-8", errors="surrogateescape")
|
|
564
|
+
return (output[: limit - len(marker)] + marker).decode(
|
|
565
|
+
"utf-8", errors="surrogateescape"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) -> str:
|
|
570
|
+
output = bytearray()
|
|
571
|
+
limit = max(0, max_bytes)
|
|
572
|
+
truncated = False
|
|
573
|
+
for index, range_expr in enumerate(ranges):
|
|
574
|
+
prefix = ("\n" if index else "") + f"### RANGE {range_expr}\n"
|
|
575
|
+
prefix_bytes = prefix.encode("utf-8", errors="surrogateescape")
|
|
576
|
+
remaining = limit - len(output)
|
|
577
|
+
if len(prefix_bytes) > remaining:
|
|
578
|
+
output.extend(prefix_bytes[:remaining])
|
|
579
|
+
truncated = True
|
|
580
|
+
break
|
|
581
|
+
output.extend(prefix_bytes)
|
|
582
|
+
|
|
583
|
+
body, body_truncated = _collect_bounded_git_diff(
|
|
584
|
+
repo_root,
|
|
585
|
+
["git", "diff", "--unified=3", range_expr],
|
|
586
|
+
limit - len(output),
|
|
587
|
+
)
|
|
588
|
+
if not body_truncated:
|
|
589
|
+
# `git()` historically stripped the captured diff before adding the
|
|
590
|
+
# section's trailing newline. Keep that output shape when the body
|
|
591
|
+
# fits, without ever collecting more than the remaining budget.
|
|
592
|
+
body = body.rstrip()
|
|
593
|
+
output.extend(body)
|
|
594
|
+
if body_truncated:
|
|
595
|
+
truncated = True
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
if len(output) >= limit:
|
|
599
|
+
truncated = True
|
|
600
|
+
break
|
|
601
|
+
output.extend(b"\n")
|
|
602
|
+
return _decode_diff_output(bytes(output), limit, truncated)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def collect_commit_messages_for_ranges(
|
|
606
|
+
repo_root: pathlib.Path, ranges: list[str]
|
|
607
|
+
) -> list[dict[str, str]]:
|
|
608
|
+
commits: list[dict[str, str]] = []
|
|
609
|
+
seen_hashes: set[str] = set()
|
|
610
|
+
for range_expr in ranges:
|
|
611
|
+
completed = run_command(
|
|
612
|
+
["git", "log", "--format=%H%x1f%s%x1f%b%x1e", range_expr],
|
|
613
|
+
cwd=repo_root,
|
|
614
|
+
check=True,
|
|
615
|
+
)
|
|
616
|
+
raw = completed.stdout or ""
|
|
617
|
+
for record in raw.split("\x1e"):
|
|
618
|
+
payload = record.rstrip("\r\n")
|
|
619
|
+
if not payload:
|
|
620
|
+
continue
|
|
621
|
+
parts = payload.split("\x1f", 2)
|
|
622
|
+
if len(parts) == 2:
|
|
623
|
+
commit_hash, subject = parts
|
|
624
|
+
body = ""
|
|
625
|
+
elif len(parts) == 3:
|
|
626
|
+
commit_hash, subject, body = parts
|
|
627
|
+
else:
|
|
628
|
+
continue
|
|
629
|
+
clean_hash = commit_hash.strip()
|
|
630
|
+
if not clean_hash or clean_hash in seen_hashes:
|
|
631
|
+
continue
|
|
632
|
+
seen_hashes.add(clean_hash)
|
|
633
|
+
commits.append(
|
|
634
|
+
{
|
|
635
|
+
"hash": clean_hash,
|
|
636
|
+
"subject": subject.strip(),
|
|
637
|
+
"body": body.strip(),
|
|
638
|
+
}
|
|
639
|
+
)
|
|
640
|
+
return commits
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def write_text_file(
|
|
644
|
+
path: pathlib.Path,
|
|
645
|
+
content: str,
|
|
646
|
+
*,
|
|
647
|
+
root: pathlib.Path | None = None,
|
|
648
|
+
) -> bool:
|
|
649
|
+
try:
|
|
650
|
+
if root is None:
|
|
651
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
else:
|
|
653
|
+
root = root.resolve(strict=True)
|
|
654
|
+
lexical_path = pathlib.Path(os.path.abspath(path))
|
|
655
|
+
relative_parent = lexical_path.parent.relative_to(root)
|
|
656
|
+
current = root
|
|
657
|
+
for part in relative_parent.parts:
|
|
658
|
+
current = current / part
|
|
659
|
+
if path_is_link_or_reparse(current):
|
|
660
|
+
raise HookError(
|
|
661
|
+
f"Output path traverses a symlink or reparse point: {path}"
|
|
662
|
+
)
|
|
663
|
+
if not current.exists():
|
|
664
|
+
current.mkdir()
|
|
665
|
+
if not current.is_dir():
|
|
666
|
+
raise HookError(f"Output path has a non-directory parent: {path}")
|
|
667
|
+
if path_has_symlink(root, lexical_path):
|
|
668
|
+
raise HookError(f"Output path traverses a symlink: {path}")
|
|
669
|
+
if lexical_path.exists() and not stat.S_ISREG(lexical_path.lstat().st_mode):
|
|
670
|
+
raise HookError(f"Output path is not a regular file: {path}")
|
|
671
|
+
write_text_no_follow(path, content)
|
|
672
|
+
return True
|
|
673
|
+
except Exception: # noqa: BLE001
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def parse_key_value_text(text: str) -> dict[str, str]:
|
|
678
|
+
payload: dict[str, str] = {}
|
|
679
|
+
for line in text.splitlines():
|
|
680
|
+
if "=" not in line:
|
|
681
|
+
continue
|
|
682
|
+
key, value = line.split("=", 1)
|
|
683
|
+
payload[key.strip()] = value.strip()
|
|
684
|
+
return payload
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _github_repository_from_url(remote_url: str) -> str:
|
|
688
|
+
value = remote_url.strip()
|
|
689
|
+
if not value or "\x00" in value or any(ord(character) < 32 for character in value):
|
|
690
|
+
return ""
|
|
691
|
+
scp_match = re.fullmatch(r"(?:[^@/:\s]+@)?github\.com:([^/\s]+)/([^/\s]+)", value, re.IGNORECASE)
|
|
692
|
+
if scp_match:
|
|
693
|
+
owner, repository = scp_match.groups()
|
|
694
|
+
else:
|
|
695
|
+
try:
|
|
696
|
+
parsed = urlsplit(value)
|
|
697
|
+
except ValueError:
|
|
698
|
+
return ""
|
|
699
|
+
if (
|
|
700
|
+
parsed.scheme.lower() not in {"git", "http", "https", "ssh"}
|
|
701
|
+
or (parsed.hostname or "").casefold() != "github.com"
|
|
702
|
+
or parsed.query
|
|
703
|
+
or parsed.fragment
|
|
704
|
+
or "%" in parsed.path
|
|
705
|
+
):
|
|
706
|
+
return ""
|
|
707
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
708
|
+
if len(parts) != 2:
|
|
709
|
+
return ""
|
|
710
|
+
owner, repository = parts
|
|
711
|
+
if repository.endswith(".git"): # noqa: FURB188
|
|
712
|
+
repository = repository[:-4]
|
|
713
|
+
if (
|
|
714
|
+
not owner
|
|
715
|
+
or not repository
|
|
716
|
+
or owner in {".", ".."}
|
|
717
|
+
or repository in {".", ".."}
|
|
718
|
+
or GITHUB_REPOSITORY_COMPONENT.fullmatch(owner) is None
|
|
719
|
+
or GITHUB_REPOSITORY_COMPONENT.fullmatch(repository) is None
|
|
720
|
+
):
|
|
721
|
+
return ""
|
|
722
|
+
return f"{owner}/{repository}"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def resolve_github_repository(
|
|
726
|
+
repo_root: pathlib.Path, remote_name: str, remote_url: str
|
|
727
|
+
) -> str:
|
|
728
|
+
repository = _github_repository_from_url(remote_url)
|
|
729
|
+
if repository:
|
|
730
|
+
return repository
|
|
731
|
+
if remote_url.strip():
|
|
732
|
+
raise HookError(f"Cannot safely determine GitHub repository from push remote URL: {remote_url!r}")
|
|
733
|
+
repository = _github_repository_from_url(remote_name)
|
|
734
|
+
if repository:
|
|
735
|
+
return repository
|
|
736
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", remote_name):
|
|
737
|
+
raise HookError(f"Cannot safely resolve push remote name: {remote_name!r}")
|
|
738
|
+
configured_url = git(repo_root, ["remote", "get-url", "--push", remote_name], check=False)
|
|
739
|
+
repository = _github_repository_from_url(configured_url)
|
|
740
|
+
if not repository:
|
|
741
|
+
raise HookError(
|
|
742
|
+
f"Cannot safely determine GitHub repository for push remote {remote_name!r}"
|
|
743
|
+
)
|
|
744
|
+
return repository
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def lookup_open_pr_url(
|
|
748
|
+
repo_root: pathlib.Path,
|
|
749
|
+
branch_name: str,
|
|
750
|
+
base_branch: str = "",
|
|
751
|
+
repository: str = "",
|
|
752
|
+
) -> str:
|
|
753
|
+
if not repository:
|
|
754
|
+
raise HookError("GitHub repository scope is required for PR lookup")
|
|
755
|
+
args = [
|
|
756
|
+
"gh",
|
|
757
|
+
"pr",
|
|
758
|
+
"list",
|
|
759
|
+
"--repo",
|
|
760
|
+
repository,
|
|
761
|
+
"--head",
|
|
762
|
+
branch_name,
|
|
763
|
+
"--state",
|
|
764
|
+
"open",
|
|
765
|
+
"--limit",
|
|
766
|
+
"1",
|
|
767
|
+
"--json",
|
|
768
|
+
"url",
|
|
769
|
+
]
|
|
770
|
+
if base_branch:
|
|
771
|
+
args.extend(["--base", base_branch])
|
|
772
|
+
completed = run_command(
|
|
773
|
+
args,
|
|
774
|
+
cwd=repo_root,
|
|
775
|
+
check=False,
|
|
776
|
+
)
|
|
777
|
+
if completed.returncode != 0:
|
|
778
|
+
details = _command_diagnostics(args, completed.stdout or "", completed.stderr or "")
|
|
779
|
+
raise HookError(details or "`gh pr list` failed")
|
|
780
|
+
try:
|
|
781
|
+
payload = json.loads((completed.stdout or "").strip() or "[]")
|
|
782
|
+
except json.JSONDecodeError as exc:
|
|
783
|
+
raise HookError("Failed to parse `gh pr list` JSON output") from exc
|
|
784
|
+
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
|
|
785
|
+
return str(payload[0].get("url", "")).strip()
|
|
786
|
+
return ""
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def extract_pr_url(text: str) -> str:
|
|
790
|
+
match = re.search(r"https://github\.com/[^\s]+/pull/\d+", text)
|
|
791
|
+
return match.group(0).strip() if match else ""
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def sanitize_pr_title(raw_title: str, branch_name: str) -> str:
|
|
795
|
+
title = re.sub(r"\s+", " ", raw_title).strip() or branch_name
|
|
796
|
+
return title[:240]
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def initial_pr_defer_reason(branch_name: str, base_branch: str) -> str:
|
|
800
|
+
return (
|
|
801
|
+
f"PR creation deferred because `{branch_name}` does not exist on the remote before "
|
|
802
|
+
"this initial push. Complete the push, then create the PR with "
|
|
803
|
+
f"`gh pr create --head {shlex.quote(branch_name)} --base "
|
|
804
|
+
f"{shlex.quote(base_branch)}`, or push another commit with PR creation enabled."
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def build_fallback_pr_body(
|
|
809
|
+
branch_name: str,
|
|
810
|
+
ranges: list[str],
|
|
811
|
+
changed_files: list[str],
|
|
812
|
+
commits: list[dict[str, str]],
|
|
813
|
+
) -> str:
|
|
814
|
+
lines = [
|
|
815
|
+
"## Summary",
|
|
816
|
+
f"- Auto-created by `ai-push-hooks` for branch `{branch_name}`.",
|
|
817
|
+
]
|
|
818
|
+
if ranges:
|
|
819
|
+
lines.append(f"- Push range: `{', '.join(ranges)}`.")
|
|
820
|
+
if commits:
|
|
821
|
+
lines.append("")
|
|
822
|
+
lines.append("## Commits")
|
|
823
|
+
for commit in commits[:8]:
|
|
824
|
+
subject = str(commit.get("subject", "")).strip()
|
|
825
|
+
if subject:
|
|
826
|
+
lines.append(f"- {subject}")
|
|
827
|
+
if changed_files:
|
|
828
|
+
lines.append("")
|
|
829
|
+
lines.append("## Changed Files")
|
|
830
|
+
for path in changed_files[:15]:
|
|
831
|
+
lines.append(f"- `{path}`")
|
|
832
|
+
if len(changed_files) > 15:
|
|
833
|
+
lines.append(f"- and {len(changed_files) - 15} more")
|
|
834
|
+
return "\n".join(lines).strip() + "\n"
|