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.
@@ -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
- from pathlib import PurePosixPath
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 = "0000000000000000000000000000000000000000"
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: int | None = None,
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 = None
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 resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str) -> pathlib.Path:
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 posix_raw == ".git":
93
- return git_dir
94
- if posix_raw.startswith(".git/"):
95
- return git_dir / posix_raw[len(".git/") :]
96
- return repo_root / path
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.mkdir(parents=True, exist_ok=True)
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(repo_root: pathlib.Path) -> tuple[bool, str]:
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
- branch_name = current_branch(repo_root)
120
- if branch_name == sync_branch:
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
- pure = PurePosixPath(path)
127
- return pure.match(pattern) or fnmatch.fnmatch(path, pattern)
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(["git", "status", "--short"], cwd=repo_root).stdout
133
- for line in output.splitlines():
134
- payload = line[3:].strip()
135
- if payload:
136
- changes.add(payload)
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 collect_ranges_from_stdin(
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
- stdin_lines: list[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],
144
373
  base_branch: str = "main",
145
- ) -> list[str]:
146
- base_branch = base_branch.strip() or "main"
147
- ranges: set[str] = set()
148
- for line in stdin_lines:
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
- _local_ref, local_sha, _remote_ref, remote_sha = parts[:4]
153
- if local_sha == ZERO_OID:
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 remote_sha and remote_sha != ZERO_OID:
156
- if (
157
- run_command(
158
- ["git", "cat-file", "-e", f"{remote_sha}^{{commit}}"], cwd=repo_root
159
- ).returncode
160
- == 0
161
- ):
162
- ranges.add(f"{remote_sha}..{local_sha}")
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
- merge_base = git(
165
- repo_root, ["merge-base", local_sha, f"{remote_name}/{base_branch}"], check=False
395
+ expression, strategy = _fallback_range(
396
+ repo_root,
397
+ remote_name,
398
+ base_branch,
399
+ local_commit,
400
+ reason="new-ref",
166
401
  )
167
- if merge_base:
168
- ranges.add(f"{merge_base}..{local_sha}")
169
- else:
170
- ranges.add(f"{local_sha}~1..{local_sha}")
171
- if ranges:
172
- return sorted(ranges)
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
- upstream = git(
175
- repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], check=False
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 = git(
191
- repo_root, ["diff", "--name-only", "--diff-filter=ACMR", range_expr], check=True
192
- )
193
- for line in output.splitlines():
194
- clean = line.strip()
195
- if clean:
196
- files.add(clean)
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
- chunks: list[str] = []
202
- for range_expr in ranges:
203
- body = git(repo_root, ["diff", "--unified=3", range_expr], check=True)
204
- chunks.append(f"### RANGE {range_expr}\n{body}\n")
205
- return "\n".join(chunks)[:max_bytes]
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": commit_hash.strip(),
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(path: pathlib.Path, content: str) -> bool:
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
- path.parent.mkdir(parents=True, exist_ok=True)
244
- path.write_text(content, encoding="utf-8")
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 lookup_open_pr_url(repo_root: pathlib.Path, branch_name: str) -> str:
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
- return (context.repo_root / report_file).resolve()
384
- return (context.repo_root / "BEADS_STATUS_ACTION_REQUIRED.md").resolve()
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
- for command in commands:
402
- if not isinstance(command, str) or not command.strip():
403
- continue
404
- run_command(shlex.split(command), cwd=context.repo_root, check=True)
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
- branch_name = current_branch(context.repo_root)
439
- existing_pr = lookup_open_pr_url(context.repo_root, branch_name)
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
- default_base_branch = context.config.general.base_branch.strip() or "main"
444
- base_branch = str(payload.get("base_branch", default_base_branch)).strip() or default_base_branch
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, context.cache.get("ranges", [])
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("changed_files", []),
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(context.repo_root, branch_name)
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
- if remote_branch_exists(context.repo_root, context.remote_name or "origin", branch_name):
480
- raise HookError(
481
- combined_output.strip()
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