ai-push-hooks 0.1.19 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +314 -62
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +25 -4
- package/pyproject.toml +11 -4
- package/src/ai_push_hooks/artifacts.py +57 -9
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +281 -15
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +845 -43
- package/src/ai_push_hooks/executors/exec.py +742 -114
- package/src/ai_push_hooks/executors/llm.py +368 -42
- package/src/ai_push_hooks/hook.py +129 -22
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +53 -7
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +6 -2
- package/src/ai_push_hooks/types.py +82 -3
|
@@ -7,19 +7,88 @@ import pathlib
|
|
|
7
7
|
import re
|
|
8
8
|
import shlex
|
|
9
9
|
import shutil
|
|
10
|
+
import stat
|
|
10
11
|
import subprocess
|
|
11
|
-
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
12
14
|
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
13
16
|
|
|
17
|
+
from ..paths import (
|
|
18
|
+
ensure_private_directory,
|
|
19
|
+
is_path_within,
|
|
20
|
+
normalized_component,
|
|
21
|
+
path_has_symlink,
|
|
22
|
+
path_is_link_or_reparse,
|
|
23
|
+
relative_path_parts,
|
|
24
|
+
resolve_contained_path,
|
|
25
|
+
write_text_no_follow,
|
|
26
|
+
)
|
|
14
27
|
from ..types import (
|
|
15
28
|
FEATURE_BRANCH_PREFIXES,
|
|
16
29
|
HookError,
|
|
17
30
|
ModuleRuntimeState,
|
|
31
|
+
PushRefUpdate,
|
|
32
|
+
PushRevisionRange,
|
|
18
33
|
RuntimeContext,
|
|
19
34
|
StepConfig,
|
|
35
|
+
ZERO_OID_LENGTHS,
|
|
20
36
|
)
|
|
21
37
|
|
|
22
|
-
ZERO_OID = "
|
|
38
|
+
ZERO_OID = "0" * 40
|
|
39
|
+
BEADS_ALIGNMENT_TIMEOUT_SECONDS = 30
|
|
40
|
+
BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS = 120
|
|
41
|
+
BEADS_ALIGNMENT_MAX_COMMANDS = 20
|
|
42
|
+
BEADS_ISSUE_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
43
|
+
BEADS_UPDATE_STATUSES = frozenset({"open", "in_progress", "blocked"})
|
|
44
|
+
BEADS_ENV_NAMES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"ALL_PROXY",
|
|
47
|
+
"APPDATA",
|
|
48
|
+
"HOME",
|
|
49
|
+
"HOMEDRIVE",
|
|
50
|
+
"HOMEPATH",
|
|
51
|
+
"HTTP_PROXY",
|
|
52
|
+
"HTTPS_PROXY",
|
|
53
|
+
"LANG",
|
|
54
|
+
"LC_ALL",
|
|
55
|
+
"LC_CTYPE",
|
|
56
|
+
"LOCALAPPDATA",
|
|
57
|
+
"LOGNAME",
|
|
58
|
+
"NO_PROXY",
|
|
59
|
+
"PATH",
|
|
60
|
+
"PROGRAMDATA",
|
|
61
|
+
"SSH_AUTH_SOCK",
|
|
62
|
+
"SSL_CERT_DIR",
|
|
63
|
+
"SSL_CERT_FILE",
|
|
64
|
+
"SYSTEMROOT",
|
|
65
|
+
"TEMP",
|
|
66
|
+
"TMP",
|
|
67
|
+
"TMPDIR",
|
|
68
|
+
"USER",
|
|
69
|
+
"USERPROFILE",
|
|
70
|
+
"XDG_CACHE_HOME",
|
|
71
|
+
"XDG_CONFIG_HOME",
|
|
72
|
+
"XDG_DATA_HOME",
|
|
73
|
+
"XDG_STATE_HOME",
|
|
74
|
+
"all_proxy",
|
|
75
|
+
"http_proxy",
|
|
76
|
+
"https_proxy",
|
|
77
|
+
"no_proxy",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
BEADS_ENV_PREFIXES = ("AWS_", "BD_", "BEADS_", "DOLT_")
|
|
81
|
+
BEADS_MIGRATION_OVERRIDE_ENV_NAMES = frozenset(
|
|
82
|
+
{
|
|
83
|
+
"BD_ALLOW_REMOTE_MIGRATE",
|
|
84
|
+
"BD_IGNORE_SCHEMA_SKEW",
|
|
85
|
+
"BD_SMART_GATE",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
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"
|
|
23
92
|
|
|
24
93
|
|
|
25
94
|
def env_bool(name: str) -> bool | None:
|
|
@@ -38,13 +107,13 @@ def run_command(
|
|
|
38
107
|
args: list[str],
|
|
39
108
|
cwd: pathlib.Path,
|
|
40
109
|
input_text: str | None = None,
|
|
41
|
-
timeout:
|
|
110
|
+
timeout: float | None = None,
|
|
42
111
|
check: bool = False,
|
|
43
112
|
env: dict[str, str | None] | None = None,
|
|
113
|
+
inherit_env: bool = True,
|
|
44
114
|
) -> subprocess.CompletedProcess[str]:
|
|
45
|
-
merged_env =
|
|
115
|
+
merged_env = os.environ.copy() if inherit_env else {}
|
|
46
116
|
if env is not None:
|
|
47
|
-
merged_env = os.environ.copy()
|
|
48
117
|
for key, value in env.items():
|
|
49
118
|
if value is None:
|
|
50
119
|
merged_env.pop(key, None)
|
|
@@ -55,6 +124,7 @@ def run_command(
|
|
|
55
124
|
cwd=cwd,
|
|
56
125
|
input=input_text,
|
|
57
126
|
text=True,
|
|
127
|
+
errors="surrogateescape",
|
|
58
128
|
capture_output=True,
|
|
59
129
|
timeout=timeout,
|
|
60
130
|
env=merged_env,
|
|
@@ -84,22 +154,37 @@ def resolve_git_dir(repo_root: pathlib.Path) -> pathlib.Path:
|
|
|
84
154
|
return (repo_root / path).resolve()
|
|
85
155
|
|
|
86
156
|
|
|
87
|
-
def
|
|
157
|
+
def resolve_git_common_dir(repo_root: pathlib.Path) -> pathlib.Path:
|
|
158
|
+
raw = git(repo_root, ["rev-parse", "--git-common-dir"])
|
|
88
159
|
path = pathlib.Path(raw)
|
|
89
160
|
if path.is_absolute():
|
|
90
|
-
return path
|
|
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")
|
|
91
167
|
posix_raw = raw.replace("\\", "/")
|
|
92
|
-
if
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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")
|
|
97
183
|
|
|
98
184
|
|
|
99
185
|
def ensure_dir(path: pathlib.Path) -> pathlib.Path | None:
|
|
100
186
|
try:
|
|
101
|
-
path
|
|
102
|
-
return path
|
|
187
|
+
return ensure_private_directory(path)
|
|
103
188
|
except Exception: # noqa: BLE001
|
|
104
189
|
return None
|
|
105
190
|
|
|
@@ -112,103 +197,387 @@ def is_feature_branch(branch_name: str) -> bool:
|
|
|
112
197
|
return bool(branch_name) and branch_name.startswith(FEATURE_BRANCH_PREFIXES)
|
|
113
198
|
|
|
114
199
|
|
|
115
|
-
def should_skip_for_sync_branch(
|
|
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]:
|
|
116
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
|
+
)
|
|
117
221
|
if "/.beads-sync-worktrees/" in repo_root.as_posix():
|
|
118
222
|
return True, "worktree is inside .beads-sync-worktrees"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return True, f"current branch is {sync_branch}"
|
|
223
|
+
if only_sync_branch_updates:
|
|
224
|
+
return True, f"all pushed branches are {sync_branch}"
|
|
122
225
|
return False, ""
|
|
123
226
|
|
|
124
227
|
|
|
125
228
|
def path_matches(path: str, pattern: str) -> bool:
|
|
126
|
-
|
|
127
|
-
|
|
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)
|
|
128
261
|
|
|
129
262
|
|
|
130
263
|
def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
|
|
131
264
|
changes: set[str] = set()
|
|
132
|
-
output = run_command(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
137
285
|
return changes
|
|
138
286
|
|
|
139
287
|
|
|
140
|
-
def
|
|
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(
|
|
141
354
|
repo_root: pathlib.Path,
|
|
142
355
|
remote_name: str,
|
|
143
|
-
|
|
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],
|
|
144
373
|
base_branch: str = "main",
|
|
145
|
-
) -> list[
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
parts = line.strip().split()
|
|
150
|
-
if len(parts) < 4:
|
|
374
|
+
) -> list[PushRevisionRange]:
|
|
375
|
+
ranges: list[PushRevisionRange] = []
|
|
376
|
+
for update in updates:
|
|
377
|
+
if update.operation == "delete":
|
|
151
378
|
continue
|
|
152
|
-
|
|
153
|
-
if
|
|
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.
|
|
154
383
|
continue
|
|
155
|
-
if
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
)
|
|
163
394
|
else:
|
|
164
|
-
|
|
165
|
-
repo_root,
|
|
395
|
+
expression, strategy = _fallback_range(
|
|
396
|
+
repo_root,
|
|
397
|
+
remote_name,
|
|
398
|
+
base_branch,
|
|
399
|
+
local_commit,
|
|
400
|
+
reason="new-ref",
|
|
166
401
|
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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))
|
|
173
410
|
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
421
|
)
|
|
177
|
-
if upstream:
|
|
178
|
-
merge_base = git(repo_root, ["merge-base", "HEAD", upstream], check=False)
|
|
179
|
-
if merge_base:
|
|
180
|
-
return [f"{merge_base}..HEAD"]
|
|
181
|
-
previous = git(repo_root, ["rev-parse", "HEAD~1"], check=False)
|
|
182
|
-
if previous:
|
|
183
|
-
return [f"{previous}..HEAD"]
|
|
184
|
-
return []
|
|
185
422
|
|
|
186
423
|
|
|
187
424
|
def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[str]:
|
|
188
425
|
files: set[str] = set()
|
|
189
426
|
for range_expr in ranges:
|
|
190
|
-
output =
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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)
|
|
197
442
|
return sorted(files)
|
|
198
443
|
|
|
199
444
|
|
|
445
|
+
def _read_bounded_stderr(stream: Any, captured: bytearray) -> None:
|
|
446
|
+
try:
|
|
447
|
+
while True:
|
|
448
|
+
chunk = stream.read(GIT_DIFF_CHUNK_BYTES)
|
|
449
|
+
if not chunk:
|
|
450
|
+
return
|
|
451
|
+
remaining = GIT_ERROR_BYTES - len(captured)
|
|
452
|
+
if remaining > 0:
|
|
453
|
+
captured.extend(chunk[:remaining])
|
|
454
|
+
except (OSError, ValueError):
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _terminate_and_wait(process: subprocess.Popen[bytes]) -> int:
|
|
459
|
+
if process.poll() is None:
|
|
460
|
+
process.terminate()
|
|
461
|
+
try:
|
|
462
|
+
return process.wait(timeout=5)
|
|
463
|
+
except subprocess.TimeoutExpired:
|
|
464
|
+
process.kill()
|
|
465
|
+
try:
|
|
466
|
+
return process.wait(timeout=5)
|
|
467
|
+
except subprocess.TimeoutExpired as error:
|
|
468
|
+
raise HookError("Git diff process did not terminate safely") from error
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _collect_bounded_git_diff(
|
|
472
|
+
repo_root: pathlib.Path, args: list[str], max_bytes: int
|
|
473
|
+
) -> tuple[bytes, bool]:
|
|
474
|
+
process = subprocess.Popen(
|
|
475
|
+
args,
|
|
476
|
+
cwd=repo_root,
|
|
477
|
+
stdout=subprocess.PIPE,
|
|
478
|
+
stderr=subprocess.PIPE,
|
|
479
|
+
)
|
|
480
|
+
if process.stdout is None or process.stderr is None:
|
|
481
|
+
raise HookError("Could not capture Git diff output")
|
|
482
|
+
|
|
483
|
+
stderr = bytearray()
|
|
484
|
+
stderr_thread = threading.Thread(
|
|
485
|
+
target=_read_bounded_stderr,
|
|
486
|
+
args=(process.stderr, stderr),
|
|
487
|
+
daemon=True,
|
|
488
|
+
)
|
|
489
|
+
stderr_thread.start()
|
|
490
|
+
output = bytearray()
|
|
491
|
+
limit = max(0, max_bytes)
|
|
492
|
+
truncated = False
|
|
493
|
+
returncode: int | None = None
|
|
494
|
+
try:
|
|
495
|
+
while True:
|
|
496
|
+
remaining = limit - len(output)
|
|
497
|
+
chunk = process.stdout.read(min(GIT_DIFF_CHUNK_BYTES, remaining + 1))
|
|
498
|
+
if not chunk:
|
|
499
|
+
break
|
|
500
|
+
if len(chunk) > remaining:
|
|
501
|
+
if remaining > 0:
|
|
502
|
+
output.extend(chunk[:remaining])
|
|
503
|
+
truncated = True
|
|
504
|
+
returncode = _terminate_and_wait(process)
|
|
505
|
+
break
|
|
506
|
+
output.extend(chunk)
|
|
507
|
+
if returncode is None:
|
|
508
|
+
returncode = process.wait()
|
|
509
|
+
finally:
|
|
510
|
+
if process.poll() is None:
|
|
511
|
+
_terminate_and_wait(process)
|
|
512
|
+
stderr_thread.join(timeout=5)
|
|
513
|
+
if stderr_thread.is_alive():
|
|
514
|
+
process.stderr.close()
|
|
515
|
+
stderr_thread.join(timeout=5)
|
|
516
|
+
process.stdout.close()
|
|
517
|
+
process.stderr.close()
|
|
518
|
+
|
|
519
|
+
if returncode != 0 and not truncated:
|
|
520
|
+
details = bytes(stderr).decode("utf-8", errors="surrogateescape").strip()
|
|
521
|
+
details = details or f"exit code {returncode}"
|
|
522
|
+
raise HookError(f"Command failed: {' '.join(args)} :: {details}")
|
|
523
|
+
return bytes(output), truncated
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _decode_diff_output(output: bytes, max_bytes: int, truncated: bool) -> str:
|
|
527
|
+
if not truncated:
|
|
528
|
+
return output.decode("utf-8", errors="surrogateescape")
|
|
529
|
+
limit = max(0, max_bytes)
|
|
530
|
+
if limit == 0:
|
|
531
|
+
return ""
|
|
532
|
+
marker = DIFF_TRUNCATION_MARKER.encode("utf-8")
|
|
533
|
+
if len(marker) >= limit:
|
|
534
|
+
return marker[:limit].decode("utf-8", errors="surrogateescape")
|
|
535
|
+
return (output[: limit - len(marker)] + marker).decode(
|
|
536
|
+
"utf-8", errors="surrogateescape"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
200
540
|
def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) -> str:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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)
|
|
206
574
|
|
|
207
575
|
|
|
208
576
|
def collect_commit_messages_for_ranges(
|
|
209
577
|
repo_root: pathlib.Path, ranges: list[str]
|
|
210
578
|
) -> list[dict[str, str]]:
|
|
211
579
|
commits: list[dict[str, str]] = []
|
|
580
|
+
seen_hashes: set[str] = set()
|
|
212
581
|
for range_expr in ranges:
|
|
213
582
|
completed = run_command(
|
|
214
583
|
["git", "log", "--format=%H%x1f%s%x1f%b%x1e", range_expr],
|
|
@@ -228,9 +597,13 @@ def collect_commit_messages_for_ranges(
|
|
|
228
597
|
commit_hash, subject, body = parts
|
|
229
598
|
else:
|
|
230
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)
|
|
231
604
|
commits.append(
|
|
232
605
|
{
|
|
233
|
-
"hash":
|
|
606
|
+
"hash": clean_hash,
|
|
234
607
|
"subject": subject.strip(),
|
|
235
608
|
"body": body.strip(),
|
|
236
609
|
}
|
|
@@ -238,10 +611,35 @@ def collect_commit_messages_for_ranges(
|
|
|
238
611
|
return commits
|
|
239
612
|
|
|
240
613
|
|
|
241
|
-
def write_text_file(
|
|
614
|
+
def write_text_file(
|
|
615
|
+
path: pathlib.Path,
|
|
616
|
+
content: str,
|
|
617
|
+
*,
|
|
618
|
+
root: pathlib.Path | None = None,
|
|
619
|
+
) -> bool:
|
|
242
620
|
try:
|
|
243
|
-
|
|
244
|
-
|
|
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)
|
|
245
643
|
return True
|
|
246
644
|
except Exception: # noqa: BLE001
|
|
247
645
|
return False
|
|
@@ -257,21 +655,93 @@ def parse_key_value_text(text: str) -> dict[str, str]:
|
|
|
257
655
|
return payload
|
|
258
656
|
|
|
259
657
|
|
|
260
|
-
def
|
|
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])
|
|
261
743
|
completed = run_command(
|
|
262
|
-
|
|
263
|
-
"gh",
|
|
264
|
-
"pr",
|
|
265
|
-
"list",
|
|
266
|
-
"--head",
|
|
267
|
-
branch_name,
|
|
268
|
-
"--state",
|
|
269
|
-
"open",
|
|
270
|
-
"--limit",
|
|
271
|
-
"1",
|
|
272
|
-
"--json",
|
|
273
|
-
"url",
|
|
274
|
-
],
|
|
744
|
+
args,
|
|
275
745
|
cwd=repo_root,
|
|
276
746
|
check=False,
|
|
277
747
|
)
|
|
@@ -297,6 +767,15 @@ def sanitize_pr_title(raw_title: str, branch_name: str) -> str:
|
|
|
297
767
|
return title[:240]
|
|
298
768
|
|
|
299
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
|
+
|
|
300
779
|
def build_fallback_pr_body(
|
|
301
780
|
branch_name: str,
|
|
302
781
|
ranges: list[str],
|
|
@@ -333,6 +812,7 @@ def attempt_pr_creation_fallback(
|
|
|
333
812
|
ranges: list[str],
|
|
334
813
|
changed_files: list[str],
|
|
335
814
|
commits: list[dict[str, str]],
|
|
815
|
+
repository: str,
|
|
336
816
|
) -> str:
|
|
337
817
|
title = sanitize_pr_title(
|
|
338
818
|
git(repo_root, ["log", "-1", "--pretty=%s"], check=False), branch_name
|
|
@@ -343,6 +823,8 @@ def attempt_pr_creation_fallback(
|
|
|
343
823
|
"gh",
|
|
344
824
|
"pr",
|
|
345
825
|
"create",
|
|
826
|
+
"--repo",
|
|
827
|
+
repository,
|
|
346
828
|
"--head",
|
|
347
829
|
branch_name,
|
|
348
830
|
"--base",
|
|
@@ -360,7 +842,7 @@ def attempt_pr_creation_fallback(
|
|
|
360
842
|
pr_url = extract_pr_url(combined_output)
|
|
361
843
|
if pr_url:
|
|
362
844
|
return pr_url
|
|
363
|
-
existing_pr = lookup_open_pr_url(repo_root, branch_name)
|
|
845
|
+
existing_pr = lookup_open_pr_url(repo_root, branch_name, base_branch, repository)
|
|
364
846
|
if existing_pr:
|
|
365
847
|
return existing_pr
|
|
366
848
|
raise HookError(
|
|
@@ -380,8 +862,104 @@ def _report_file_path(context: RuntimeContext, state: ModuleRuntimeState) -> pat
|
|
|
380
862
|
if branch_context and branch_context.exists():
|
|
381
863
|
payload = parse_key_value_text(branch_context.read_text(encoding="utf-8"))
|
|
382
864
|
report_file = payload.get("report_file", "BEADS_STATUS_ACTION_REQUIRED.md")
|
|
383
|
-
|
|
384
|
-
|
|
865
|
+
else:
|
|
866
|
+
report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
|
|
867
|
+
|
|
868
|
+
parts = relative_path_parts(report_file, "Beads alignment report path")
|
|
869
|
+
if any(normalized_component(part) == ".git" for part in parts):
|
|
870
|
+
raise HookError("Beads alignment report path must not reference Git metadata")
|
|
871
|
+
lexical_path = context.repo_root.joinpath(*parts)
|
|
872
|
+
if path_has_symlink(context.repo_root, lexical_path):
|
|
873
|
+
raise HookError("Beads alignment report path must not traverse a symlink")
|
|
874
|
+
report_path = resolve_contained_path(
|
|
875
|
+
context.repo_root,
|
|
876
|
+
report_file,
|
|
877
|
+
"Beads alignment report path",
|
|
878
|
+
)
|
|
879
|
+
if report_path.exists() and not stat.S_ISREG(report_path.lstat().st_mode):
|
|
880
|
+
raise HookError("Beads alignment report path must be a regular file")
|
|
881
|
+
return report_path
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _validate_beads_issue_ids(values: list[str]) -> None:
|
|
885
|
+
if not values or len(values) > 20:
|
|
886
|
+
raise HookError("Beads alignment commands require between 1 and 20 issue ids")
|
|
887
|
+
for issue_id in values:
|
|
888
|
+
if not BEADS_ISSUE_ID_PATTERN.fullmatch(issue_id):
|
|
889
|
+
raise HookError(f"Invalid Beads issue id in alignment command: {issue_id!r}")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def validate_beads_alignment_command(command: str) -> list[str]:
|
|
893
|
+
if not isinstance(command, str) or not command.strip():
|
|
894
|
+
raise HookError("Beads alignment commands must be non-empty strings")
|
|
895
|
+
if len(command) > 4096 or "\x00" in command or any(ord(char) < 32 for char in command):
|
|
896
|
+
raise HookError("Beads alignment command contains invalid or excessive input")
|
|
897
|
+
try:
|
|
898
|
+
argv = shlex.split(command, posix=True)
|
|
899
|
+
except ValueError as exc:
|
|
900
|
+
raise HookError(f"Malformed Beads alignment command: {exc}") from exc
|
|
901
|
+
|
|
902
|
+
if len(argv) < 3 or argv[0] != "bd":
|
|
903
|
+
raise HookError("Beads alignment commands must use the literal `bd` executable")
|
|
904
|
+
|
|
905
|
+
subcommand = argv[1]
|
|
906
|
+
if subcommand == "update":
|
|
907
|
+
if len(argv) < 5 or argv[-2] != "--status" or argv[-1] not in BEADS_UPDATE_STATUSES:
|
|
908
|
+
raise HookError(
|
|
909
|
+
"Allowed Beads update form is: bd update <issue-id> [<issue-id> ...] "
|
|
910
|
+
"--status <open|in_progress|blocked>"
|
|
911
|
+
)
|
|
912
|
+
_validate_beads_issue_ids(argv[2:-2])
|
|
913
|
+
return argv
|
|
914
|
+
|
|
915
|
+
if subcommand == "close":
|
|
916
|
+
issue_ids = argv[2:]
|
|
917
|
+
if "--reason" in issue_ids:
|
|
918
|
+
if issue_ids.count("--reason") != 1 or issue_ids[-2] != "--reason":
|
|
919
|
+
raise HookError(
|
|
920
|
+
"Allowed Beads close form is: bd close <issue-id> [<issue-id> ...] "
|
|
921
|
+
"[--reason <text>]"
|
|
922
|
+
)
|
|
923
|
+
reason = issue_ids[-1]
|
|
924
|
+
if not reason or reason.startswith("-") or len(reason) > 500:
|
|
925
|
+
raise HookError("Invalid Beads close reason")
|
|
926
|
+
issue_ids = issue_ids[:-2]
|
|
927
|
+
_validate_beads_issue_ids(issue_ids)
|
|
928
|
+
return argv
|
|
929
|
+
|
|
930
|
+
raise HookError(
|
|
931
|
+
f"Beads alignment subcommand `{subcommand}` is not allowed; only `update` and `close` are permitted"
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def resolve_beads_executable(repo_root: pathlib.Path) -> str:
|
|
936
|
+
candidate = shutil.which("bd")
|
|
937
|
+
if not candidate:
|
|
938
|
+
raise HookError("`bd` is required for Beads alignment but is not installed")
|
|
939
|
+
lexical_candidate = pathlib.Path(os.path.abspath(candidate))
|
|
940
|
+
resolved_repo_root = repo_root.resolve(strict=True)
|
|
941
|
+
if is_path_within(lexical_candidate, resolved_repo_root):
|
|
942
|
+
raise HookError(f"Refusing repository-contained `bd` executable: {lexical_candidate}")
|
|
943
|
+
try:
|
|
944
|
+
executable = lexical_candidate.resolve(strict=True)
|
|
945
|
+
except (OSError, RuntimeError) as exc:
|
|
946
|
+
raise HookError("Unable to safely resolve the `bd` executable") from exc
|
|
947
|
+
if is_path_within(executable, resolved_repo_root):
|
|
948
|
+
raise HookError(f"Refusing repository-contained `bd` executable: {executable}")
|
|
949
|
+
if path_is_link_or_reparse(executable) or not stat.S_ISREG(executable.stat().st_mode):
|
|
950
|
+
raise HookError(f"Resolved `bd` executable is not a regular file: {executable}")
|
|
951
|
+
if not os.access(executable, os.X_OK):
|
|
952
|
+
raise HookError(f"Resolved `bd` executable is not executable: {executable}")
|
|
953
|
+
return str(executable)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def beads_alignment_env() -> dict[str, str]:
|
|
957
|
+
return {
|
|
958
|
+
name: value
|
|
959
|
+
for name, value in os.environ.items()
|
|
960
|
+
if name not in BEADS_MIGRATION_OVERRIDE_ENV_NAMES
|
|
961
|
+
and (name in BEADS_ENV_NAMES or name.startswith(BEADS_ENV_PREFIXES))
|
|
962
|
+
}
|
|
385
963
|
|
|
386
964
|
|
|
387
965
|
def beads_alignment_executor(
|
|
@@ -393,15 +971,35 @@ def beads_alignment_executor(
|
|
|
393
971
|
if state.metadata.get("skip_module"):
|
|
394
972
|
return {"skipped": True, "commands_run": [], "report_written": False, "unresolved": False}
|
|
395
973
|
payload = json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
974
|
+
if not isinstance(payload, dict):
|
|
975
|
+
raise HookError("beads_alignment payload must be an object")
|
|
396
976
|
commands = payload.get("commands", [])
|
|
397
977
|
if not isinstance(commands, list):
|
|
398
978
|
raise HookError("beads_alignment commands must be an array")
|
|
979
|
+
if len(commands) > BEADS_ALIGNMENT_MAX_COMMANDS:
|
|
980
|
+
raise HookError(
|
|
981
|
+
f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
|
|
982
|
+
)
|
|
983
|
+
validated_commands = [validate_beads_alignment_command(command) for command in commands]
|
|
984
|
+
beads_executable = resolve_beads_executable(context.repo_root) if commands else ""
|
|
985
|
+
command_env = beads_alignment_env()
|
|
399
986
|
report_path = _report_file_path(context, state)
|
|
400
987
|
commands_run: list[str] = []
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
988
|
+
started_at = time.monotonic()
|
|
989
|
+
for command, argv in zip(commands, validated_commands):
|
|
990
|
+
remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (time.monotonic() - started_at)
|
|
991
|
+
if remaining <= 0:
|
|
992
|
+
raise HookError(
|
|
993
|
+
f"Beads alignment exceeded its {BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS}-second total budget"
|
|
994
|
+
)
|
|
995
|
+
run_command(
|
|
996
|
+
[beads_executable, *argv[1:]],
|
|
997
|
+
cwd=context.repo_root,
|
|
998
|
+
timeout=min(BEADS_ALIGNMENT_TIMEOUT_SECONDS, remaining),
|
|
999
|
+
check=True,
|
|
1000
|
+
env=command_env,
|
|
1001
|
+
inherit_env=False,
|
|
1002
|
+
)
|
|
405
1003
|
commands_run.append(command)
|
|
406
1004
|
|
|
407
1005
|
report_markdown = str(payload.get("report_markdown", "")).strip()
|
|
@@ -410,9 +1008,14 @@ def beads_alignment_executor(
|
|
|
410
1008
|
if report_markdown:
|
|
411
1009
|
if not report_markdown.endswith("\n"):
|
|
412
1010
|
report_markdown += "\n"
|
|
413
|
-
write_text_file(report_path, report_markdown)
|
|
1011
|
+
if not write_text_file(report_path, report_markdown, root=context.repo_root):
|
|
1012
|
+
raise HookError(f"Failed to write Beads alignment report: {report_path}")
|
|
414
1013
|
report_written = True
|
|
415
1014
|
elif report_path.exists() and not unresolved:
|
|
1015
|
+
if path_has_symlink(context.repo_root, report_path) or not stat.S_ISREG(
|
|
1016
|
+
report_path.lstat().st_mode
|
|
1017
|
+
):
|
|
1018
|
+
raise HookError("Refusing to remove unsafe Beads alignment report path")
|
|
416
1019
|
report_path.unlink()
|
|
417
1020
|
|
|
418
1021
|
return {
|
|
@@ -432,33 +1035,59 @@ def gh_pr_create_executor(
|
|
|
432
1035
|
) -> dict[str, Any]:
|
|
433
1036
|
if state.metadata.get("skip_module"):
|
|
434
1037
|
return {"skipped": True, "pr_url": state.metadata.get("existing_pr_url", "")}
|
|
1038
|
+
branch_name = str(context.cache.get("branch_name", "")).strip()
|
|
1039
|
+
if not branch_name:
|
|
1040
|
+
reason = str(
|
|
1041
|
+
context.cache.get("branch_selection_reason", "no single pushed branch is available")
|
|
1042
|
+
)
|
|
1043
|
+
raise HookError(f"PR creation requires one pushed branch: {reason}")
|
|
1044
|
+
default_base_branch = context.config.general.base_branch.strip() or "main"
|
|
1045
|
+
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)
|
|
1048
|
+
return {
|
|
1049
|
+
"skipped": True,
|
|
1050
|
+
"pr_url": "",
|
|
1051
|
+
"deferred_until_remote": True,
|
|
1052
|
+
"reason": reason,
|
|
1053
|
+
}
|
|
435
1054
|
if shutil.which("gh") is None:
|
|
436
1055
|
raise HookError("`gh` is required for PR creation but is not installed")
|
|
1056
|
+
repository = resolve_github_repository(
|
|
1057
|
+
context.repo_root, context.remote_name, context.remote_url
|
|
1058
|
+
)
|
|
437
1059
|
payload = json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
438
|
-
|
|
439
|
-
|
|
1060
|
+
if not isinstance(payload, dict):
|
|
1061
|
+
raise HookError("PR creation payload must be an object")
|
|
1062
|
+
existing_pr = lookup_open_pr_url(
|
|
1063
|
+
context.repo_root, branch_name, default_base_branch, repository
|
|
1064
|
+
)
|
|
440
1065
|
if existing_pr:
|
|
441
1066
|
return {"skipped": False, "pr_url": existing_pr, "already_exists": True}
|
|
442
1067
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
head_branch = str(payload.get("head_branch", branch_name)).strip() or branch_name
|
|
1068
|
+
base_branch = default_base_branch
|
|
1069
|
+
head_branch = branch_name
|
|
446
1070
|
title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
|
|
447
1071
|
body = str(payload.get("body", "")).strip()
|
|
448
1072
|
if not body:
|
|
449
1073
|
commits = collect_commit_messages_for_ranges(
|
|
450
|
-
context.repo_root,
|
|
1074
|
+
context.repo_root,
|
|
1075
|
+
context.cache.get("branch_ranges", context.cache.get("ranges", [])),
|
|
451
1076
|
)
|
|
452
1077
|
body = build_fallback_pr_body(
|
|
453
1078
|
branch_name,
|
|
454
|
-
context.cache.get("ranges", []),
|
|
455
|
-
context.cache.get(
|
|
1079
|
+
context.cache.get("branch_ranges", context.cache.get("ranges", [])),
|
|
1080
|
+
context.cache.get(
|
|
1081
|
+
"branch_changed_files", context.cache.get("changed_files", [])
|
|
1082
|
+
),
|
|
456
1083
|
commits,
|
|
457
1084
|
)
|
|
458
1085
|
args = [
|
|
459
1086
|
"gh",
|
|
460
1087
|
"pr",
|
|
461
1088
|
"create",
|
|
1089
|
+
"--repo",
|
|
1090
|
+
repository,
|
|
462
1091
|
"--head",
|
|
463
1092
|
head_branch,
|
|
464
1093
|
"--base",
|
|
@@ -474,14 +1103,13 @@ def gh_pr_create_executor(
|
|
|
474
1103
|
combined_output = "\n".join([(created.stdout or "").strip(), (created.stderr or "").strip()])
|
|
475
1104
|
pr_url = extract_pr_url(combined_output)
|
|
476
1105
|
if created.returncode != 0 and not pr_url:
|
|
477
|
-
pr_url = lookup_open_pr_url(
|
|
1106
|
+
pr_url = lookup_open_pr_url(
|
|
1107
|
+
context.repo_root, branch_name, default_base_branch, repository
|
|
1108
|
+
)
|
|
478
1109
|
if not pr_url:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
or f"gh pr create failed with exit code {created.returncode}"
|
|
483
|
-
)
|
|
484
|
-
return {"skipped": False, "pr_url": "", "deferred_until_remote": True}
|
|
1110
|
+
raise HookError(
|
|
1111
|
+
combined_output.strip() or f"gh pr create failed with exit code {created.returncode}"
|
|
1112
|
+
)
|
|
485
1113
|
return {"skipped": False, "pr_url": pr_url, "already_exists": False}
|
|
486
1114
|
|
|
487
1115
|
|