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