ai-push-hooks 0.1.18 → 0.2.0

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,81 @@ 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
+ GITHUB_REPOSITORY_COMPONENT = re.compile(r"[A-Za-z0-9_.-]+\Z")
82
+ GIT_DIFF_CHUNK_BYTES = 64 * 1024
83
+ GIT_ERROR_BYTES = 64 * 1024
84
+ DIFF_TRUNCATION_MARKER = "\n[diff truncated]\n"
23
85
 
24
86
 
25
87
  def env_bool(name: str) -> bool | None:
@@ -38,13 +100,13 @@ def run_command(
38
100
  args: list[str],
39
101
  cwd: pathlib.Path,
40
102
  input_text: str | None = None,
41
- timeout: int | None = None,
103
+ timeout: float | None = None,
42
104
  check: bool = False,
43
105
  env: dict[str, str | None] | None = None,
106
+ inherit_env: bool = True,
44
107
  ) -> subprocess.CompletedProcess[str]:
45
- merged_env = None
108
+ merged_env = os.environ.copy() if inherit_env else {}
46
109
  if env is not None:
47
- merged_env = os.environ.copy()
48
110
  for key, value in env.items():
49
111
  if value is None:
50
112
  merged_env.pop(key, None)
@@ -55,6 +117,7 @@ def run_command(
55
117
  cwd=cwd,
56
118
  input=input_text,
57
119
  text=True,
120
+ errors="surrogateescape",
58
121
  capture_output=True,
59
122
  timeout=timeout,
60
123
  env=merged_env,
@@ -84,22 +147,37 @@ def resolve_git_dir(repo_root: pathlib.Path) -> pathlib.Path:
84
147
  return (repo_root / path).resolve()
85
148
 
86
149
 
87
- def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str) -> pathlib.Path:
150
+ def resolve_git_common_dir(repo_root: pathlib.Path) -> pathlib.Path:
151
+ raw = git(repo_root, ["rev-parse", "--git-common-dir"])
88
152
  path = pathlib.Path(raw)
89
153
  if path.is_absolute():
90
- return path
154
+ return path.resolve()
155
+ return (repo_root / path).resolve()
156
+
157
+
158
+ def resolve_storage_path(repo_root: pathlib.Path, git_dir: pathlib.Path, raw: str) -> pathlib.Path:
159
+ parts = relative_path_parts(raw, "Configured storage path")
91
160
  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
161
+ if parts[0] == ".git":
162
+ if len(parts) == 1:
163
+ return pathlib.Path(git_dir).resolve(strict=False)
164
+ lexical_path = pathlib.Path(git_dir).joinpath(*parts[1:])
165
+ if path_has_symlink(pathlib.Path(git_dir), lexical_path):
166
+ raise HookError(f"Configured Git storage path must not traverse a symlink: {raw}")
167
+ return resolve_contained_path(
168
+ git_dir,
169
+ "/".join(parts[1:]),
170
+ "Configured Git storage path",
171
+ )
172
+ lexical_path = repo_root.joinpath(*parts)
173
+ if path_has_symlink(repo_root, lexical_path):
174
+ raise HookError(f"Configured repository storage path must not traverse a symlink: {raw}")
175
+ return resolve_contained_path(repo_root, posix_raw, "Configured repository storage path")
97
176
 
98
177
 
99
178
  def ensure_dir(path: pathlib.Path) -> pathlib.Path | None:
100
179
  try:
101
- path.mkdir(parents=True, exist_ok=True)
102
- return path
180
+ return ensure_private_directory(path)
103
181
  except Exception: # noqa: BLE001
104
182
  return None
105
183
 
@@ -112,101 +190,387 @@ def is_feature_branch(branch_name: str) -> bool:
112
190
  return bool(branch_name) and branch_name.startswith(FEATURE_BRANCH_PREFIXES)
113
191
 
114
192
 
115
- def should_skip_for_sync_branch(repo_root: pathlib.Path) -> tuple[bool, str]:
193
+ def should_skip_for_sync_branch(
194
+ repo_root: pathlib.Path,
195
+ pushed_branches: list[str] | None = None,
196
+ push_updates: list[PushRefUpdate] | None = None,
197
+ ) -> tuple[bool, str]:
116
198
  sync_branch = os.getenv("BEADS_SYNC_BRANCH", "beads-sync")
199
+ if pushed_branches is None:
200
+ pushed_branches = [current_branch(repo_root)]
201
+ if push_updates is not None:
202
+ only_sync_branch_updates = bool(push_updates) and all(
203
+ update.ref_kind == "branch"
204
+ and update.operation != "delete"
205
+ and update.branch_name == sync_branch
206
+ for update in push_updates
207
+ )
208
+ if push_updates and not only_sync_branch_updates:
209
+ return False, ""
210
+ else:
211
+ only_sync_branch_updates = bool(pushed_branches) and all(
212
+ branch_name == sync_branch for branch_name in pushed_branches
213
+ )
117
214
  if "/.beads-sync-worktrees/" in repo_root.as_posix():
118
215
  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}"
216
+ if only_sync_branch_updates:
217
+ return True, f"all pushed branches are {sync_branch}"
122
218
  return False, ""
123
219
 
124
220
 
125
221
  def path_matches(path: str, pattern: str) -> bool:
