ai-push-hooks 0.3.1 → 0.3.2

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 (35) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +91 -80
  3. package/ai-push-hooks.toml +1 -1
  4. package/docs/configuration.md +263 -0
  5. package/package.json +3 -3
  6. package/pyproject.toml +12 -3
  7. package/src/ai_push_hooks/artifacts.py +19 -6
  8. package/src/ai_push_hooks/cli.py +24 -8
  9. package/src/ai_push_hooks/config.py +220 -49
  10. package/src/ai_push_hooks/engine.py +93 -43
  11. package/src/ai_push_hooks/executors/apply.py +121 -43
  12. package/src/ai_push_hooks/executors/ask.py +30 -13
  13. package/src/ai_push_hooks/executors/exec.py +61 -22
  14. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  15. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  16. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  17. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  18. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  19. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  20. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  21. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  22. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  23. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  24. package/src/ai_push_hooks/git_utils.py +92 -27
  25. package/src/ai_push_hooks/hook.py +48 -12
  26. package/src/ai_push_hooks/install.py +40 -18
  27. package/src/ai_push_hooks/modules/beads.py +18 -7
  28. package/src/ai_push_hooks/modules/docs.py +17 -7
  29. package/src/ai_push_hooks/modules/pr.py +18 -7
  30. package/src/ai_push_hooks/paths.py +6 -2
  31. package/src/ai_push_hooks/plugin_loader.py +79 -26
  32. package/src/ai_push_hooks/plugins.py +3 -1
  33. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  34. package/src/ai_push_hooks/types.py +47 -27
  35. package/run.sh +0 -29
@@ -79,7 +79,10 @@ class StepCommandResult:
79
79
  raise TypeError("step command returncode must be an integer")
80
80
  if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes):
81
81
  raise TypeError("step command streams must be bytes")
82
- if type(self.stdout_truncated) is not bool or type(self.stderr_truncated) is not bool:
82
+ if (
83
+ type(self.stdout_truncated) is not bool
84
+ or type(self.stderr_truncated) is not bool
85
+ ):
83
86
  raise TypeError("step command truncation flags must be booleans")
84
87
 
85
88
  def __repr__(self) -> str:
@@ -118,18 +121,24 @@ def _validated_inputs(
118
121
  if inputs is None:
119
122
  return {}
120
123
  if not isinstance(inputs, Mapping):
121
- raise StepCommandError("step command inputs must be a logical-reference mapping")
124
+ raise StepCommandError(
125
+ "step command inputs must be a logical-reference mapping"
126
+ )
122
127
  normalized: dict[str, pathlib.Path] = {}
123
128
  for logical_ref, path in inputs.items():
124
129
  if not isinstance(logical_ref, str) or not logical_ref:
125
- raise StepCommandError("step command input references must be non-empty strings")
130
+ raise StepCommandError(
131
+ "step command input references must be non-empty strings"
132
+ )
126
133
  if not isinstance(path, pathlib.Path):
127
134
  path = pathlib.Path(path)
128
135
  try:
129
136
  resolved = path.resolve(strict=True)
130
137
  metadata = resolved.stat()
131
138
  except (OSError, RuntimeError) as exc:
132
- raise StepCommandError("step command input artifact could not be opened") from exc
139
+ raise StepCommandError(
140
+ "step command input artifact could not be opened"
141
+ ) from exc
133
142
  if path_is_link_or_reparse(path) or not resolved.is_file() or not metadata:
134
143
  raise StepCommandError("step command input artifact must be a regular file")
135
144
  normalized[logical_ref] = resolved
@@ -160,7 +169,9 @@ def _substitute_argv(
160
169
  )
161
170
  rendered.append(str(inputs[logical_ref]))
162
171
  else:
163
- raise StepCommandError(f"unknown step command placeholder in argument {index}")
172
+ raise StepCommandError(
173
+ f"unknown step command placeholder in argument {index}"
174
+ )
164
175
  continue
165
176
 
166
177
  if _RECOGNIZED_TOKEN.search(argument):
@@ -183,12 +194,16 @@ def resolve_step_command_argv(
183
194
  try:
184
195
  root = pathlib.Path(repo_root).resolve(strict=True)
185
196
  except (OSError, RuntimeError) as exc:
186
- raise StepCommandError("step command repository root could not be resolved") from exc
197
+ raise StepCommandError(
198
+ "step command repository root could not be resolved"
199
+ ) from exc
187
200
  if not root.is_dir():
188
201
  raise StepCommandError("step command repository root must be a directory")
189
202
  executable = python_executable or sys.executable
190
203
  if not isinstance(executable, str) or not executable or "\x00" in executable:
191
- raise StepCommandError("step command Python interpreter must be a non-empty NUL-free string")
204
+ raise StepCommandError(
205
+ "step command Python interpreter must be a non-empty NUL-free string"
206
+ )
192
207
  return _substitute_argv(
193
208
  command,
194
209
  repo_root=root,
@@ -227,7 +242,9 @@ def _validate_utf8(result: StepCommandResult) -> None:
227
242
  try:
228
243
  stream.decode("utf-8")
229
244
  except UnicodeDecodeError as exc:
230
- error = StepCommandEncodingError(f"step command emitted invalid UTF-8 on {name}")
245
+ error = StepCommandEncodingError(
246
+ f"step command emitted invalid UTF-8 on {name}"
247
+ )
231
248
  error._step_command_result = result
232
249
  raise error from exc
233
250
 
@@ -250,17 +267,27 @@ def run_step_command(
250
267
  their private ``_process_result`` attribute for the persistence helper.
251
268
  """
252
269
 
253
- if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)):
270
+ if isinstance(timeout_seconds, bool) or not isinstance(
271
+ timeout_seconds, (int, float)
272
+ ):
254
273
  raise StepCommandError("step command timeout must be a finite positive number")
255
274
  if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
256
275
  raise StepCommandError("step command timeout must be a finite positive number")
257
- if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes < 0:
258
- raise StepCommandError("step command output bound must be a non-negative integer")
276
+ if (
277
+ isinstance(max_output_bytes, bool)
278
+ or not isinstance(max_output_bytes, int)
279
+ or max_output_bytes < 0
280
+ ):
281
+ raise StepCommandError(
282
+ "step command output bound must be a non-negative integer"
283
+ )
259
284
 
260
285
  input_paths = _validated_inputs(inputs)
261
286
  if stdin is not None:
262
287
  if not isinstance(stdin, str) or stdin not in input_paths:
263
- raise StepCommandError("step command stdin must exactly match a declared input")
288
+ raise StepCommandError(
289
+ "step command stdin must exactly match a declared input"
290
+ )
264
291
  input_path = input_paths[stdin]
265
292
  else:
266
293
  input_path = None
@@ -288,7 +315,9 @@ def run_step_command(
288
315
  )
289
316
  if truncated
290
317
  )
291
- error = StepCommandTruncatedError(f"step command {streams} exceeded its capture limit")
318
+ error = StepCommandTruncatedError(
319
+ f"step command {streams} exceeded its capture limit"
320
+ )
292
321
  error._step_command_result = result
293
322
  raise error
294
323
  _validate_utf8(result)
@@ -312,7 +341,10 @@ def _assert_message(result: StepCommandResult) -> str:
312
341
  stderr = result.stderr.decode("utf-8", errors="surrogateescape")
313
342
  combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
314
343
  safe = redact_diagnostic(combined, secrets=_environment_secrets())
315
- return bounded_diagnostic(safe, max_chars=1_200) or f"command exited with status {result.returncode}"
344
+ return (
345
+ bounded_diagnostic(safe, max_chars=1_200)
346
+ or f"command exited with status {result.returncode}"
347
+ )
316
348
 
317
349
 
318
350
  def step_command_result_payload(
@@ -397,7 +429,9 @@ def execute_step_command(
397
429
  """
398
430
 
399
431
  if step.type not in {"exec", "assert"}:
400
- raise StepCommandError("step command implementation requires an exec or assert step")
432
+ raise StepCommandError(
433
+ "step command implementation requires an exec or assert step"
434
+ )
401
435
  if isinstance(inputs, Mapping):
402
436
  input_map = dict(inputs)
403
437
  declared_references = set(step.inputs)
@@ -407,7 +441,9 @@ def execute_step_command(
407
441
  )
408
442
  else:
409
443
  if len(inputs) != len(step.inputs):
410
- raise StepCommandError("step command input paths do not match declared inputs")
444
+ raise StepCommandError(
445
+ "step command input paths do not match declared inputs"
446
+ )
411
447
  input_map = dict(zip(step.inputs, inputs))
412
448
  store = artifacts or ArtifactStore(context.run_dir)
413
449
  store.prepare()
@@ -444,9 +480,13 @@ def execute_step_command(
444
480
  raise
445
481
 
446
482
  payload = step_command_result_payload(process_result, step_type=step.type)
447
- persisted = _persist_process_result(store, state, step, process_result, payload=payload)
483
+ persisted = _persist_process_result(
484
+ store, state, step, process_result, payload=payload
485
+ )
448
486
  if step.type == "exec" and process_result.returncode != 0:
449
- error = StepCommandExecutionError("step exec command returned a non-zero status")
487
+ error = StepCommandExecutionError(
488
+ "step exec command returned a non-zero status"
489
+ )
450
490
  error._step_command_result = process_result
451
491
  error._step_command_persisted = persisted
452
492
  raise error
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import codecs
3
4
  import fnmatch
4
5
  import json
5
6
  import os
@@ -82,9 +83,7 @@ def run_command(
82
83
  else:
83
84
  merged_env[key] = value
84
85
 
85
- effective_timeout = (
86
- COMMAND_DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout
87
- )
86
+ effective_timeout = COMMAND_DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout
88
87
  result = _run_bounded_text_command(
89
88
  args,
90
89
  cwd,
@@ -199,7 +198,14 @@ def _command_diagnostics(
199
198
  if input_text:
200
199
  secret_values.append(input_text)
201
200
  if env is not None:
202
- secret_markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
201
+ secret_markers = (
202
+ "API_KEY",
203
+ "TOKEN",
204
+ "SECRET",
205
+ "PASSWORD",
206
+ "AUTH",
207
+ "CREDENTIAL",
208
+ )
203
209
  secret_values.extend(
204
210
  value
205
211
  for name, value in env.items()
@@ -233,7 +239,9 @@ def resolve_git_common_dir(repo_root: pathlib.Path) -> pathlib.Path:
233
239
  return (repo_root / path).resolve()
234
240
 
235
241
 
236
- def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str) -> pathlib.Path:
242
+ def resolve_storage_path(
243
+ repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str
244
+ ) -> pathlib.Path:
237
245
  parts = relative_path_parts(raw, "Configured storage path")
238
246
  posix_raw = raw.replace("\\", "/")
239
247
  if parts[0] == ".git":
@@ -241,7 +249,9 @@ def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: st
241
249
  return pathlib.Path(git_dir).resolve(strict=False)
242
250
  lexical_path = pathlib.Path(git_dir).joinpath(*parts[1:])
243
251
  if path_has_symlink(pathlib.Path(git_dir), lexical_path):
244
- raise HookError(f"Configured Git storage path must not traverse a symlink: {raw}")
252
+ raise HookError(
253
+ f"Configured Git storage path must not traverse a symlink: {raw}"
254
+ )
245
255
  return resolve_contained_path(
246
256
  git_dir,
247
257
  "/".join(parts[1:]),
@@ -249,8 +259,12 @@ def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: st
249
259
  )
250
260
  lexical_path = repo_root.joinpath(*parts)
251
261
  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")
262
+ raise HookError(
263
+ f"Configured repository storage path must not traverse a symlink: {raw}"
264
+ )
265
+ return resolve_contained_path(
266
+ repo_root, posix_raw, "Configured repository storage path"
267
+ )
254
268
 
255
269
 
256
270
  def ensure_dir(path: pathlib.Path) -> pathlib.Path | None:
@@ -322,9 +336,13 @@ def path_matches(path: str, pattern: str) -> bool:
322
336
  path_index < len(path_parts) and matches(path_index + 1, pattern_index)
323
337
  )
324
338
  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)
339
+ result = (
340
+ path_index < len(path_parts)
341
+ and fnmatch.fnmatchcase(
342
+ path_parts[path_index], pattern_parts[pattern_index]
343
+ )
344
+ and matches(path_index + 1, pattern_index + 1)
345
+ )
328
346
  memo[key] = result
329
347
  return result
330
348
 
@@ -350,7 +368,9 @@ def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
350
368
  changes.add(record[3:])
351
369
  if "R" in status or "C" in status:
352
370
  if index >= len(records) or not records[index]:
353
- raise HookError("Malformed rename output from `git status --porcelain=v1 -z`")
371
+ raise HookError(
372
+ "Malformed rename output from `git status --porcelain=v1 -z`"
373
+ )
354
374
  changes.add(records[index])
355
375
  index += 1
356
376
  return changes
@@ -389,7 +409,11 @@ def parse_push_updates(stdin_lines: list[str]) -> list[PushRefUpdate]:
389
409
 
390
410
 
391
411
  def _resolve_commit(repo_root: pathlib.Path, oid: str) -> str:
392
- return git(repo_root, ["rev-parse", "--verify", "--quiet", f"{oid}^{{commit}}"], check=False)
412
+ return git(
413
+ repo_root,
414
+ ["rev-parse", "--verify", "--quiet", f"{oid}^{{commit}}"],
415
+ check=False,
416
+ )
393
417
 
394
418
 
395
419
  def _configured_base_commit(
@@ -428,10 +452,29 @@ def _fallback_range(
428
452
  local_commit: str,
429
453
  *,
430
454
  reason: str,
455
+ pushed_ref: str,
431
456
  ) -> tuple[str, str]:
457
+ configured_base = base_branch.strip() or "main"
458
+ configured_base_ref = ""
459
+ if configured_base.startswith("refs/heads/"):
460
+ configured_base_ref = configured_base
461
+ elif configured_base.startswith("refs/remotes/"):
462
+ remote_base = configured_base.removeprefix("refs/remotes/").split("/", 1)
463
+ if len(remote_base) == 2 and all(remote_base):
464
+ configured_base_ref = f"refs/heads/{remote_base[1]}"
465
+ elif not configured_base.startswith("refs/"):
466
+ configured_base_ref = f"refs/heads/{configured_base}"
467
+
468
+ # A zero advertised OID means the remote target is absent; a local or stale
469
+ # tracking base cannot describe this first publication.
470
+ if pushed_ref == configured_base_ref:
471
+ return f"{_empty_tree_oid(repo_root)}..{local_commit}", f"{reason}:empty-tree"
472
+
432
473
  base_commit = _configured_base_commit(repo_root, remote_name, base_branch)
433
474
  if base_commit:
434
- merge_base = git(repo_root, ["merge-base", local_commit, base_commit], check=False)
475
+ merge_base = git(
476
+ repo_root, ["merge-base", local_commit, base_commit], check=False
477
+ )
435
478
  if merge_base:
436
479
  return f"{merge_base}..{local_commit}", f"{reason}:configured-base"
437
480
  return f"{_empty_tree_oid(repo_root)}..{local_commit}", f"{reason}:empty-tree"
@@ -469,6 +512,7 @@ def collect_revision_ranges(
469
512
  base_branch,
470
513
  local_commit,
471
514
  reason="new-ref",
515
+ pushed_ref=update.remote_ref,
472
516
  )
473
517
  ranges.append(
474
518
  PushRevisionRange(update=update, expression=expression, strategy=strategy)
@@ -537,7 +581,11 @@ def _collect_bounded_git_diff(
537
581
  process_result.stdout if process_result else "",
538
582
  process_result.stderr if process_result else "",
539
583
  )
540
- reason = "timed out" if isinstance(exc, RunnerTimeoutError) else "terminated by signal"
584
+ reason = (
585
+ "timed out"
586
+ if isinstance(exc, RunnerTimeoutError)
587
+ else "terminated by signal"
588
+ )
541
589
  suffix = f": {details}" if details else ""
542
590
  raise HookError(f"Git diff command {reason}{suffix}") from exc
543
591
  except RunnerError as exc:
@@ -553,17 +601,26 @@ def _collect_bounded_git_diff(
553
601
 
554
602
 
555
603
  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
604
  limit = max(0, max_bytes)
559
605
  if limit == 0:
560
606
  return ""
607
+
608
+ # Diff artifacts are written as strict UTF-8. Git normally emits UTF-8,
609
+ # but malformed bytes are represented explicitly as U+FFFD rather than
610
+ # leaking surrogateescape code points into the artifact writer. A decoder
611
+ # left non-final for a source-truncated stream drops only its incomplete
612
+ # pending character; a final decoder represents malformed EOF bytes.
613
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
614
+ normalized = decoder.decode(output, final=not truncated)
615
+ normalized_bytes = normalized.encode("utf-8")
561
616
  marker = DIFF_TRUNCATION_MARKER.encode("utf-8")
617
+ if not truncated and len(normalized_bytes) <= limit:
618
+ return normalized
562
619
  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
- )
620
+ return marker[:limit].decode("ascii")
621
+ payload_limit = limit - len(marker)
622
+ payload = normalized_bytes[:payload_limit].decode("utf-8", errors="ignore")
623
+ return payload + DIFF_TRUNCATION_MARKER
567
624
 
568
625
 
569
626
  def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) -> str:
@@ -688,7 +745,9 @@ def _github_repository_from_url(remote_url: str) -> str:
688
745
  value = remote_url.strip()
689
746
  if not value or "\x00" in value or any(ord(character) < 32 for character in value):
690
747
  return ""
691
- scp_match = re.fullmatch(r"(?:[^@/:\s]+@)?github\.com:([^/\s]+)/([^/\s]+)", value, re.IGNORECASE)
748
+ scp_match = re.fullmatch(
749
+ r"(?:[^@/:\s]+@)?github\.com:([^/\s]+)/([^/\s]+)", value, re.IGNORECASE
750
+ )
692
751
  if scp_match:
693
752
  owner, repository = scp_match.groups()
694
753
  else:
@@ -729,17 +788,21 @@ def resolve_github_repository(
729
788
  if repository:
730
789
  return repository
731
790
  if remote_url.strip():
732
- raise HookError(f"Cannot safely determine GitHub repository from push remote URL: {remote_url!r}")
791
+ raise HookError(
792
+ "Cannot safely determine GitHub repository from push remote URL"
793
+ )
733
794
  repository = _github_repository_from_url(remote_name)
734
795
  if repository:
735
796
  return repository
736
797
  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)
798
+ raise HookError("Cannot safely resolve push remote name")
799
+ configured_url = git(
800
+ repo_root, ["remote", "get-url", "--push", remote_name], check=False
801
+ )
739
802
  repository = _github_repository_from_url(configured_url)
740
803
  if not repository:
741
804
  raise HookError(
742
- f"Cannot safely determine GitHub repository for push remote {remote_name!r}"
805
+ "Cannot safely determine GitHub repository for the configured push remote"
743
806
  )
744
807
  return repository
745
808
 
@@ -775,7 +838,9 @@ def lookup_open_pr_url(
775
838
  check=False,
776
839
  )
777
840
  if completed.returncode != 0:
778
- details = _command_diagnostics(args, completed.stdout or "", completed.stderr or "")
841
+ details = _command_diagnostics(
842
+ args, completed.stdout or "", completed.stderr or ""
843
+ )
779
844
  raise HookError(details or "`gh pr list` failed")
780
845
  try:
781
846
  payload = json.loads((completed.stdout or "").strip() or "[]")
@@ -9,7 +9,11 @@ from typing import Sequence
9
9
  from .artifacts import ArtifactStore, generate_run_id
10
10
  from .config import load_config
11
11
  from .engine import WorkflowEngine
12
- from .paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
12
+ from .paths import (
13
+ ensure_private_directory,
14
+ resolve_contained_path,
15
+ write_text_no_follow,
16
+ )
13
17
  from .git_utils import (
14
18
  collect_changed_files,
15
19
  collect_diff,
@@ -28,11 +32,15 @@ from .git_utils import (
28
32
  from .types import HookConfig, HookError, HookLogger, RuntimeContext
29
33
 
30
34
 
31
- def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig) -> HookLogger:
35
+ def _build_logger(
36
+ repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig
37
+ ) -> HookLogger:
32
38
  ensure_private_directory(git_dir / "ai-push-hooks")
33
39
  jsonl_path = None
34
40
  if config.logging.jsonl:
35
- log_dir = ensure_dir(resolve_storage_path(repo_root, git_dir, config.logging.dir))
41
+ log_dir = ensure_dir(
42
+ resolve_storage_path(repo_root, git_dir, config.logging.dir)
43
+ )
36
44
  if log_dir is not None:
37
45
  jsonl_path = resolve_contained_path(log_dir, "hook.jsonl", "JSONL log path")
38
46
  return HookLogger(jsonl_path=jsonl_path, console_level=config.logging.level)
@@ -40,7 +48,9 @@ def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookCo
40
48
 
41
49
  def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
42
50
  summary_dir = ensure_dir(
43
- resolve_storage_path(context.repo_root, context.git_dir, context.config.logging.summary_dir)
51
+ resolve_storage_path(
52
+ context.repo_root, context.git_dir, context.config.logging.summary_dir
53
+ )
44
54
  )
45
55
  if summary_dir is None:
46
56
  return
@@ -49,7 +59,9 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
49
59
  f"{context.run_id}.json",
50
60
  "Summary output path",
51
61
  )
52
- write_text_no_follow(summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n")
62
+ write_text_no_follow(
63
+ summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n"
64
+ )
53
65
 
54
66
 
55
67
  def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
@@ -77,7 +89,11 @@ def _run_hook_impl(
77
89
  if config.general.require_clean_worktree:
78
90
  _assert_clean_worktree(repo_root)
79
91
 
80
- actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
92
+ actual_stdin = (
93
+ list(stdin_lines)
94
+ if stdin_lines is not None
95
+ else [line.rstrip("\n") for line in sys.stdin]
96
+ )
81
97
  push_updates = parse_push_updates(actual_stdin)
82
98
  pushed_branch_updates = [
83
99
  update
@@ -92,7 +108,9 @@ def _run_hook_impl(
92
108
  )
93
109
  pushed_branches = list(
94
110
  dict.fromkeys(
95
- update.branch_name for update in pushed_branch_updates if update.branch_name is not None
111
+ update.branch_name
112
+ for update in pushed_branch_updates
113
+ if update.branch_name is not None
96
114
  )
97
115
  )
98
116
  if config.general.skip_on_sync_branch:
@@ -108,7 +126,9 @@ def _run_hook_impl(
108
126
  )
109
127
  ranges = unique_range_expressions(revision_ranges)
110
128
  changed_files = collect_changed_files(repo_root, ranges) if ranges else []
111
- diff_text = collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
129
+ diff_text = (
130
+ collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
131
+ )
112
132
  if len(pushed_branches) == 1:
113
133
  branch_name = pushed_branches[0]
114
134
  branch_selection_reason = "single pushed branch"
@@ -141,7 +161,9 @@ def _run_hook_impl(
141
161
  branch_diff_text = ""
142
162
  branch_is_new = False
143
163
  run_id = generate_run_id()
144
- run_dir = resolve_storage_path(repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}")
164
+ run_dir = resolve_storage_path(
165
+ repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}"
166
+ )
145
167
 
146
168
  context = RuntimeContext(
147
169
  repo_root=repo_root,
@@ -198,14 +220,28 @@ def _run_hook_impl(
198
220
  try:
199
221
  workflow_result = engine.run()
200
222
  logger.llm_summary()
201
- _write_summary(context, {"run_dir": str(workflow_result.run_dir), "modules": workflow_result.modules})
202
- logger.status("hook.complete", "AI push hooks workflow completed", run_dir=str(workflow_result.run_dir))
223
+ _write_summary(
224
+ context,
225
+ {
226
+ "run_dir": str(workflow_result.run_dir),
227
+ "modules": workflow_result.modules,
228
+ },
229
+ )
230
+ logger.status(
231
+ "hook.complete",
232
+ "AI push hooks workflow completed",
233
+ run_dir=str(workflow_result.run_dir),
234
+ )
203
235
  return 0
204
236
  except Exception as exc: # noqa: BLE001
205
237
  message = str(exc).strip() or exc.__class__.__name__
206
238
  logger.error("hook.failed", "AI push hooks workflow failed", error=message)
207
239
  if config.general.allow_push_on_error:
208
- logger.warn("hook.fail_open", "Allowing push because allow_push_on_error=true", error=message)
240
+ logger.warn(
241
+ "hook.fail_open",
242
+ "Allowing push because allow_push_on_error=true",
243
+ error=message,
244
+ )
209
245
  return 0
210
246
  raise
211
247
 
@@ -61,11 +61,7 @@ def pre_push_hook_script(delegate: tuple[str, ...] | None = None) -> str:
61
61
  )
62
62
  else:
63
63
  availability_check = ""
64
- return (
65
- "#!/bin/sh\n"
66
- + availability_check
67
- + f'exec {command} hook "$@"\n'
68
- )
64
+ return "#!/bin/sh\n" + availability_check + f'exec {command} hook "$@"\n'
69
65
 
70
66
 
71
67
  def _git_value(cwd: pathlib.Path, *args: str) -> str:
@@ -81,7 +77,9 @@ def _git_value(cwd: pathlib.Path, *args: str) -> str:
81
77
  except FileNotFoundError as exc:
82
78
  raise HookError("Git is required for `ai-push-hooks install`") from exc
83
79
  except subprocess.TimeoutExpired as exc:
84
- raise HookError(f"Git command timed out while resolving hook location: {' '.join(args)}") from exc
80
+ raise HookError(
81
+ f"Git command timed out while resolving hook location: {' '.join(args)}"
82
+ ) from exc
85
83
  except subprocess.CalledProcessError as exc:
86
84
  detail = (exc.stderr or exc.stdout or "not a Git repository").strip()
87
85
  raise HookError(f"Could not resolve Git hook location: {detail}") from exc
@@ -93,11 +91,15 @@ def _resolve_git_namespace(repo_root: pathlib.Path, value: str) -> pathlib.Path:
93
91
  return (repo_root / path).resolve() if not path.is_absolute() else path.resolve()
94
92
 
95
93
 
96
- def _path_is_in_namespace(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> bool:
94
+ def _path_is_in_namespace(
95
+ path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
96
+ ) -> bool:
97
97
  return any(is_path_within(path, namespace) for namespace in namespaces)
98
98
 
99
99
 
100
- def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> None:
100
+ def _validate_parent_chain(
101
+ path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
102
+ ) -> None:
101
103
  """Reject symlink/reparse parents and create only missing safe directories."""
102
104
  parent = path.parent
103
105
  existing: list[pathlib.Path] = []
@@ -129,25 +131,41 @@ def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, .
129
131
  current = current.parent
130
132
 
131
133
 
132
- def _effective_hook_path(current_dir: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
133
- repo_root = pathlib.Path(_git_value(current_dir, "rev-parse", "--show-toplevel")).resolve()
134
- git_dir = _resolve_git_namespace(repo_root, _git_value(repo_root, "rev-parse", "--git-dir"))
134
+ def _effective_hook_path(
135
+ current_dir: pathlib.Path,
136
+ ) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
137
+ repo_root = pathlib.Path(
138
+ _git_value(current_dir, "rev-parse", "--show-toplevel")
139
+ ).resolve()
140
+ git_dir = _resolve_git_namespace(
141
+ repo_root, _git_value(repo_root, "rev-parse", "--git-dir")
142
+ )
135
143
  common_dir = _resolve_git_namespace(
136
144
  repo_root, _git_value(repo_root, "rev-parse", "--git-common-dir")
137
145
  )
138
- raw_hooks_dir = pathlib.Path(_git_value(current_dir, "rev-parse", "--git-path", "hooks"))
146
+ raw_hooks_dir = pathlib.Path(
147
+ _git_value(current_dir, "rev-parse", "--git-path", "hooks")
148
+ )
139
149
  lexical_hooks_dir = (
140
150
  raw_hooks_dir if raw_hooks_dir.is_absolute() else current_dir / raw_hooks_dir
141
151
  )
142
152
  if path_is_link_or_reparse(current_dir) or not current_dir.is_dir():
143
- raise HookError(f"Refusing to install from an unsafe working directory: {current_dir}")
153
+ raise HookError(
154
+ f"Refusing to install from an unsafe working directory: {current_dir}"
155
+ )
144
156
  if any(path_is_link_or_reparse(part) for part in lexical_hooks_dir.parents):
145
- raise HookError(f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}")
157
+ raise HookError(
158
+ f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
159
+ )
146
160
  if path_is_link_or_reparse(lexical_hooks_dir):
147
- raise HookError(f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}")
161
+ raise HookError(
162
+ f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
163
+ )
148
164
  lexical_hook_path = lexical_hooks_dir / "pre-push"
149
165
  if path_is_link_or_reparse(lexical_hook_path):
150
- raise HookError(f"Refusing symlink or reparse-point hook target: {lexical_hook_path}")
166
+ raise HookError(
167
+ f"Refusing symlink or reparse-point hook target: {lexical_hook_path}"
168
+ )
151
169
  hook_path = lexical_hook_path.resolve(strict=False)
152
170
 
153
171
  namespaces = (repo_root, git_dir)
@@ -179,11 +197,15 @@ def install_hook(force: bool, cwd: pathlib.Path | None = None) -> int:
179
197
  except FileNotFoundError:
180
198
  metadata = None
181
199
  except OSError as exc:
182
- raise HookError(f"Could not inspect pre-push hook path {hook_path}: {exc}") from exc
200
+ raise HookError(
201
+ f"Could not inspect pre-push hook path {hook_path}: {exc}"
202
+ ) from exc
183
203
 
184
204
  if metadata is not None:
185
205
  if path_is_link_or_reparse(hook_path):
186
- raise HookError(f"Refusing symlink or reparse-point hook target: {hook_path}")
206
+ raise HookError(
207
+ f"Refusing symlink or reparse-point hook target: {hook_path}"
208
+ )
187
209
  if not stat.S_ISREG(metadata.st_mode):
188
210
  raise HookError(f"Refusing non-regular hook target: {hook_path}")
189
211
  if not force: