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.
@@ -1,24 +1,50 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import os
3
5
  import pathlib
4
6
  import re
5
7
  import shutil
8
+ import stat
6
9
  from pathlib import PurePosixPath
7
10
  from typing import Any
8
11
 
9
12
  from ..types import CollectorResult, RuntimeContext
10
- from ..executors.exec import collect_commit_messages_for_ranges, git, run_command
13
+ from ..executors.exec import collect_commit_messages_for_ranges, git, path_matches, run_command
11
14
 
12
15
  DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
13
16
  DOC_IGNORE_PATTERNS = ("docs/archive/**",)
17
+ DOC_CONTEXT_LINES = 2
18
+ DOC_MAX_BYTES = 64 * 1024
19
+ DOC_CONTEXT_BUDGET = 32000
20
+ DOC_FALLBACK_FILE_LIMIT = 8
14
21
 
15
22
 
16
23
  def _path_matches(path: str, patterns: tuple[str, ...]) -> bool:
17
- pure = PurePosixPath(path)
18
- return any(pure.match(pattern) for pattern in patterns)
24
+ return any(path_matches(path, pattern) for pattern in patterns)
25
+
26
+
27
+ def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
28
+ """Return whether candidate is a contained, non-link regular file."""
29
+ try:
30
+ relative = candidate.relative_to(repo_root)
31
+ current = repo_root
32
+ for part in relative.parts:
33
+ current /= part
34
+ if stat.S_ISLNK(current.lstat().st_mode):
35
+ return False
36
+ candidate_stat = candidate.lstat()
37
+ if not stat.S_ISREG(candidate_stat.st_mode):
38
+ return False
39
+ resolved = candidate.resolve(strict=True)
40
+ resolved.relative_to(repo_root)
41
+ except (OSError, RuntimeError, ValueError):
42
+ return False
43
+ return True
19
44
 
20
45
 
21
46
  def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
47
+ repo_root = repo_root.resolve(strict=True)
22
48
  files: list[pathlib.Path] = []
23
49
  for candidate in repo_root.rglob("*.md"):
24
50
  relative = candidate.relative_to(repo_root).as_posix()
@@ -26,6 +52,8 @@ def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
26
52
  continue
27
53
  if _path_matches(relative, DOC_IGNORE_PATTERNS):
28
54
  continue
55
+ if not _is_safe_doc_file(repo_root, candidate):
56
+ continue
29
57
  files.append(candidate)
30
58
  return sorted(files)
31
59
 
@@ -74,50 +102,142 @@ def _deterministic_seed_queries(diff_text: str, changed_files: list[str]) -> lis
74
102
  return deduped[:20]
75
103
 
76
104
 