126
- pure = PurePosixPath(path)
127
- return pure.match(pattern) or fnmatch.fnmatch(path, pattern)
222
+ path_parts = tuple(path.split("/"))
223
+ if (
224
+ not path_parts
225
+ or path.startswith("/")
226
+ or any(part in {"", ".", ".."} for part in path_parts)
227
+ ):
228
+ return False
229
+ try:
230
+ pattern_parts = relative_path_parts(pattern, "Glob pattern")
231
+ except HookError:
232
+ return False
233
+
234
+ memo: dict[tuple[int, int], bool] = {}
235
+
236
+ def matches(path_index: int, pattern_index: int) -> bool:
237
+ key = (path_index, pattern_index)
238
+ if key in memo:
239
+ return memo[key]
240
+ if pattern_index == len(pattern_parts):
241
+ result = path_index == len(path_parts)
242
+ elif pattern_parts[pattern_index] == "**":
243
+ result = matches(path_index, pattern_index + 1) or (
244
+ path_index < len(path_parts) and matches(path_index + 1, pattern_index)
245
+ )
246
+ else:
247
+ result = path_index < len(path_parts) and fnmatch.fnmatchcase(
248
+ path_parts[path_index], pattern_parts[pattern_index]
249
+ ) and matches(path_index + 1, pattern_index + 1)
250
+ memo[key] = result
251
+ return result
252
+
253
+ return matches(0, 0)
128
254
 
129
255
 
130
256
  def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
131
257
  changes: set[str] = set()
132
- output = git(repo_root, ["status", "--short"], check=False)
133
- for line in output.splitlines():
134
- payload = line[3:].strip()
135
- if payload:
136
- changes.add(payload)
258
+ output = run_command(
259
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
260
+ cwd=repo_root,
261
+ ).stdout
262
+ records = output.split("\x00")
263
+ index = 0
264
+ while index < len(records):
265
+ record = records[index]
266
+ index += 1
267
+ if not record:
268
+ continue
269
+ if len(record) < 4 or record[2] != " ":
270
+ raise HookError("Malformed output from `git status --porcelain=v1 -z`")
271
+ status = record[:2]
272
+ changes.add(record[3:])
273
+ if "R" in status or "C" in status:
274
+ if index >= len(records) or not records[index]:
275
+ raise HookError("Malformed rename output from `git status --porcelain=v1 -z`")
276
+ changes.add(records[index])
277
+ index += 1
137
278
  return changes
138
279
 
139
280
 
140
- def collect_ranges_from_stdin(
281
+ def parse_push_updates(stdin_lines: list[str]) -> list[PushRefUpdate]:
282
+ updates: list[PushRefUpdate] = []
283
+ oid_pattern = re.compile(r"[0-9a-fA-F]+\Z")
284
+ for line_number, line in enumerate(stdin_lines, start=1):
285
+ if not line.strip():
286
+ continue
287
+ parts = line.split()
288
+ if len(parts) != 4:
289
+ raise HookError(
290
+ f"Malformed pre-push input on line {line_number}: expected four fields"
291
+ )
292
+ local_ref, local_sha, remote_ref, remote_sha = parts
293
+ if (
294
+ len(local_sha) not in ZERO_OID_LENGTHS
295
+ or len(remote_sha) != len(local_sha)
296
+ or oid_pattern.fullmatch(local_sha) is None
297
+ or oid_pattern.fullmatch(remote_sha) is None
298
+ ):
299
+ raise HookError(
300
+ f"Malformed pre-push input on line {line_number}: expected full SHA-1 or SHA-256 object IDs"
301
+ )
302
+ updates.append(
303
+ PushRefUpdate(
304
+ local_ref=local_ref,
305
+ local_sha=local_sha.lower(),
306
+ remote_ref=remote_ref,
307
+ remote_sha=remote_sha.lower(),
308
+ )
309
+ )
310
+ return updates
311
+
312
+
313
+ def _resolve_commit(repo_root: pathlib.Path, oid: str) -> str:
314
+ return git(repo_root, ["rev-parse", "--verify", "--quiet", f"{oid}^{{commit}}"], check=False)
315
+
316
+
317
+ def _configured_base_commit(
318
+ repo_root: pathlib.Path, remote_name: str, base_branch: str
319
+ ) -> str:
320
+ base_branch = base_branch.strip() or "main"
321
+ candidates: list[str] = []
322
+ if base_branch.startswith("refs/"):
323
+ candidates.append(base_branch)
324
+ else:
325
+ configured_remotes = set(git(repo_root, ["remote"], check=False).splitlines())
326
+ if remote_name in configured_remotes:
327
+ candidates.append(f"refs/remotes/{remote_name}/{base_branch}")
328
+ candidates.append(f"refs/heads/{base_branch}")
329
+ for candidate in candidates:
330
+ commit = _resolve_commit(repo_root, candidate)
331
+ if commit:
332
+ return commit
333
+ return ""
334
+
335
+
336
+ def _empty_tree_oid(repo_root: pathlib.Path) -> str:
337
+ completed = run_command(
338
+ ["git", "hash-object", "-t", "tree", "--stdin"],
339
+ cwd=repo_root,
340
+ input_text="",
341
+ check=True,
342
+ )
343
+ return (completed.stdout or "").strip()
344
+
345
+
346
+ def _fallback_range(
141
347
  repo_root: pathlib.Path,
142
348
  remote_name: str,
143
- stdin_lines: list[str],
144
- ) -> list[str]:
145
- ranges: set[str] = set()
146
- for line in stdin_lines:
147
- parts = line.strip().split()
148
- if len(parts) < 4:
349
+ base_branch: str,
350
+ local_commit: str,
351
+ *,
352
+ reason: str,
353
+ ) -> tuple[str, str]:
354
+ base_commit = _configured_base_commit(repo_root, remote_name, base_branch)
355
+ if base_commit:
356
+ merge_base = git(repo_root, ["merge-base", local_commit, base_commit], check=False)
357
+ if merge_base:
358
+ return f"{merge_base}..{local_commit}", f"{reason}:configured-base"
359
+ return f"{_empty_tree_oid(repo_root)}..{local_commit}", f"{reason}:empty-tree"
360
+
361
+
362
+ def collect_revision_ranges(
363
+ repo_root: pathlib.Path,
364
+ remote_name: str,
365
+ updates: list[PushRefUpdate],
366
+ base_branch: str = "main",
367
+ ) -> list[PushRevisionRange]:
368
+ ranges: list[PushRevisionRange] = []
369
+ for update in updates:
370
+ if update.operation == "delete":
149
371
  continue
