ai-push-hooks 0.1.19 → 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,103 +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 = 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)
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],
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],
144
366
  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:
367
+ ) -> list[PushRevisionRange]:
368
+ ranges: list[PushRevisionRange] = []
369
+ for update in updates:
370
+ if update.operation == "delete":
151
371
  continue
152
- _local_ref, local_sha, _remote_ref, remote_sha = parts[:4]
153
- 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.
154
376
  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}")
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
+ )
163
387
  else:
164
- merge_base = git(
165
- repo_root, ["merge-base", local_sha, f"{remote_name}/{base_branch}"], check=False
388
+ expression, strategy = _fallback_range(
389
+ repo_root,
390
+ remote_name,
391
+ base_branch,
392
+ local_commit,
393
+ reason="new-ref",
166
394
  )
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)
395
+ ranges.append(
396
+ PushRevisionRange(update=update, expression=expression, strategy=strategy)
397
+ )
398
+ return ranges
399
+
173
400
 
174
- upstream = git(
175
- 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)
176
414
  )
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
415
 
186
416
 
187
417
  def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[str]:
188
418
  files: set[str] = set()
189
419
  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)
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)
197
435
  return sorted(files)
198
436
 
199
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
+
200
533
  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]
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)
206
567
 
207
568
 
208
569
  def collect_commit_messages_for_ranges(
209
570
  repo_root: pathlib.Path, ranges: list[str]
210
571
  ) -> list[dict[str, str]]:
211
572
  commits: list[dict[str, str]] = []
573
+ seen_hashes: set[str] = set()
212
574
  for range_expr in ranges:
213
575
  completed = run_command(
214
576
  ["git", "log", "--format=%H%x1f%s%x1f%b%x1e", range_expr],
@@ -228,9 +590,13 @@ def collect_commit_messages_for_ranges(
228
590
  commit_hash, subject, body = parts
229
591
  else:
230
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)
231
597
  commits.append(
232
598
  {
233
- "hash": commit_hash.strip(),
599
+ "hash": clean_hash,
234
600
  "subject": subject.strip(),
235
601
  "body": body.strip(),
236
602
  }
@@ -238,10 +604,35 @@ def collect_commit_messages_for_ranges(
238
604
  return commits
239
605
 
240
606
 
241
- 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:
242
613
  try:
243
- path.parent.mkdir(parents=True, exist_ok=True)
244
- 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)
245
636
  return True
246
637
  except Exception: # noqa: BLE001
247
638
  return False
@@ -257,21 +648,93 @@ def parse_key_value_text(text: str) -> dict[str, str]:
257
648
  return payload
258
649
 
259
650
 
260
- 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])
261
736
  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
- ],
737
+ args,
275
738
  cwd=repo_root,
276
739
  check=False,
277
740
  )
@@ -297,6 +760,15 @@ def sanitize_pr_title(raw_title: str, branch_name: str) -> str:
297
760
  return title[:240]
298
761
 
299
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
+
300
772
  def build_fallback_pr_body(
301
773
  branch_name: str,
302
774
  ranges: list[str],
@@ -333,6 +805,7 @@ def attempt_pr_creation_fallback(
333
805
  ranges: list[str],
334
806
  changed_files: list[str],
335
807
  commits: list[dict[str, str]],
808
+ repository: str,
336
809
  ) -> str:
337
810
  title = sanitize_pr_title(
338
811
  git(repo_root, ["log", "-1", "--pretty=%s"], check=False), branch_name
@@ -343,6 +816,8 @@ def attempt_pr_creation_fallback(
343
816
  "gh",
344
817
  "pr",
345
818
  "create",
819
+ "--repo",
820
+ repository,
346
821
  "--head",
347
822
  branch_name,
348
823
  "--base",
@@ -360,7 +835,7 @@ def attempt_pr_creation_fallback(
360
835
  pr_url = extract_pr_url(combined_output)
361
836
  if pr_url:
362
837
  return pr_url
363
- existing_pr = lookup_open_pr_url(repo_root, branch_name)
838
+ existing_pr = lookup_open_pr_url(repo_root, branch_name, base_branch, repository)
364
839
  if existing_pr:
365
840
  return existing_pr
366
841
  raise HookError(
@@ -380,8 +855,103 @@ def _report_file_path(context: RuntimeContext, state: ModuleRuntimeState) -> pat
380
855
  if branch_context and branch_context.exists():
381
856
  payload = parse_key_value_text(branch_context.read_text(encoding="utf-8"))
382
857
  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()
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
+ }
385
955
 
386
956
 
387
957
  def beads_alignment_executor(
@@ -393,15 +963,35 @@ def beads_alignment_executor(
393
963
  if state.metadata.get("skip_module"):
394
964
  return {"skipped": True, "commands_run": [], "report_written": False, "unresolved": False}
395
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")
396
968
  commands = payload.get("commands", [])
397
969
  if not isinstance(commands, list):
398
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()
399
978
  report_path = _report_file_path(context, state)
400
979
  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)
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
+ )
405
995
  commands_run.append(command)
406
996
 
407
997
  report_markdown = str(payload.get("report_markdown", "")).strip()
@@ -410,9 +1000,14 @@ def beads_alignment_executor(
410
1000
  if report_markdown:
411
1001
  if not report_markdown.endswith("\n"):
412
1002
  report_markdown += "\n"
413
- 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}")
414
1005
  report_written = True
415
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")
416
1011
  report_path.unlink()
417
1012
 
418
1013
  return {
@@ -432,33 +1027,59 @@ def gh_pr_create_executor(
432
1027
  ) -> dict[str, Any]:
433
1028
  if state.metadata.get("skip_module"):
434
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
+ }
435
1046
  if shutil.which("gh") is None:
436
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
+ )
437
1051
  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)
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
+ )
440
1057
  if existing_pr:
441
1058
  return {"skipped": False, "pr_url": existing_pr, "already_exists": True}
442
1059
 
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
1060
+ base_branch = default_base_branch
1061
+ head_branch = branch_name
446
1062
  title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
447
1063
  body = str(payload.get("body", "")).strip()
448
1064
  if not body:
449
1065
  commits = collect_commit_messages_for_ranges(
450
- context.repo_root, context.cache.get("ranges", [])
1066
+ context.repo_root,
1067
+ context.cache.get("branch_ranges", context.cache.get("ranges", [])),
451
1068
  )
452
1069
  body = build_fallback_pr_body(
453
1070
  branch_name,
454
- context.cache.get("ranges", []),
455
- 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
+ ),
456
1075
  commits,
457
1076
  )
458
1077
  args = [
459
1078
  "gh",
460
1079
  "pr",
461
1080
  "create",
1081
+ "--repo",
1082
+ repository,
462
1083
  "--head",
463
1084
  head_branch,
464
1085
  "--base",
@@ -474,14 +1095,13 @@ def gh_pr_create_executor(
474
1095
  combined_output = "\n".join([(created.stdout or "").strip(), (created.stderr or "").strip()])
475
1096
  pr_url = extract_pr_url(combined_output)
476
1097
  if created.returncode != 0 and not pr_url:
477
- 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
+ )
478
1101
  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}
1102
+ raise HookError(
1103
+ combined_output.strip() or f"gh pr create failed with exit code {created.returncode}"
1104
+ )
485
1105
  return {"skipped": False, "pr_url": pr_url, "already_exists": False}
486
1106
 
487
1107