77
- def _parse_rg_line(line: str) -> tuple[str, int, str] | None:
78
- match = re.match(r"^(.*?):(\d+):(.*)$", line)
79
- if not match:
80
- return None
81
- return match.group(1), int(match.group(2)), match.group(3)
105
+ def _read_bounded_text(path: pathlib.Path, max_bytes: int | None = None) -> str:
106
+ if max_bytes is None:
107
+ max_bytes = DOC_MAX_BYTES
108
+ flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
109
+ if hasattr(os, "O_NOFOLLOW"):
110
+ flags |= os.O_NOFOLLOW
111
+ try:
112
+ descriptor = os.open(path, flags)
113
+ except OSError:
114
+ return ""
115
+ try:
116
+ file_stat = os.fstat(descriptor)
117
+ if not stat.S_ISREG(file_stat.st_mode):
118
+ return ""
119
+ with os.fdopen(descriptor, "rb") as handle:
120
+ descriptor = -1
121
+ return handle.read(max_bytes).decode("utf-8", errors="replace")
122
+ except (OSError, UnicodeError):
123
+ return ""
124
+ finally:
125
+ if descriptor != -1:
126
+ os.close(descriptor)
127
+
128
+
129
+ def _append_context_chunk(chunks: list[str], chunk: str, budget: int) -> bool:
130
+ current_size = sum(len(item) for item in chunks) + max(0, len(chunks) - 1)
131
+ remaining = budget - current_size
132
+ if remaining <= 0:
133
+ return False
134
+ truncated = len(chunk) > remaining
135
+ if truncated:
136
+ if chunks:
137
+ return False
138
+ chunk = chunk[:remaining]
139
+ chunks.append(chunk)
140
+ return not truncated
141
+
142
+
143
+ def _fallback_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path]) -> str:
144
+ snippets: list[str] = []
145
+ for path in doc_files[:DOC_FALLBACK_FILE_LIMIT]:
146
+ relative = path.relative_to(repo_root).as_posix()
147
+ content = _read_bounded_text(path)
148
+ block = f"--- {relative} ---\n{content}"
149
+ current_size = len("\n\n".join(snippets))
150
+ remaining = DOC_CONTEXT_BUDGET - current_size
151
+ if len(block) > remaining:
152
+ if not snippets and remaining > 0:
153
+ snippets.append(block[:remaining])
154
+ break
155
+ snippets.append(block)
156
+ return "\n\n".join(snippets)
82
157
 
83
158
 
84
159
  def _search_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]) -> str:
160
+ repo_root = repo_root.resolve(strict=True)
85
161
  if not doc_files:
86
162
  return ""
87
- if shutil.which("rg") is None or not queries:
88
- snippets: list[str] = []
89
- budget = 32000
90
- for path in doc_files[:8]:
91
- relative = path.relative_to(repo_root).as_posix()
92
- content = path.read_text(encoding="utf-8")[:4000]
93
- block = f"--- {relative} ---\n{content}"
94
- if len("\n\n".join(snippets)) + len(block) > budget:
95
- break
96
- snippets.append(block)
97
- return "\n\n".join(snippets)
98
-
99
- files = [str(path) for path in doc_files]
163
+ if not queries:
164
+ return _fallback_docs_context(repo_root, doc_files)
165
+
166
+ if shutil.which("rg") is None:
167
+ chunks: list[str] = []
168
+ seen: set[tuple[str, int]] = set()
169
+ for query in queries:
170
+ for path in doc_files:
171
+ relative = path.relative_to(repo_root).as_posix()
172
+ lines = _read_bounded_text(path).splitlines()
173
+ matching_lines = [index for index, line in enumerate(lines) if query in line]
174
+ for matching_index in matching_lines:
175
+ first = max(0, matching_index - DOC_CONTEXT_LINES)
176
+ last = min(len(lines), matching_index + DOC_CONTEXT_LINES + 1)
177
+ for index in range(first, last):
178
+ key = (relative, index + 1)
179
+ if key in seen:
180
+ continue
181
+ seen.add(key)
182
+ chunk = f"{relative}:{index + 1}: {lines[index]}"
183
+ if not _append_context_chunk(chunks, chunk, DOC_CONTEXT_BUDGET):
184
+ return "\n".join(chunks)
185
+ return "\n".join(chunks) if chunks else _fallback_docs_context(repo_root, doc_files)
186
+
187
+ files = [path.relative_to(repo_root).as_posix() for path in doc_files]
188
+ allowed_files = set(files)
100
189
  chunks: list[str] = []
101
190
  seen: set[tuple[str, int]] = set()
102
191
  for query in queries:
103
192
  completed = run_command(
104
- ["rg", "--line-number", "--no-heading", "--color=never", "-C", "2", "--", query, *files],
193
+ [
194
+ "rg",
195
+ "--json",
196
+ "--fixed-strings",
197
+ "--with-filename",
198
+ "--color=never",
199
+ "--context",
200
+ str(DOC_CONTEXT_LINES),
201
+ "--max-filesize",
202
+ str(DOC_MAX_BYTES),
203
+ "--",
204
+ query,
205
+ *files,
206
+ ],
105
207
  cwd=repo_root,
106
208
  check=False,
107
209
  )