150
- _local_ref, local_sha, _remote_ref, remote_sha = parts[:4]
151
- if local_sha == ZERO_OID:
372
+ local_commit = _resolve_commit(repo_root, update.local_sha)
373
+ if not local_commit:
374
+ # Tags may legally point to non-commit objects. They still remain in
375
+ # push_updates, but there is no commit/tree diff to collect for them.
152
376
  continue
153
- if remote_sha and remote_sha != ZERO_OID:
154
- if (
155
- run_command(
156
- ["git", "cat-file", "-e", f"{remote_sha}^{{commit}}"], cwd=repo_root
157
- ).returncode
158
- == 0
159
- ):
160
- ranges.add(f"{remote_sha}..{local_sha}")
377
+ if update.operation == "update":
378
+ remote_commit = _resolve_commit(repo_root, update.remote_sha)
379
+ if remote_commit:
380
+ expression = f"{remote_commit}..{local_commit}"
381
+ strategy = "remote-object"
382
+ else:
383
+ raise HookError(
384
+ "Advertised remote commit is unavailable locally; refusing to "
385
+ f"approximate push range for {update.remote_ref}: {update.remote_sha}"
386
+ )
161
387
  else:
162
- merge_base = git(
163
- repo_root, ["merge-base", local_sha, f"{remote_name}/main"], check=False
388
+ expression, strategy = _fallback_range(
389
+ repo_root,
390
+ remote_name,
391
+ base_branch,
392
+ local_commit,
393
+ reason="new-ref",
164
394
  )
165
- if merge_base:
166
- ranges.add(f"{merge_base}..{local_sha}")
167
- else:
168
- ranges.add(f"{local_sha}~1..{local_sha}")
169
- if ranges:
170
- return sorted(ranges)
395
+ ranges.append(
396
+ PushRevisionRange(update=update, expression=expression, strategy=strategy)
397
+ )
398
+ return ranges
399
+
171
400
 
172
- upstream = git(
173
- repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], check=False
401
+ def unique_range_expressions(ranges: list[PushRevisionRange]) -> list[str]:
402
+ return list(dict.fromkeys(item.expression for item in ranges))
403
+
404
+
405
+ def collect_ranges_from_stdin(
406
+ repo_root: pathlib.Path,
407
+ remote_name: str,
408
+ stdin_lines: list[str],
409
+ base_branch: str = "main",
410
+ ) -> list[str]:
411
+ updates = parse_push_updates(stdin_lines)
412
+ return unique_range_expressions(
413
+ collect_revision_ranges(repo_root, remote_name, updates, base_branch)
174
414
  )
175
- if upstream:
176
- merge_base = git(repo_root, ["merge-base", "HEAD", upstream], check=False)
177
- if merge_base:
178
- return [f"{merge_base}..HEAD"]
179
- previous = git(repo_root, ["rev-parse", "HEAD~1"], check=False)
180
- if previous:
181
- return [f"{previous}..HEAD"]
182
- return []
183
415
 
184
416
 
185
417
  def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[str]:
186
418
  files: set[str] = set()
187
419
  for range_expr in ranges:
188
- output = git(
189
- repo_root, ["diff", "--name-only", "--diff-filter=ACMR", range_expr], check=True
190
- )
191
- for line in output.splitlines():
192
- clean = line.strip()
193
- if clean:
194
- files.add(clean)
420
+ output = run_command(
421
+ [
422
+ "git",
423
+ "diff",
424
+ "--name-only",
425
+ "--diff-filter=ACMRD",
426
+ "-z",
427
+ range_expr,
428
+ ],
429
+ cwd=repo_root,
430
+ check=True,
431
+ ).stdout
432
+ for path in output.split("\x00"):
433
+ if path:
434
+ files.add(path)
195
435
  return sorted(files)
196
436
 
197
437
 
438
+ def _read_bounded_stderr(stream: Any, captured: bytearray) -> None:
439
+ try:
440
+ while True:
441
+ chunk = stream.read(GIT_DIFF_CHUNK_BYTES)
442
+ if not chunk:
443
+ return
444
+ remaining = GIT_ERROR_BYTES - len(captured)
445
+ if remaining > 0:
446
+ captured.extend(chunk[:remaining])
447
+ except (OSError, ValueError):
448
+ return
449
+
450
+
451
+ def _terminate_and_wait(process: subprocess.Popen[bytes]) -> int:
452
+ if process.poll() is None:
453
+ process.terminate()
454
+ try:
455
+ return process.wait(timeout=5)
456
+ except subprocess.TimeoutExpired:
457
+ process.kill()
458
+ try:
459
+ return process.wait(timeout=5)
460
+ except subprocess.TimeoutExpired as error:
461
+ raise HookError("Git diff process did not terminate safely") from error
462
+
463
+
464
+ def _collect_bounded_git_diff(
465
+ repo_root: pathlib.Path, args: list[str], max_bytes: int
466
+ ) -> tuple[bytes, bool]:
467
+ process = subprocess.Popen(
468
+ args,
469
+ cwd=repo_root,
470
+ stdout=subprocess.PIPE,
471
+ stderr=subprocess.PIPE,
472
+ )
473
+ if process.stdout is None or process.stderr is None:
474
+ raise HookError("Could not capture Git diff output")
475
+
476
+ stderr = bytearray()
477
+ stderr_thread = threading.Thread(
478
+ target=_read_bounded_stderr,
479
+ args=(process.stderr, stderr),
480
+ daemon=True,
481
+ )
482
+ stderr_thread.start()
483
+ output = bytearray()
484
+ limit = max(0, max_bytes)
485
+ truncated = False
486
+ returncode: int | None = None
487
+ try:
488
+ while True:
489
+ remaining = limit - len(output)
490
+ chunk = process.stdout.read(min(GIT_DIFF_CHUNK_BYTES, remaining + 1))
491
+ if not chunk:
492
+ break
493
+ if len(chunk) > remaining:
494
+ if remaining > 0:
495
+ output.extend(chunk[:remaining])
496
+ truncated = True
497
+ returncode = _terminate_and_wait(process)
498
+ break
499
+ output.extend(chunk)
500
+ if returncode is None:
501
+ returncode = process.wait()
502
+ finally:
503
+ if process.poll() is None:
504
+ _terminate_and_wait(process)
505
+ stderr_thread.join(timeout=5)
506
+ if stderr_thread.is_alive():
507
+ process.stderr.close()
508
+ stderr_thread.join(timeout=5)
509
+ process.stdout.close()
510
+ process.stderr.close()
511
+
512
+ if returncode != 0 and not truncated:
513
+ details = bytes(stderr).decode("utf-8", errors="surrogateescape").strip()
514
+ details = details or f"exit code {returncode}"
515
+ raise HookError(f"Command failed: {' '.join(args)} :: {details}")
516
+ return bytes(output), truncated
517
+
518
+
519
+ def _decode_diff_output(output: bytes, max_bytes: int, truncated: bool) -> str:
520
+ if not truncated:
521
+ return output.decode("utf-8", errors="surrogateescape")
522
+ limit = max(0, max_bytes)
523
+ if limit == 0:
524
+ return ""
525
+ marker = DIFF_TRUNCATION_MARKER.encode("utf-8")
526
+ if len(marker) >= limit:
527
+ return marker[:limit].decode("utf-8", errors="surrogateescape")
528
+ return (output[: limit - len(marker)] + marker).decode(
529
+ "utf-8", errors="surrogateescape"
530
+ )
531
+
532
+
198
533
  def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) -> str:
