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.
- package/CHANGELOG.md +49 -1
- package/README.md +91 -80
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +263 -0
- package/package.json +3 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -6
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +220 -49
- package/src/ai_push_hooks/engine.py +93 -43
- package/src/ai_push_hooks/executors/apply.py +121 -43
- package/src/ai_push_hooks/executors/ask.py +30 -13
- package/src/ai_push_hooks/executors/exec.py +61 -22
- package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
- package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
- package/src/ai_push_hooks/executors/runners/process.py +26 -7
- package/src/ai_push_hooks/executors/runners/registry.py +14 -5
- package/src/ai_push_hooks/executors/step_commands.py +58 -18
- package/src/ai_push_hooks/git_utils.py +92 -27
- package/src/ai_push_hooks/hook.py +48 -12
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +18 -7
- package/src/ai_push_hooks/modules/docs.py +17 -7
- package/src/ai_push_hooks/modules/pr.py +18 -7
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +79 -26
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +47 -27
- 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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
258
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 = (
|
|
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(
|
|
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(
|
|
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(
|
|
253
|
-
|
|
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 =
|
|
326
|
-
|
|
327
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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("
|
|
564
|
-
|
|
565
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
738
|
-
configured_url = git(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
202
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|