108
210
  if completed.returncode not in {0, 1}:
109
211
  continue
110
212
  for line in completed.stdout.splitlines():
111
- parsed = _parse_rg_line(line)
112
- if not parsed:
213
+ try:
214
+ message = json.loads(line)
215
+ except json.JSONDecodeError:
216
+ continue
217
+ if message.get("type") not in {"match", "context"}:
218
+ continue
219
+ data = message.get("data")
220
+ if not isinstance(data, dict):
221
+ continue
222
+ path_data = data.get("path")
223
+ lines_data = data.get("lines")
224
+ file_name = path_data.get("text") if isinstance(path_data, dict) else None
225
+ line_number = data.get("line_number")
226
+ content = lines_data.get("text") if isinstance(lines_data, dict) else None
227
+ if (
228
+ not isinstance(file_name, str)
229
+ or file_name not in allowed_files
230
+ or not isinstance(line_number, int)
231
+ or not isinstance(content, str)
232
+ ):
113
233
  continue
114
- file_name, line_number, content = parsed
115
234
  key = (file_name, line_number)
116
235
  if key in seen:
117
236
  continue
118
237
  seen.add(key)
119
- chunks.append(f"{file_name}:{line_number}: {content}")
120
- if sum(len(chunk) for chunk in chunks) > 32000:
238
+ clean_content = content.rstrip("\r\n")
239
+ chunk = f"{file_name}:{line_number}: {clean_content}"
240
+ if not _append_context_chunk(chunks, chunk, DOC_CONTEXT_BUDGET):
121
241
  return "\n".join(chunks)
122
242
  return "\n".join(chunks)
123
243
 
@@ -4,17 +4,32 @@ from typing import Any
4
4
 
5
5
  from ..executors.exec import (
6
6
  collect_commit_messages_for_ranges,
7
- current_branch,
8
7
  env_bool,
8
+ initial_pr_defer_reason,
9
9
  is_feature_branch,
10
10
  lookup_open_pr_url,
11
+ resolve_github_repository,
11
12
  )
12
13
  from ..types import CollectorResult, RuntimeContext
13
14
 
14
15
 
15
16
  def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
16
- branch_name = current_branch(context.repo_root)
17
+ branch_name = str(context.cache.get("branch_name", ""))
18
+ branch_selection_reason = str(
19
+ context.cache.get("branch_selection_reason", "no pushed branch updates")
20
+ )
17
21
  base_branch = context.config.general.base_branch.strip() or "main"
22
+ if not branch_name:
23
+ return CollectorResult(
24
+ artifacts={
25
+ "pr-context.txt": (
26
+ f"branch=\nbase_branch={base_branch}\n"
27
+ f"branch_selection_reason={branch_selection_reason}\n"
28
+ )
29
+ },
30
+ skip_module=True,
31
+ skip_reason=branch_selection_reason,
32
+ )
18
33
  flag_env = ""
19
34
  for step in state.module.steps:
20
35
  if step.when_env:
@@ -26,15 +41,42 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
26
41
  skip_module=True,
27
42
  skip_reason="PR create env flag is not enabled",
28
43
  )
29
- if not branch_name or branch_name in {"HEAD", base_branch} or not is_feature_branch(branch_name):
44
+ if branch_name in {"HEAD", base_branch} or not is_feature_branch(branch_name):
30
45
  return CollectorResult(
31
46
  artifacts={"pr-context.txt": f"branch={branch_name}\n"},
32
47
  skip_module=True,
33
48
  skip_reason="branch does not require PR creation",
34
49
  )