199
- chunks: list[str] = []
200
- for range_expr in ranges:
201
- body = git(repo_root, ["diff", "--unified=3", range_expr], check=True)
202
- chunks.append(f"### RANGE {range_expr}\n{body}\n")
203
- return "\n".join(chunks)[:max_bytes]
534
+ output = bytearray()
535
+ limit = max(0, max_bytes)
536
+ truncated = False
537
+ for index, range_expr in enumerate(ranges):
538
+ prefix = ("\n" if index else "") + f"### RANGE {range_expr}\n"
539
+ prefix_bytes = prefix.encode("utf-8", errors="surrogateescape")
540
+ remaining = limit - len(output)
541
+ if len(prefix_bytes) > remaining:
542
+ output.extend(prefix_bytes[:remaining])
543
+ truncated = True
544
+ break
545
+ output.extend(prefix_bytes)
546
+
547
+ body, body_truncated = _collect_bounded_git_diff(
548
+ repo_root,
549
+ ["git", "diff", "--unified=3", range_expr],
550
+ limit - len(output),
551
+ )
552
+ if not body_truncated:
553
+ # `git()` historically stripped the captured diff before adding the
554
+ # section's trailing newline. Keep that output shape when the body
555
+ # fits, without ever collecting more than the remaining budget.
556
+ body = body.rstrip()
557
+ output.extend(body)
558
+ if body_truncated:
559
+ truncated = True
560
+ break
561
+
562
+ if len(output) >= limit:
563
+ truncated = True
564
+ break
565
+ output.extend(b"\n")
566
+ return _decode_diff_output(bytes(output), limit, truncated)
204
567
 
205
568
 
206
569
  def collect_commit_messages_for_ranges(
207
570
  repo_root: pathlib.Path, ranges: list[str]
208
571
  ) -> list[dict[str, str]]:
209
572
  commits: list[dict[str, str]] = []
573
+ seen_hashes: set[str] = set()
210
574
  for range_expr in ranges:
211
575
  completed = run_command(
212
576
  ["git", "log", "--format=%H%x1f%s%x1f%b%x1e", range_expr],
@@ -226,9 +590,13 @@ def collect_commit_messages_for_ranges(
226
590
  commit_hash, subject, body = parts
227
591
  else:
228
592
  continue
593
+ clean_hash = commit_hash.strip()
594
+ if not clean_hash or clean_hash in seen_hashes:
595
+ continue
596
+ seen_hashes.add(clean_hash)
229
597
  commits.append(
230
598
  {
231
- "hash": commit_hash.strip(),
599
+ "hash": clean_hash,
232
600
  "subject": subject.strip(),
233
601
  "body": body.strip(),
234
602
  }
@@ -236,10 +604,35 @@ def collect_commit_messages_for_ranges(
236
604
  return commits
237
605
 
238
606
 
239
- def write_text_file(path: pathlib.Path, content: str) -> bool:
607
+ def write_text_file(
608
+ path: pathlib.Path,
609
+ content: str,
610
+ *,
611
+ root: pathlib.Path | None = None,
612
+ ) -> bool:
240
613
  try:
241
- path.parent.mkdir(parents=True, exist_ok=True)
242
- path.write_text(content, encoding="utf-8")
614
+ if root is None:
615
+ path.parent.mkdir(parents=True, exist_ok=True)
616
+ else:
617
+ root = root.resolve(strict=True)
618
+ lexical_path = pathlib.Path(os.path.abspath(path))
619
+ relative_parent = lexical_path.parent.relative_to(root)
620
+ current = root
621
+ for part in relative_parent.parts:
622
+ current = current / part
623
+ if path_is_link_or_reparse(current):
624
+ raise HookError(
625
+ f"Output path traverses a symlink or reparse point: {path}"
626
+ )
627
+ if not current.exists():
628
+ current.mkdir()
629
+ if not current.is_dir():
630
+ raise HookError(f"Output path has a non-directory parent: {path}")
631
+ if path_has_symlink(root, lexical_path):
632
+ raise HookError(f"Output path traverses a symlink: {path}")
633
+ if lexical_path.exists() and not stat.S_ISREG(lexical_path.lstat().st_mode):
634
+ raise HookError(f"Output path is not a regular file: {path}")
635
+ write_text_no_follow(path, content)
243
636
  return True
244
637
  except Exception: # noqa: BLE001
245
638
  return False
@@ -255,21 +648,93 @@ def parse_key_value_text(text: str) -> dict[str, str]:
255
648
  return payload
256
649
 
257
650
 
258
- def lookup_open_pr_url(repo_root: pathlib.Path, branch_name: str) -> str:
651
+ def _github_repository_from_url(remote_url: str) -> str:
652
+ value = remote_url.strip()
653
+ if not value or "\x00" in value or any(ord(character) < 32 for character in value):
654
+ return ""
655
+ scp_match = re.fullmatch(r"(?:[^@/:\s]+@)?github\.com:([^/\s]+)/([^/\s]+)", value, re.IGNORECASE)
656
+ if scp_match:
657
+ owner, repository = scp_match.groups()
658
+ else:
659
+ try:
660
+ parsed = urlsplit(value)
661
+ except ValueError:
662
+ return ""
663
+ if (
664
+ parsed.scheme.lower() not in {"git", "http", "https", "ssh"}
665
+ or (parsed.hostname or "").casefold() != "github.com"
666
+ or parsed.query
667
+ or parsed.fragment
668
+ or "%" in parsed.path
669
+ ):
670
+ return ""
671
+ parts = [part for part in parsed.path.split("/") if part]
672
+ if len(parts) != 2:
673
+ return ""
674
+ owner, repository = parts
675
+ if repository.endswith(".git"):
676
+ repository = repository[:-4]
677
+ if (
678
+ not owner
679
+ or not repository
680
+ or owner in {".", ".."}
681
+ or repository in {".", ".."}
682
+ or GITHUB_REPOSITORY_COMPONENT.fullmatch(owner) is None
683
+ or GITHUB_REPOSITORY_COMPONENT.fullmatch(repository) is None
684
+ ):
685
+ return ""
686
+ return f"{owner}/{repository}"
687
+
688
+
689
+ def resolve_github_repository(
690
+ repo_root: pathlib.Path, remote_name: str, remote_url: str
691
+ ) -> str:
692
+ repository = _github_repository_from_url(remote_url)
693
+ if repository:
694
+ return repository
695
+ if remote_url.strip():
696
+ raise HookError(f"Cannot safely determine GitHub repository from push remote URL: {remote_url!r}")
697
+ repository = _github_repository_from_url(remote_name)
698
+ if repository:
699
+ return repository
700
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", remote_name):
701
+ raise HookError(f"Cannot safely resolve push remote name: {remote_name!r}")
702
+ configured_url = git(repo_root, ["remote", "get-url", "--push", remote_name], check=False)
703
+ repository = _github_repository_from_url(configured_url)
704
+ if not repository:
705
+ raise HookError(
706
+ f"Cannot safely determine GitHub repository for push remote {remote_name!r}"
707
+ )
708
+ return repository
709
+
710
+
711
+ def lookup_open_pr_url(
712
+ repo_root: pathlib.Path,
713
+ branch_name: str,
714
+ base_branch: str = "",
715
+ repository: str = "",
716
+ ) -> str:
717
+ if not repository:
718
+ raise HookError("GitHub repository scope is required for PR lookup")
719
+ args = [
720
+ "gh",
721
+ "pr",
722
+ "list",
723
+ "--repo",
724
+ repository,
725
+ "--head",
726
+ branch_name,
727
+ "--state",
728
+ "open",
729
+ "--limit",
730
+ "1",
731
+ "--json",
732
+ "url",
733
+ ]
734
+ if base_branch:
735
+ args.extend(["--base", base_branch])
259
736
  completed = run_command(
260
- [
261
- "gh",
262
- "pr",
263
- "list",
264
- "--head",
265
- branch_name,
266
- "--state",
267
- "open",
268
- "--limit",
269
- "1",
270
- "--json",
271
- "url",
272
- ],
737
+ args,
273
738
  cwd=repo_root,
274
739
  check=False,
275
740
  )
@@ -295,6 +760,15 @@ def sanitize_pr_title(raw_title: str, branch_name: str) -> str:
295
760
  return title[:240]
296
761
 
297
762
 
763
+ def initial_pr_defer_reason(branch_name: str, base_branch: str) -> str:
764
+ return (
765
+ f"PR creation deferred because `{branch_name}` does not exist on the remote before "
766
+ "this initial push. Complete the push, then create the PR with "
767
+ f"`gh pr create --head {shlex.quote(branch_name)} --base "
768
+ f"{shlex.quote(base_branch)}`, or push another commit with PR creation enabled."
769
+ )
770
+
771
+
298
772
  def build_fallback_pr_body(
299
773
  branch_name: str,
300
774
  ranges: list[str],
@@ -331,6 +805,7 @@ def attempt_pr_creation_fallback(
331
805
  ranges: list[str],
332
806
  changed_files: list[str],
333
807
  commits: list[dict[str, str]],
808
+ repository: str,
334
809
  ) -> str:
335
810
  title = sanitize_pr_title(
336
811
  git(repo_root, ["log", "-1", "--pretty=%s"], check=False), branch_name
@@ -341,6 +816,8 @@ def attempt_pr_creation_fallback(
341
816
  "gh",
342
817
  "pr",
343
818
  "create",
819
+ "--repo",
820
+ repository,
344
821
  "--head",
345
822
  branch_name,
346
823
  "--base",
@@ -358,7 +835,7 @@ def attempt_pr_creation_fallback(
358
835
  pr_url = extract_pr_url(combined_output)
359
836
  if pr_url:
360
837
  return pr_url
361
- existing_pr = lookup_open_pr_url(repo_root, branch_name)
838
+ existing_pr = lookup_open_pr_url(repo_root, branch_name, base_branch, repository)
362
839
  if existing_pr:
363
840
  return existing_pr
364
841
  raise HookError(
@@ -378,8 +855,103 @@ def _report_file_path(context: RuntimeContext, state: ModuleRuntimeState) -> pat
378
855
  if branch_context and branch_context.exists():
379
856
  payload = parse_key_value_text(branch_context.read_text(encoding="utf-8"))
380
857
  report_file = payload.get("report_file", "BEADS_STATUS_ACTION_REQUIRED.md")
381
- return (context.repo_root / report_file).resolve()
382
- return (context.repo_root / "BEADS_STATUS_ACTION_REQUIRED.md").resolve()
858
+ else:
859
+ report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
860
+
861
+ parts = relative_path_parts(report_file, "Beads alignment report path")
862
+ if any(normalized_component(part) == ".git" for part in parts):
863
+ raise HookError("Beads alignment report path must not reference Git metadata")
864
+ lexical_path = context.repo_root.joinpath(*parts)
865
+ if path_has_symlink(context.repo_root, lexical_path):
866
+ raise HookError("Beads alignment report path must not traverse a symlink")
867
+ report_path = resolve_contained_path(
868
+ context.repo_root,
869
+ report_file,
870
+ "Beads alignment report path",
871
+ )
872
+ if report_path.exists() and not stat.S_ISREG(report_path.lstat().st_mode):
873
+ raise HookError("Beads alignment report path must be a regular file")
874
+ return report_path
875
+
876
+
877
+ def _validate_beads_issue_ids(values: list[str]) -> None:
878
+ if not values or len(values) > 20:
879
+ raise HookError("Beads alignment commands require between 1 and 20 issue ids")
880
+ for issue_id in values:
881
+ if not BEADS_ISSUE_ID_PATTERN.fullmatch(issue_id):
882
+ raise HookError(f"Invalid Beads issue id in alignment command: {issue_id!r}")
883
+
884
+
885
+ def validate_beads_alignment_command(command: str) -> list[str]:
886
+ if not isinstance(command, str) or not command.strip():
887
+ raise HookError("Beads alignment commands must be non-empty strings")
888
+ if len(command) > 4096 or "\x00" in command or any(ord(char) < 32 for char in command):
889
+ raise HookError("Beads alignment command contains invalid or excessive input")
890
+ try:
891
+ argv = shlex.split(command, posix=True)
892
+ except ValueError as exc:
893
+ raise HookError(f"Malformed Beads alignment command: {exc}") from exc
894
+
895
+ if len(argv) < 3 or argv[0] != "bd":
896
+ raise HookError("Beads alignment commands must use the literal `bd` executable")
897
+
898
+ subcommand = argv[1]
899
+ if subcommand == "update":
900
+ if len(argv) < 5 or argv[-2] != "--status" or argv[-1] not in BEADS_UPDATE_STATUSES:
901
+ raise HookError(
902
+ "Allowed Beads update form is: bd update <issue-id> [<issue-id> ...] "
903
+ "--status <open|in_progress|blocked>"
904
+ )
905
+ _validate_beads_issue_ids(argv[2:-2])
906
+ return argv
907
+
908
+ if subcommand == "close":
909
+ issue_ids = argv[2:]
910
+ if "--reason" in issue_ids:
911
+ if issue_ids.count("--reason") != 1 or issue_ids[-2] != "--reason":
912
+ raise HookError(
913
+ "Allowed Beads close form is: bd close <issue-id> [<issue-id> ...] "
914
+ "[--reason <text>]"
915
+ )
916
+ reason = issue_ids[-1]
917
+ if not reason or reason.startswith("-") or len(reason) > 500:
918
+ raise HookError("Invalid Beads close reason")
919
+ issue_ids = issue_ids[:-2]
920
+ _validate_beads_issue_ids(issue_ids)
921
+ return argv
922
+
923
+ raise HookError(
924
+ f"Beads alignment subcommand `{subcommand}` is not allowed; only `update` and `close` are permitted"
925
+ )
926
+
927
+
928
+ def resolve_beads_executable(repo_root: pathlib.Path) -> str:
929
+ candidate = shutil.which("bd")
930
+ if not candidate:
931
+ raise HookError("`bd` is required for Beads alignment but is not installed")
932
+ lexical_candidate = pathlib.Path(os.path.abspath(candidate))
933
+ resolved_repo_root = repo_root.resolve(strict=True)
934
+ if is_path_within(lexical_candidate, resolved_repo_root):
935
+ raise HookError(f"Refusing repository-contained `bd` executable: {lexical_candidate}")
936
+ try:
937
+ executable = lexical_candidate.resolve(strict=True)
938
+ except (OSError, RuntimeError) as exc:
939
+ raise HookError("Unable to safely resolve the `bd` executable") from exc
940
+ if is_path_within(executable, resolved_repo_root):
941
+ raise HookError(f"Refusing repository-contained `bd` executable: {executable}")
942
+ if path_is_link_or_reparse(executable) or not stat.S_ISREG(executable.stat().st_mode):
943
+ raise HookError(f"Resolved `bd` executable is not a regular file: {executable}")
944
+ if not os.access(executable, os.X_OK):
945
+ raise HookError(f"Resolved `bd` executable is not executable: {executable}")
946
+ return str(executable)
947
+
948
+
949
+ def beads_alignment_env() -> dict[str, str]:
950
+ return {
951
+ name: value
952
+ for name, value in os.environ.items()
953
+ if name in BEADS_ENV_NAMES or name.startswith(BEADS_ENV_PREFIXES)
954
+ }
383
955
 
384
956
 
385
957
  def beads_alignment_executor(
@@ -391,15 +963,35 @@ def beads_alignment_executor(
391
963
  if state.metadata.get("skip_module"):
392
964
  return {"skipped": True, "commands_run": [], "report_written": False, "unresolved": False}
393
965
  payload = json.loads(inputs[0].read_text(encoding="utf-8"))
966
+ if not isinstance(payload, dict):
967
+ raise HookError("beads_alignment payload must be an object")
394
968
  commands = payload.get("commands", [])
395
969
  if not isinstance(commands, list):
396
970
  raise HookError("beads_alignment commands must be an array")
971
+ if len(commands) > BEADS_ALIGNMENT_MAX_COMMANDS:
972
+ raise HookError(
973
+ f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
974
+ )
975
+ validated_commands = [validate_beads_alignment_command(command) for command in commands]
976
+ beads_executable = resolve_beads_executable(context.repo_root) if commands else ""
977
+ command_env = beads_alignment_env()
397
978
  report_path = _report_file_path(context, state)
398
979
  commands_run: list[str] = []
399
- for command in commands:
400
- if not isinstance(command, str) or not command.strip():
401
- continue
402
- run_command(shlex.split(command), cwd=context.repo_root, check=True)
980
+ started_at = time.monotonic()
981
+ for command, argv in zip(commands, validated_commands):
982
+ remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (time.monotonic() - started_at)
983
+ if remaining <= 0:
984
+ raise HookError(
985
+ f"Beads alignment exceeded its {BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS}-second total budget"
986
+ )
987
+ run_command(
988
+ [beads_executable, *argv[1:]],
989
+ cwd=context.repo_root,
990
+ timeout=min(BEADS_ALIGNMENT_TIMEOUT_SECONDS, remaining),
991
+ check=True,
992
+ env=command_env,
993
+ inherit_env=False,
994
+ )
403
995
  commands_run.append(command)
404
996
 
405
997
  report_markdown = str(payload.get("report_markdown", "")).strip()
@@ -408,9 +1000,14 @@ def beads_alignment_executor(
408
1000
  if report_markdown:
409
1001
  if not report_markdown.endswith("\n"):
410
1002
  report_markdown += "\n"
411
- write_text_file(report_path, report_markdown)
1003
+ if not write_text_file(report_path, report_markdown, root=context.repo_root):
1004
+ raise HookError(f"Failed to write Beads alignment report: {report_path}")
412
1005
  report_written = True
413
1006
  elif report_path.exists() and not unresolved:
1007
+ if path_has_symlink(context.repo_root, report_path) or not stat.S_ISREG(
1008
+ report_path.lstat().st_mode
1009
+ ):
1010
+ raise HookError("Refusing to remove unsafe Beads alignment report path")
414
1011
  report_path.unlink()
415
1012
 
416
1013
  return {
@@ -430,32 +1027,59 @@ def gh_pr_create_executor(
430
1027
  ) -> dict[str, Any]:
431
1028
  if state.metadata.get("skip_module"):
432
1029
  return {"skipped": True, "pr_url": state.metadata.get("existing_pr_url", "")}
1030
+ branch_name = str(context.cache.get("branch_name", "")).strip()
1031
+ if not branch_name:
1032
+ reason = str(
1033
+ context.cache.get("branch_selection_reason", "no single pushed branch is available")
1034
+ )
1035
+ raise HookError(f"PR creation requires one pushed branch: {reason}")
1036
+ default_base_branch = context.config.general.base_branch.strip() or "main"
1037
+ if bool(context.cache.get("branch_is_new", False)):
1038
+ reason = initial_pr_defer_reason(branch_name, default_base_branch)
1039
+ context.logger.warn("pr.create_deferred", reason, branch=branch_name)
1040
+ return {
1041
+ "skipped": True,
1042
+ "pr_url": "",
1043
+ "deferred_until_remote": True,
1044
+ "reason": reason,
1045
+ }
433
1046
  if shutil.which("gh") is None:
434
1047
  raise HookError("`gh` is required for PR creation but is not installed")
1048
+ repository = resolve_github_repository(
1049
+ context.repo_root, context.remote_name, context.remote_url
1050
+ )
435
1051
  payload = json.loads(inputs[0].read_text(encoding="utf-8"))
436
- branch_name = current_branch(context.repo_root)
437
- existing_pr = lookup_open_pr_url(context.repo_root, branch_name)
1052
+ if not isinstance(payload, dict):
1053
+ raise HookError("PR creation payload must be an object")
1054
+ existing_pr = lookup_open_pr_url(
1055
+ context.repo_root, branch_name, default_base_branch, repository
1056
+ )
438
1057
  if existing_pr:
439
1058
  return {"skipped": False, "pr_url": existing_pr, "already_exists": True}
440
1059
 
441
- base_branch = str(payload.get("base_branch", "main")).strip() or "main"
442
- head_branch = str(payload.get("head_branch", branch_name)).strip() or branch_name
1060
+ base_branch = default_base_branch
1061
+ head_branch = branch_name
443
1062
  title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
444
1063
  body = str(payload.get("body", "")).strip()
445
1064
  if not body:
446
1065
  commits = collect_commit_messages_for_ranges(
447
- context.repo_root, context.cache.get("ranges", [])
1066
+ context.repo_root,
1067
+ context.cache.get("branch_ranges", context.cache.get("ranges", [])),
448
1068
  )
449
1069
  body = build_fallback_pr_body(
450
1070
  branch_name,
451
- context.cache.get("ranges", []),
452
- context.cache.get("changed_files", []),
1071
+ context.cache.get("branch_ranges", context.cache.get("ranges", [])),
1072
+ context.cache.get(
1073
+ "branch_changed_files", context.cache.get("changed_files", [])
1074
+ ),
453
1075
  commits,
454
1076
  )
455
1077
  args = [
456
1078
  "gh",
457
1079
  "pr",
458
1080
  "create",
1081
+ "--repo",
1082
+ repository,
459
1083
  "--head",
460
1084
  head_branch,
461
1085
  "--base",
@@ -471,14 +1095,13 @@ def gh_pr_create_executor(
471
1095
  combined_output = "\n".join([(created.stdout or "").strip(), (created.stderr or "").strip()])
472
1096
  pr_url = extract_pr_url(combined_output)
473
1097
  if created.returncode != 0 and not pr_url:
474
- pr_url = lookup_open_pr_url(context.repo_root, branch_name)
1098
+ pr_url = lookup_open_pr_url(
1099
+ context.repo_root, branch_name, default_base_branch, repository
1100
+ )
475
1101
  if not pr_url:
476
- if remote_branch_exists(context.repo_root, context.remote_name or "origin", branch_name):
477
- raise HookError(
478
- combined_output.strip()
479
- or f"gh pr create failed with exit code {created.returncode}"
480
- )
481
- return {"skipped": False, "pr_url": "", "deferred_until_remote": True}
1102
+ raise HookError(
1103
+ combined_output.strip() or f"gh pr create failed with exit code {created.returncode}"
1104
+ )
482
1105
  return {"skipped": False, "pr_url": pr_url, "already_exists": False}
483
1106
 
484
1107