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
@@ -1,17 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pathlib
4
+ from collections.abc import Callable
4
5
  from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
5
- from typing import Any, Callable
6
+ from typing import Any
6
7
 
7
8
  from .artifacts import ArtifactStore
8
9
  from .config import resolve_prompt_text
9
10
  from .executors.apply import run_apply_step
11
+ from .executors.ask import run_ask_step
10
12
  from .executors.assertions import ASSERTION_HANDLERS
11
13
  from .executors.exec import EXEC_HANDLERS
12
- from .git_utils import env_bool
13
- from .executors.ask import run_ask_step
14
14
  from .executors.step_commands import execute_step_command
15
+ from .git_utils import env_bool
15
16
  from .modules import COLLECTORS
16
17
  from .plugin_loader import PluginDispatcher
17
18
  from .plugins import (
@@ -30,8 +31,12 @@ from .types import (
30
31
  )
31
32
 
32
33
  CollectorHandler = Callable[[RuntimeContext, ModuleRuntimeState], CollectorResult]
33
- ExecHandler = Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]]
34
- AssertionHandler = Callable[[RuntimeContext, StepConfig, list[pathlib.Path]], dict[str, Any]]
34
+ ExecHandler = Callable[
35
+ [RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]
36
+ ]
37
+ AssertionHandler = Callable[
38
+ [RuntimeContext, StepConfig, list[pathlib.Path]], dict[str, Any]
39
+ ]
35
40
 
36
41
 
37
42
  class WorkflowEngine:
@@ -42,8 +47,20 @@ class WorkflowEngine:
42
47
  collectors: dict[str, CollectorHandler] | None = None,
43
48
  exec_handlers: dict[str, ExecHandler] | None = None,
44
49
  assertion_handlers: dict[str, AssertionHandler] | None = None,
45
- ask_executor: Callable[[RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any] = run_ask_step,
46
- apply_executor: Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, str, list[pathlib.Path], str], dict[str, object]] = run_apply_step,
50
+ ask_executor: Callable[
51
+ [RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any
52
+ ] = run_ask_step,
53
+ apply_executor: Callable[
54
+ [
55
+ RuntimeContext,
56
+ ModuleRuntimeState,
57
+ StepConfig,
58
+ str,
59
+ list[pathlib.Path],
60
+ str,
61
+ ],
62
+ dict[str, object],
63
+ ] = run_apply_step,
47
64
  ) -> None:
48
65
  self.context = context
49
66
  self.artifacts = artifacts
@@ -68,7 +85,9 @@ class WorkflowEngine:
68
85
  statuses: dict[str, str] = {state.module.id: "pending" for state in states}
69
86
  futures: dict[Future[StepResult], tuple[ModuleRuntimeState, StepConfig]] = {}
70
87
 
71
- with ThreadPoolExecutor(max_workers=max(1, self.context.config.llm.max_parallel)) as pool:
88
+ with ThreadPoolExecutor(
89
+ max_workers=max(1, self.context.config.llm.max_parallel)
90
+ ) as pool:
72
91
  while True:
73
92
  for state in states:
74
93
  if state.status in {"completed", "failed"}:
@@ -83,9 +102,14 @@ class WorkflowEngine:
83
102
  continue
84
103
  if futures and not step.is_read_only:
85
104
  continue
86
- if any(not running_step.is_read_only for _future, (_state, running_step) in futures.items()):
105
+ if any(
106
+ not running_step.is_read_only
107
+ for _future, (_state, running_step) in futures.items()
108
+ ):
87
109
  continue
88
- if step.is_read_only and len(futures) >= max(1, self.context.config.llm.max_parallel):
110
+ if step.is_read_only and len(futures) >= max(
111
+ 1, self.context.config.llm.max_parallel
112
+ ):
89
113
  continue
90
114
  future = pool.submit(self._execute_step, state, step)
91
115
  futures[future] = (state, step)
@@ -97,8 +121,15 @@ class WorkflowEngine:
97
121
  if not futures:
98
122
  if all(state.status == "completed" for state in states):
99
123
  break