50
+ initial_push = bool(context.cache.get("branch_is_new", False))
51
+ if initial_push:
52
+ reason = initial_pr_defer_reason(branch_name, base_branch)
53
+ context.logger.warn("pr.create_deferred", reason, branch=branch_name)
54
+ return CollectorResult(
55
+ artifacts={
56
+ "pr-context.txt": (
57
+ f"branch={branch_name}\nbase_branch={base_branch}\n"
58
+ "initial_push=true\n"
59
+ f"defer_reason={reason}\n"
60
+ ),
61
+ "deferred-result.json": {
62
+ "skipped": True,
63
+ "pr_url": "",
64
+ "deferred_until_remote": True,
65
+ "reason": reason,
66
+ },
67
+ },
68
+ skip_module=True,
69
+ skip_reason=reason,
70
+ metadata={"deferred_until_remote": True, "reason": reason},
71
+ )
72
+ repository = resolve_github_repository(
73
+ context.repo_root, context.remote_name, context.remote_url
74
+ )
35
75
  existing_pr_url = ""
36
76
  try:
37
- existing_pr_url = lookup_open_pr_url(context.repo_root, branch_name)
77
+ existing_pr_url = lookup_open_pr_url(
78
+ context.repo_root, branch_name, base_branch, repository
79
+ )
38
80
  except Exception: # noqa: BLE001
39
81
  existing_pr_url = ""
40
82
  if existing_pr_url:
@@ -45,9 +87,11 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
45
87
  metadata={"existing_pr_url": existing_pr_url},
46
88
  )
47
89
 
48
- ranges = context.cache.get("ranges", [])
49
- changed_files = context.cache.get("changed_files", [])
50
- diff_text = context.cache.get("diff_text", "")
90
+ ranges = context.cache.get("branch_ranges", context.cache.get("ranges", []))
91
+ changed_files = context.cache.get(
92
+ "branch_changed_files", context.cache.get("changed_files", [])
93
+ )
94
+ diff_text = context.cache.get("branch_diff_text", context.cache.get("diff_text", ""))
51
95
  commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
52
96
  commit_lines = []
53
97
  for commit in commits:
@@ -64,6 +108,8 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
64
108
  f"branch={branch_name}",
65
109
  f"base_branch={base_branch}",
66
110
  f"remote_name={context.remote_name or 'origin'}",
111
+ f"repository={repository}",
112
+ f"initial_push={'true' if initial_push else 'false'}",
67
113
  ]
68
114
  )
