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