100
- pending = [state.module.id for state in states if state.status not in {"completed", "failed"}]
101
- raise HookError("Scheduler deadlock while running modules: " + ", ".join(pending))
124
+ pending = [
125
+ state.module.id
126
+ for state in states
127
+ if state.status not in {"completed", "failed"}
128
+ ]
129
+ raise HookError(
130
+ "Scheduler deadlock while running modules: "
131
+ + ", ".join(pending)
132
+ )
102
133
 
103
134
  done, _ = wait(set(futures), return_when=FIRST_COMPLETED)
104
135
  for future in done:
@@ -106,7 +137,7 @@ class WorkflowEngine:
106
137
  state.active_step_id = None
107
138
  try:
108
139
  result = future.result()
109
- except Exception as exc: # noqa: BLE001
140
+ except Exception as exc:
110
141
  state.status = "failed"
111
142
  state.error = str(exc)
112
143
  raise
@@ -127,8 +158,10 @@ class WorkflowEngine:
127
158
  def _execute_step(self, state: ModuleRuntimeState, step: StepConfig) -> StepResult:
128
159
  if step.when_env and env_bool(step.when_env) is not True:
129
160
  payload = {"skipped": True, "reason": f"{step.when_env} not enabled"}
130
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
131
- return StepResult(status="skipped", artifacts={"result.json": path}, metadata={})
161
+ self.artifacts.write_json(
162
+ state, state.step_index, step.id, "result.json", payload
163
+ )
164
+ return StepResult()
132
165
 
133
166
  if step.type == "collect":
134
167
  if step.python:
@@ -139,24 +172,36 @@ class WorkflowEngine:
139
172
  return self._persist_plugin_collect(state, step, result)
140
173
  return self._run_collect(state, step)
141
174
 
142
- input_paths = [self.artifacts.resolve_input(state, reference) for reference in step.inputs]
175
+ input_paths = [
176
+ self.artifacts.resolve_input(state, reference) for reference in step.inputs
177
+ ]
143
178
  stage_name = f"{state.module.id}.{step.id}"
144
179
 
145
180
  if step.type == "ask":
146
181
  prompt = resolve_prompt_text(self.context.repo_root, step)
147
- payload = self.ask_executor(self.context, step, prompt, input_paths, stage_name)
182
+ payload = self.ask_executor(
183
+ self.context, step, prompt, input_paths, stage_name
184
+ )
148
185
  artifact_name = step.output or "result.json"
149
186
  if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
150
- path = self.artifacts.write_json(state, state.step_index, step.id, artifact_name, payload)
187
+ self.artifacts.write_json(
188
+ state, state.step_index, step.id, artifact_name, payload
189
+ )
151
190
  else:
152
- path = self.artifacts.write_text(state, state.step_index, step.id, artifact_name, str(payload))
153
- return StepResult(artifacts={artifact_name: path})
191
+ self.artifacts.write_text(
192
+ state, state.step_index, step.id, artifact_name, str(payload)
193
+ )
194
+ return StepResult()
154
195
 
155
196
  if step.type == "apply":
156
197
  prompt = resolve_prompt_text(self.context.repo_root, step)
157
- payload = self.apply_executor(self.context, state, step, prompt, input_paths, stage_name)
158
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
159
- return StepResult(artifacts={"result.json": path})
198
+ payload = self.apply_executor(
199
+ self.context, state, step, prompt, input_paths, stage_name
200
+ )
201
+ self.artifacts.write_json(
202
+ state, state.step_index, step.id, "result.json", payload
203
+ )
204
+ return StepResult()
160
205
 
161
206
  if step.type == "exec":
162
207
  if step.python:
@@ -164,23 +209,25 @@ class WorkflowEngine:
164
209
  payload = validate_exec_result(
165
210
  self._dispatch_plugin(state, step, plugin_inputs)
166
211
  )
167
- path = self._persist_plugin_result(state, step, payload)
168
- return StepResult(artifacts={"result.json": path})
212
+ self._persist_plugin_result(state, step, payload)
213
+ return StepResult()
169
214
  if step.command:
170
- persisted = execute_step_command(
215
+ execute_step_command(
171
216
  self.context,
172
217
  state,
173
218
  step,
174
219
  dict(zip(step.inputs, input_paths)),
175
220
  artifacts=self.artifacts,
176
221
  )
177
- return StepResult(artifacts=dict(persisted.artifacts))
222
+ return StepResult()
178
223
  handler = self.exec_handlers.get(step.executor or "")
179
224
  if handler is None:
180
225
  raise HookError(f"Unknown exec handler: {step.executor}")
181
226
  payload = handler(self.context, state, step, input_paths)
182
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
183
- return StepResult(artifacts={"result.json": path})
227
+ self.artifacts.write_json(
228
+ state, state.step_index, step.id, "result.json", payload
229
+ )
230
+ return StepResult()
184
231
 
185
232
  if step.type == "assert":
186
233
  if step.python:
@@ -188,27 +235,29 @@ class WorkflowEngine:
188
235
  payload = validate_assert_result(
189
236
  self._dispatch_plugin(state, step, plugin_inputs)
190
237
  )
191
- path = self._persist_plugin_result(state, step, payload)
238
+ self._persist_plugin_result(state, step, payload)
192
239
  if not payload["ok"]:
193
240
  raise HookError(payload.get("message", "assertion failed"))
194
- return StepResult(artifacts={"result.json": path})
241
+ return StepResult()
195
242
  if step.command:
196
- persisted = execute_step_command(
243
+ execute_step_command(
197
244
  self.context,
198
245
  state,
199
246
  step,
200
247
  dict(zip(step.inputs, input_paths)),
201
248
  artifacts=self.artifacts,
202
249
  )
203
- return StepResult(artifacts=dict(persisted.artifacts))
250
+ return StepResult()
204
251
  handler = self.assertion_handlers.get(step.assertion or "")
205
252
  if handler is None:
206
253
  raise HookError(f"Unknown assertion handler: {step.assertion}")
207
254
  payload = handler(self.context, step, input_paths)
208
- path = self.artifacts.write_json(state, state.step_index, step.id, "result.json", payload)
255
+ self.artifacts.write_json(
256
+ state, state.step_index, step.id, "result.json", payload
257
+ )
209
258
  if not bool(payload.get("ok", False)):
210
259
  raise HookError(str(payload.get("message", "assertion failed")))
211
- return StepResult(artifacts={"result.json": path})
260
+ return StepResult()
212
261
 
213
262
  raise HookError(f"Unsupported step type: {step.type}")
214
263
 
@@ -238,16 +287,15 @@ class WorkflowEngine:
238
287
  ) -> StepResult:
239
288
  # Serialize and enforce both limits before the first write/register.
240
289
  serialized = self.artifacts.serialize_plugin_artifacts(result.artifacts)
241
- artifacts: dict[str, pathlib.Path] = {}
242
290
  for artifact_name, content in serialized.items():
243
- artifacts[artifact_name] = self.artifacts.write_bytes(
291
+ self.artifacts.write_bytes(
244
292
  state, state.step_index, step.id, artifact_name, content
245
293
  )
246
294
  metadata = dict(result.metadata)
247
295
  if result.skip_module:
248
296
  metadata["skip_module"] = True
249
297
  metadata["skip_reason"] = result.skip_reason
250
- return StepResult(artifacts=artifacts, metadata=metadata)
298
+ return StepResult(metadata=metadata)
251
299
 
252
300
  def _persist_plugin_result(
253
301
  self, state: ModuleRuntimeState, step: StepConfig, payload: dict[str, Any]
@@ -265,15 +313,17 @@ class WorkflowEngine:
265
313
  if handler is None:
266
314
  raise HookError(f"Unknown collector: {step.collector}")
267
315
  result = handler(self.context, state)
268
- artifacts: dict[str, pathlib.Path] = {}
269
316
  for artifact_name, payload in result.artifacts.items():
270
317
  if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
271
- path = self.artifacts.write_json(state, state.step_index, step.id, artifact_name, payload)
318
+ self.artifacts.write_json(
319
+ state, state.step_index, step.id, artifact_name, payload
320
+ )
272
321
  else:
273
- path = self.artifacts.write_text(state, state.step_index, step.id, artifact_name, str(payload))
274
- artifacts[artifact_name] = path
322
+ self.artifacts.write_text(
323
+ state, state.step_index, step.id, artifact_name, str(payload)
324
+ )
275
325
  metadata = dict(result.metadata)
276
326
  if result.skip_module:
277
327
  metadata["skip_module"] = True
278
328
  metadata["skip_reason"] = result.skip_reason
279
- return StepResult(artifacts=artifacts, metadata=metadata)
329
+ return StepResult(metadata=metadata)
@@ -68,8 +68,11 @@ class ApplyOperation:
68
68
 
69
69
  def _is_protected_path(path: str) -> bool:
70
70
  parts = pathlib.PurePosixPath(path).parts
71
- return any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts) or (
72
- bool(parts) and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
71
+ return any(
72
+ normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts
73
+ ) or (
74
+ bool(parts)
75
+ and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
73
76
  )
74
77
 
75
78
 
@@ -102,7 +105,9 @@ def _hash_file(path: pathlib.Path, max_bytes: int | None = None) -> str:
102
105
  break
103
106
  total_bytes += len(chunk)
104
107
  if max_bytes is not None and total_bytes > max_bytes:
105
- raise HookError(f"File grew beyond bounded read budget while reading: {path}")
108
+ raise HookError(
109
+ f"File grew beyond bounded read budget while reading: {path}"
110
+ )
106
111
  digest.update(chunk)
107
112
  return digest.hexdigest()
108
113
 
@@ -116,11 +121,15 @@ def _read_regular_file(
116
121
  raise HookError(f"File exceeds bounded read budget before reading: {path}")
117
122
  content = handle.read() if max_bytes is None else handle.read(max_bytes + 1)
118
123
  if max_bytes is not None and len(content) > max_bytes:
119
- raise HookError(f"File grew beyond bounded read budget while reading: {path}")
124
+ raise HookError(
125
+ f"File grew beyond bounded read budget while reading: {path}"
126
+ )
120
127
  return content, metadata.st_mode
121
128
 
122
129
 
123
- def _snapshot_destination(repo_root: pathlib.Path, relative_path: str) -> DestinationState:
130
+ def _snapshot_destination(
131
+ repo_root: pathlib.Path, relative_path: str
132
+ ) -> DestinationState:
124
133
  destination = _repo_path_from_git(repo_root, relative_path)
125
134
  if path_has_symlink(repo_root, destination):
126
135
  return DestinationState("symlink")
@@ -143,15 +152,23 @@ def _repo_path_from_git(repo_root: pathlib.Path, path: str) -> pathlib.Path:
143
152
  return repo_root.joinpath(*pure_path.parts)
144
153
 
145
154
 
146
- def _snapshot_repo_files(repo_root: pathlib.Path, paths: set[str]) -> dict[str, FileSnapshot]:
155
+ def _snapshot_repo_files(
156
+ repo_root: pathlib.Path, paths: set[str]
157
+ ) -> dict[str, FileSnapshot]:
147
158
  if len(paths) > STAGING_MAX_FILES:
148
- raise HookError("Git-visible checkout changes exceed the bounded safety snapshot budget")
159
+ raise HookError(
160
+ "Git-visible checkout changes exceed the bounded safety snapshot budget"
161
+ )
149
162
  snapshot: dict[str, FileSnapshot] = {}
150
163
  total_bytes = 0
151
164
  for path in paths:
152
165
  full_path = _repo_path_from_git(repo_root, path)
153
166
  if path_has_symlink(repo_root, full_path):
154
- mode = full_path.lstat().st_mode if full_path.exists() or full_path.is_symlink() else None
167
+ mode = (
168
+ full_path.lstat().st_mode
169
+ if full_path.exists() or full_path.is_symlink()
170
+ else None
171
+ )
155
172
  snapshot[path] = (
156
173
  "symlink",
157
174
  mode,
@@ -205,12 +222,16 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
205
222
 
206
223
  def excluded(path: pathlib.Path) -> bool:
207
224
  lexical = pathlib.Path(os.path.abspath(path))
208
- return any(is_path_within(lexical, namespace) for namespace in excluded_namespaces)
225
+ return any(
226
+ is_path_within(lexical, namespace) for namespace in excluded_namespaces
227
+ )
209
228
 
210
229
  def record(key: str, path: pathlib.Path) -> None:
211
230
  budget["entries"] += 1
212
231
  if budget["entries"] > METADATA_MAX_FILES:
213
- raise HookError("Git control metadata exceeds the bounded safety snapshot budget")
232
+ raise HookError(
233
+ "Git control metadata exceeds the bounded safety snapshot budget"
234
+ )
214
235
  if path_is_link_or_reparse(path):
215
236
  raise HookError(
216
237
  f"Refusing symlinked monitored Git metadata or reparse point: {key} ({path})"
@@ -282,7 +303,13 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
282
303
  "refs",
283
304
  "worktrees",
284
305
  },
285
- skipped_root_files={"HEAD", "config", "config.worktree", "index", "packed-refs"},
306
+ skipped_root_files={
307
+ "HEAD",
308
+ "config",
309
+ "config.worktree",
310
+ "index",
311
+ "packed-refs",
312
+ },
286
313
  )
287
314
  record("current:logs/HEAD", common_dir / "logs" / "HEAD")
288
315
  else:
@@ -318,11 +345,15 @@ def _git_index_state(repo_root: pathlib.Path) -> tuple[str, str]:
318
345
  staged = run_command(
319
346
  ["git", "ls-files", "--stage", "-z"], cwd=repo_root, check=True
320
347
  ).stdout
321
- flags = run_command(["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True).stdout
348
+ flags = run_command(
349
+ ["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True
350
+ ).stdout
322
351
  return staged, flags
323
352
 
324
353
 
325
- def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]) -> None:
354
+ def _validate_apply_allowlist(
355
+ repo_root: pathlib.Path, patterns: tuple[str, ...]
356
+ ) -> None:
326
357
  for pattern in patterns:
327
358
  parts = relative_path_parts(pattern, "Apply allow_paths entry")
328
359
  if any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts):
@@ -337,7 +368,9 @@ def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]
337
368
  if len(static_parts) == len(parts):
338
369
  candidates = [repo_root.joinpath(*parts)]
339
370
  else:
340
- candidates = [repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
371
+ candidates = (
372
+ [repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
373
+ )
341
374
  for candidate in candidates:
342
375
  if path_has_symlink(repo_root, candidate):
343
376
  raise HookError(f"Apply allow_paths traverses a symlink: {pattern}")
@@ -392,7 +425,9 @@ def _copy_checkout_to_staging(
392
425
  )
393
426
  resolved_source = source.resolve(strict=False)
394
427
  if not is_path_within(resolved_source, resolved_repo_root):
395
- raise HookError(f"Allowed checkout source escapes repository: {relative_path}")
428
+ raise HookError(
429
+ f"Allowed checkout source escapes repository: {relative_path}"
430
+ )
396
431
  if any(is_path_within(resolved_source, root) for root in git_roots):
397
432
  continue
398
433
  if not source.exists():
@@ -400,15 +435,21 @@ def _copy_checkout_to_staging(
400
435
  if not stat.S_ISREG(source.lstat().st_mode):
401
436
  if project_access == "project":
402
437
  continue
403
- raise HookError(f"Allowed checkout path is not a regular file: {relative_path}")
438
+ raise HookError(
439
+ f"Allowed checkout path is not a regular file: {relative_path}"
440
+ )
404
441
  copied_files += 1
405
442
  if copied_files > STAGING_MAX_FILES:
406
- raise HookError("Allowed apply files exceed the bounded staging workspace budget")
443
+ raise HookError(
444
+ "Allowed apply files exceed the bounded staging workspace budget"
445
+ )
407
446
  remaining_bytes = STAGING_MAX_BYTES - copied_bytes
408
447
  content, source_mode = _read_regular_file(source, max_bytes=remaining_bytes)
409
448
  copied_bytes += len(content)
410
449
  if copied_bytes > STAGING_MAX_BYTES:
411
- raise HookError("Allowed apply files exceed the bounded staging workspace budget")
450
+ raise HookError(
451
+ "Allowed apply files exceed the bounded staging workspace budget"
452
+ )
412
453
  baselines[relative_path] = DestinationState(
413
454
  "file",
414
455
  source_mode,
@@ -427,7 +468,9 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
427
468
  for directory, dirnames, filenames in os.walk(staging_root, followlinks=False):
428
469
  total_entries += len(dirnames) + len(filenames)
429
470
  if total_entries > STAGING_MAX_FILES:
430
- raise HookError("Apply staging workspace exceeds its bounded inventory budget")
471
+ raise HookError(
472
+ "Apply staging workspace exceeds its bounded inventory budget"
473
+ )
431
474
  directory_path = pathlib.Path(directory)
432
475
  for name in dirnames:
433
476
  path = directory_path / name
@@ -445,10 +488,14 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
445
488
  f"Apply staging workspace contains symlink or reparse point: {relative}"
446
489
  )
447
490
  if not stat.S_ISREG(metadata.st_mode):
448
- raise HookError(f"Apply staging workspace contains non-regular file: {relative}")
491
+ raise HookError(
492
+ f"Apply staging workspace contains non-regular file: {relative}"
493
+ )
449
494
  total_bytes += metadata.st_size
450
495
  if len(inventory) >= STAGING_MAX_FILES or total_bytes > STAGING_MAX_BYTES:
451
- raise HookError("Apply staging workspace exceeds its bounded inventory budget")
496
+ raise HookError(
497
+ "Apply staging workspace exceeds its bounded inventory budget"
498
+ )
452
499
  inventory[relative] = StagedFile(
453
500
  _hash_file(path, metadata.st_size), metadata.st_mode, metadata.st_size
454
501
  )
@@ -471,7 +518,10 @@ def _changed_staging_paths(
471
518
  )
472
519
  )
473
520
  if unexpected:
474
- raise HookError("Apply staging workspace contains paths outside allowlist: " + ", ".join(unexpected))
521
+ raise HookError(
522
+ "Apply staging workspace contains paths outside allowlist: "
523
+ + ", ".join(unexpected)
524
+ )
475
525
  return {path for path in all_paths if before.get(path) != after.get(path)}
476
526
 
477
527
 
@@ -506,7 +556,9 @@ def _ignored_changed_paths(
506
556
  def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Path:
507
557
  repo_root = context.repo_root.resolve(strict=True)
508
558
  if _is_protected_path(relative_path):
509
- raise HookError(f"Apply destination must not contain Git metadata: {relative_path}")
559
+ raise HookError(
560
+ f"Apply destination must not contain Git metadata: {relative_path}"
561
+ )
510
562
  destination = _repo_path_from_git(repo_root, relative_path)
511
563
  if path_has_symlink(repo_root, destination):
512
564
  raise HookError(f"Apply destination is or traverses a symlink: {relative_path}")
@@ -514,8 +566,12 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
514
566
  while not existing_parent.exists() and existing_parent != repo_root:
515
567
  existing_parent = existing_parent.parent
516
568
  if not existing_parent.is_dir():
517
- raise HookError(f"Apply destination has a non-directory parent: {relative_path}")
518
- if not is_path_within(existing_parent.resolve(strict=True), repo_root.resolve(strict=True)):
569
+ raise HookError(
570
+ f"Apply destination has a non-directory parent: {relative_path}"
571
+ )
572
+ if not is_path_within(
573
+ existing_parent.resolve(strict=True), repo_root.resolve(strict=True)
574
+ ):
519
575
  raise HookError(f"Apply destination escapes repository: {relative_path}")
520
576
  resolved_destination = destination.resolve(strict=False)
521
577
  git_roots = (
@@ -523,7 +579,9 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
523
579
  resolve_git_common_dir(context.repo_root).resolve(strict=True),
524
580
  )
525
581
  if any(is_path_within(resolved_destination, root) for root in git_roots):
526
- raise HookError(f"Apply destination resolves inside Git metadata: {relative_path}")
582
+ raise HookError(
583
+ f"Apply destination resolves inside Git metadata: {relative_path}"
584
+ )
527
585
  return destination
528
586
 
529
587
 
@@ -547,16 +605,23 @@ def _preflight_apply_operations(
547
605
  conflicts = [
548
606
  operation.relative_path
549
607
  for operation in operations
550
- if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline
608
+ if _snapshot_destination(context.repo_root, operation.relative_path)
609
+ != operation.baseline
551
610
  ]
552
611
  if conflicts:
553
612
  raise HookError(
554
- "Apply checkout changed concurrently; refusing to overwrite: " + ", ".join(conflicts)
613
+ "Apply checkout changed concurrently; refusing to overwrite: "
614
+ + ", ".join(conflicts)
555
615
  )
556
616
 
557
617
 
558
- def _verify_operation_baseline(context: RuntimeContext, operation: ApplyOperation) -> None:
559
- if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline:
618
+ def _verify_operation_baseline(
619
+ context: RuntimeContext, operation: ApplyOperation
620
+ ) -> None:
621
+ if (
622
+ _snapshot_destination(context.repo_root, operation.relative_path)
623
+ != operation.baseline
624
+ ):
560
625
  raise HookError(
561
626
  "Apply checkout changed concurrently; refusing to overwrite: "
562
627
  + operation.relative_path
@@ -575,7 +640,10 @@ def _propagate_staging_changes(
575
640
  _ignored_changed_paths(context.repo_root, changed_paths, work_tree=staging_root)
576
641
  )
577
642
  if ignored:
578
- raise HookError("Refusing to copy staging output to ignored paths: " + ", ".join(sorted(ignored)))
643
+ raise HookError(
644
+ "Refusing to copy staging output to ignored paths: "
645
+ + ", ".join(sorted(ignored))
646
+ )
579
647
  operations: list[ApplyOperation] = []
580
648
  expected: dict[str, StagedFile | None] = {}
581
649
  for relative_path in sorted(changed_paths):
@@ -598,12 +666,13 @@ def _propagate_staging_changes(
598
666
  if not is_path_within(
599
667
  source.resolve(strict=True), staging_root.resolve(strict=True)
600
668
  ):
601
- raise HookError(f"Refusing staged output that escapes workspace: {relative_path}")
669
+ raise HookError(
670
+ f"Refusing staged output that escapes workspace: {relative_path}"
671
+ )
602
672
  content, source_mode = _read_regular_file(source, max_bytes=staged.size)
603
- if (
604
- hashlib.sha256(content).hexdigest() != staged.digest
605
- or stat.S_IMODE(source_mode) != stat.S_IMODE(staged.mode)
606
- ):
673
+ if hashlib.sha256(content).hexdigest() != staged.digest or stat.S_IMODE(
674
+ source_mode
675
+ ) != stat.S_IMODE(staged.mode):
607
676
  raise HookError(f"Staged output changed after validation: {relative_path}")
608
677
  approved_mode = _conservative_propagation_mode(baseline, staged.mode)
609
678
  operations.append(
@@ -649,13 +718,14 @@ def _verify_propagated_changes(
649
718
  mismatches.append(relative_path)
650
719
  continue
651
720
  metadata = destination.lstat()
652
- if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(metadata.st_mode) != stat.S_IMODE(
653
- staged.mode
654
- ):
721
+ if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(
722
+ metadata.st_mode
723
+ ) != stat.S_IMODE(staged.mode):
655
724
  mismatches.append(relative_path)
656
725
  if mismatches:
657
726
  raise HookError(
658
- "Real checkout does not match validated staging output: " + ", ".join(mismatches)
727
+ "Real checkout does not match validated staging output: "
728
+ + ", ".join(mismatches)
659
729
  )
660
730
 
661
731
 
@@ -834,7 +904,9 @@ def run_apply_step(
834
904
  call_error: Exception | None = None
835
905
  staged_changes: set[str] = set()
836
906
  propagated_expected: dict[str, StagedFile | None] = {}
837
- with tempfile.TemporaryDirectory(prefix="ai-push-hooks-apply-") as temporary_directory:
907
+ with tempfile.TemporaryDirectory(
908
+ prefix="ai-push-hooks-apply-"
909
+ ) as temporary_directory:
838
910
  staging_root = pathlib.Path(temporary_directory).resolve(strict=True)
839
911
  profile = resolve_runner_profile(context.config, step)
840
912
  destination_baselines = _copy_checkout_to_staging(
@@ -877,11 +949,17 @@ def run_apply_step(
877
949
  metadata_before,
878
950
  )
879
951
  if call_error is not None:
880
- raise HookError(f"Apply step failed in isolated staging: {call_error}") from call_error
952
+ raise HookError(
953
+ f"Apply step failed in isolated staging: {call_error}"
954
+ ) from call_error
881
955
  if result is None:
882
956
  raise HookError("Apply step failed without a runner result")
883
957
  if result.returncode != 0:
884
- details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
958
+ details = (
959
+ result.stderr.strip()
960
+ or result.stdout.strip()
961
+ or f"exit code {result.returncode}"
962
+ )
885
963
  raise HookError(f"Apply step failed in isolated staging: {details}")
886
964
  propagated_expected = _propagate_staging_changes(
887
965
  context,