69
115
  + "\n",
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ import stat
6
+ import tempfile
7
+ import unicodedata
8
+
9
+ from .types import HookError
10
+
11
+ PRIVATE_FILE_MODE = 0o600
12
+ PRIVATE_DIRECTORY_MODE = 0o700
13
+ ORDINARY_FILE_MODE_MASK = 0o777
14
+
15
+
16
+ def relative_path_parts(raw: str, label: str) -> tuple[str, ...]:
17
+ if not isinstance(raw, str) or not raw.strip():
18
+ raise HookError(f"{label} must be a non-empty relative path")
19
+ if "\x00" in raw or any(ord(character) < 32 for character in raw):
20
+ raise HookError(f"{label} contains invalid control characters")
21
+
22
+ normalized = raw.replace("\\", "/")
23
+ windows_path = pathlib.PureWindowsPath(raw)
24
+ if normalized.startswith("/") or windows_path.is_absolute() or windows_path.drive:
25
+ raise HookError(f"{label} must be relative: {raw}")
26
+
27
+ raw_parts = normalized.split("/")
28
+ if any(part == ".." for part in raw_parts):
29
+ raise HookError(f"{label} must not contain '..': {raw}")
30
+ parts = tuple(part for part in raw_parts if part not in {"", "."})
31
+ if not parts:
32
+ raise HookError(f"{label} must be a non-empty relative path")
33
+ return parts
34
+
35
+
36
+ def validate_path_component(raw: str, label: str) -> str:
37
+ parts = relative_path_parts(raw, label)
38
+ if len(parts) != 1 or "/" in raw or "\\" in raw or parts[0] in {".", ".."}:
39
+ raise HookError(f"{label} must be a single path component: {raw}")
40
+ return parts[0]
41
+
42
+
43
+ def is_path_within(path: pathlib.Path, root: pathlib.Path) -> bool:
44
+ try:
45
+ path.relative_to(root)
46
+ return True
47
+ except ValueError:
48
+ return False
49
+
50
+
51
+ def normalized_component(value: str) -> str:
52
+ return unicodedata.normalize("NFKC", value).casefold()
53
+
54
+
55
+ def sanitize_file_mode(mode: int) -> int:
56
+ return mode & ORDINARY_FILE_MODE_MASK
57
+
58
+
59
+ def path_is_link_or_reparse(path: pathlib.Path) -> bool:
60
+ try:
61
+ metadata = path.lstat()
62
+ except FileNotFoundError:
63
+ return False
64
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
65
+ return stat.S_ISLNK(metadata.st_mode) or bool(
66
+ getattr(metadata, "st_file_attributes", 0) & reparse_flag
67
+ )
68
+
69
+
70
+ def path_has_symlink(root: pathlib.Path, path: pathlib.Path) -> bool:
71
+ lexical_root = pathlib.Path(os.path.abspath(root))
72
+ lexical_path = pathlib.Path(os.path.abspath(path))
73
+ try:
74
+ relative = lexical_path.relative_to(lexical_root)
75
+ except ValueError:
76
+ return True
77
+ current = lexical_root
78
+ if path_is_link_or_reparse(current):
79
+ return True
80
+ for part in relative.parts:
81
+ current = current / part
82
+ if path_is_link_or_reparse(current):
83
+ return True
84
+ if not current.exists():
85
+ break
86
+ return False
87
+
88
+
89
+ def resolve_contained_path(base: pathlib.Path, raw: str, label: str) -> pathlib.Path:
90
+ parts = relative_path_parts(raw, label)
91
+ lexical_base = pathlib.Path(os.path.abspath(base))
92
+ lexical_candidate = lexical_base.joinpath(*parts)
93
+ if not is_path_within(lexical_candidate, lexical_base):
94
+ raise HookError(f"{label} escapes its intended directory: {raw}")
95
+ if path_has_symlink(lexical_base, lexical_candidate):
96
+ raise HookError(f"{label} traverses a symlink or reparse point: {raw}")
97
+
98
+ resolved_base = lexical_base.resolve(strict=False)
99
+ resolved_candidate = lexical_candidate.resolve(strict=False)
100
+ if not is_path_within(resolved_candidate, resolved_base):
101
+ raise HookError(f"{label} escapes its intended directory through a symlink: {raw}")
102
+ return resolved_candidate
103
+
104
+
105
+ def ensure_private_directory(
106
+ path: pathlib.Path,
107
+ *,
108
+ private_root: pathlib.Path | None = None,
109
+ ) -> pathlib.Path:
110
+ target = pathlib.Path(os.path.abspath(path))
111
+ if private_root is None:
112
+ missing: list[pathlib.Path] = []
113
+ current = target
114
+ while not current.exists() and not path_is_link_or_reparse(current):
115
+ missing.append(current)
116
+ current = current.parent
117
+ if path_is_link_or_reparse(current) or not current.is_dir():
118
+ raise HookError(f"Private runtime directory has an unsafe parent: {path}")
119
+ for directory in reversed(missing):
120
+ directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
121
+ os.chmod(directory, PRIVATE_DIRECTORY_MODE)
122
+ if path_is_link_or_reparse(target) or not target.is_dir():
123
+ raise HookError(f"Private runtime directory is unsafe: {path}")
124
+ os.chmod(target, PRIVATE_DIRECTORY_MODE)
125
+ return target
126
+
127
+ root = pathlib.Path(os.path.abspath(private_root))
128
+ if not is_path_within(target, root):
129
+ raise HookError(f"Private runtime directory escapes its namespace: {path}")
130
+ if path_is_link_or_reparse(root.parent) or not root.parent.is_dir():
131
+ raise HookError(f"Private runtime namespace has an unsafe parent: {root}")
132
+ directories = [root]
133
+ current = root
134
+ for part in target.relative_to(root).parts:
135
+ current = current / part
136
+ directories.append(current)
137
+ for directory in directories:
138
+ if path_is_link_or_reparse(directory):
139
+ raise HookError(
140
+ f"Private runtime directory traverses a symlink or reparse point: {directory}"
141
+ )
142
+ if not directory.exists():
143
+ directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
144
+ if not directory.is_dir():
145
+ raise HookError(f"Private runtime path is not a directory: {directory}")
146
+ os.chmod(directory, PRIVATE_DIRECTORY_MODE)
147
+ return target
148
+
149
+
150
+ def atomic_write_bytes(
151
+ path: pathlib.Path,
152
+ content: bytes,
153
+ *,
154
+ mode: int = PRIVATE_FILE_MODE,
155
+ ) -> None:
156
+ parent = path.parent.resolve(strict=True)
157
+ target = parent / path.name
158
+ if path_is_link_or_reparse(target):
159
+ raise HookError(f"Refusing to replace symlink or reparse point: {target}")
160
+ descriptor, temporary_name = tempfile.mkstemp(prefix=".ai-push-hooks-", dir=parent)
161
+ temporary_path = pathlib.Path(temporary_name)
162
+ try:
163
+ with os.fdopen(descriptor, "wb") as handle:
164
+ descriptor = -1
165
+ handle.write(content)
166
+ handle.flush()
167
+ os.fsync(handle.fileno())
168
+ os.chmod(temporary_path, sanitize_file_mode(mode))
169
+ if path_is_link_or_reparse(target):
170
+ raise HookError(f"Refusing to replace symlink or reparse point: {target}")
171
+ os.replace(temporary_path, target)
172
+ finally:
173
+ if descriptor >= 0:
174
+ os.close(descriptor)
175
+ try:
176
+ temporary_path.unlink()
177
+ except FileNotFoundError:
178
+ pass
179
+
180
+
181
+ def write_text_no_follow(path: pathlib.Path, content: str, *, encoding: str = "utf-8") -> None:
182
+ atomic_write_bytes(path, content.encode(encoding))
@@ -32,10 +32,14 @@ Rules:
32
32
  BEADS_PLAN_PROMPT = """Check the attached branch context and output a JSON object describing Beads alignment work.
33
33
 
34
34
  Return keys:
35
- - commands: array of non-interactive bd command strings to run
35
+ - commands: array containing only these exact non-interactive command forms:
36
+ - bd update <issue-id> [<issue-id> ...] --status <open|in_progress|blocked>
37
+ - bd close <issue-id> [<issue-id> ...] [--reason <text>]
36
38
  - unresolved: boolean
37
39
  - report_markdown: markdown string or empty
38
40
 
41
+ Do not return global flags or any other bd subcommands.
42
+
39
43
  Return JSON only.
40
44
  """
41
45
 
@@ -68,7 +72,7 @@ base_branch = "main"
68
72
 
69
73
  [llm]
70
74
  runner = "opencode"
71
- model = "openai/gpt-5.5"
75
+ model = "openai/gpt-5.6-terra"
72
76
  variant = ""
73
77
  timeout_seconds = 800
74
78
  max_parallel = 2
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import pathlib
6
+ import stat
5
7
  import sys
6
8
  from dataclasses import dataclass, field
7
9
  from datetime import datetime, timezone
@@ -11,12 +13,54 @@ READ_ONLY_STEP_TYPES = frozenset({"collect", "llm"})
11
13
  PROMPTABLE_STEP_TYPES = frozenset({"llm", "apply"})
12
14
  SUPPORTED_STEP_TYPES = frozenset({"collect", "llm", "apply", "exec", "assert"})
13
15
  FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
16
+ ZERO_OID_LENGTHS = frozenset({40, 64})
14
17
 
15
18
 
16
19
  class HookError(RuntimeError):
17
20
  pass
18
21
 
19
22
 
23
+ def is_zero_oid(value: str) -> bool:
24
+ return len(value) in ZERO_OID_LENGTHS and not value.strip("0")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PushRefUpdate:
29
+ local_ref: str
30
+ local_sha: str
31
+ remote_ref: str
32
+ remote_sha: str
33
+
34
+ @property
35
+ def ref_kind(self) -> str:
36
+ if self.remote_ref.startswith("refs/heads/"):
37
+ return "branch"
38
+ if self.remote_ref.startswith("refs/tags/"):
39
+ return "tag"
40
+ return "other"
41
+
42
+ @property
43
+ def operation(self) -> str:
44
+ if is_zero_oid(self.local_sha):
45
+ return "delete"
46
+ if is_zero_oid(self.remote_sha):
47
+ return "create"
48
+ return "update"
49
+
50
+ @property
51
+ def branch_name(self) -> str | None:
52
+ if self.ref_kind != "branch" or self.operation == "delete":
53
+ return None
54
+ return self.remote_ref.removeprefix("refs/heads/")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PushRevisionRange:
59
+ update: PushRefUpdate
60
+ expression: str
61
+ strategy: str
62
+
63
+
20
64
  @dataclass(frozen=True)
21
65
  class GeneralConfig:
22
66
  enabled: bool = True
@@ -29,7 +73,7 @@ class GeneralConfig:
29
73
  @dataclass(frozen=True)
30
74
  class LlmConfig:
31
75
  runner: str = "opencode"
32
- model: str = "openai/gpt-5.5"
76
+ model: str = "openai/gpt-5.6-terra"
33
77
  variant: str = ""
34
78
  timeout_seconds: int = 800
35
79
  max_parallel: int = 2
@@ -177,8 +221,43 @@ class HookLogger:
177
221
  return
178
222
  record = {"ts": stamp, "level": level, "event": event, "message": message, **fields}
179
223
  try:
180
- with self.jsonl_path.open("a", encoding="utf-8") as handle:
181
- handle.write(json.dumps(record, ensure_ascii=True) + "\n")
224
+ try:
225
+ initial_metadata = self.jsonl_path.lstat()
226
+ except FileNotFoundError:
227
+ initial_metadata = None
228
+ if initial_metadata is not None:
229
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
230
+ if stat.S_ISLNK(initial_metadata.st_mode) or bool(
231
+ getattr(initial_metadata, "st_file_attributes", 0) & reparse_flag
232
+ ):
233
+ raise HookError(
234
+ "JSONL log target must not be a symlink or reparse point: "
235
+ f"{self.jsonl_path}"
236
+ )
237
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
238
+ flags |= getattr(os, "O_NOFOLLOW", 0)
239
+ descriptor = os.open(self.jsonl_path, flags, 0o600)
240
+ try:
241
+ descriptor_metadata = os.fstat(descriptor)
242
+ path_metadata = self.jsonl_path.lstat()
243
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
244
+ if (
245
+ not stat.S_ISREG(descriptor_metadata.st_mode)
246
+ or stat.S_ISLNK(path_metadata.st_mode)
247
+ or bool(
248
+ getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
249
+ )
250
+ or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
251
+ != (path_metadata.st_dev, path_metadata.st_ino)
252
+ ):
253
+ raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
254
+ os.fchmod(descriptor, 0o600)
255
+ os.write(
256
+ descriptor,
257
+ (json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
258
+ )
259
+ finally:
260
+ os.close(descriptor)
182
261
  except Exception as exc: # noqa: BLE001
183
262
  self.jsonl_write_failed = True
184
263
  sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")