ai-push-hooks 0.3.0 → 0.3.2

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. package/run.sh +0 -29
@@ -9,8 +9,12 @@ from typing import Sequence
9
9
  from .artifacts import ArtifactStore, generate_run_id
10
10
  from .config import load_config
11
11
  from .engine import WorkflowEngine
12
- from .paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
13
- from .executors.exec import (
12
+ from .paths import (
13
+ ensure_private_directory,
14
+ resolve_contained_path,
15
+ write_text_no_follow,
16
+ )
17
+ from .git_utils import (
14
18
  collect_changed_files,
15
19
  collect_diff,
16
20
  collect_revision_ranges,
@@ -28,11 +32,15 @@ from .executors.exec import (
28
32
  from .types import HookConfig, HookError, HookLogger, RuntimeContext
29
33
 
30
34
 
31
- def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig) -> HookLogger:
35
+ def _build_logger(
36
+ repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig
37
+ ) -> HookLogger:
32
38
  ensure_private_directory(git_dir / "ai-push-hooks")
33
39
  jsonl_path = None
34
40
  if config.logging.jsonl:
35
- log_dir = ensure_dir(resolve_storage_path(repo_root, git_dir, config.logging.dir))
41
+ log_dir = ensure_dir(
42
+ resolve_storage_path(repo_root, git_dir, config.logging.dir)
43
+ )
36
44
  if log_dir is not None:
37
45
  jsonl_path = resolve_contained_path(log_dir, "hook.jsonl", "JSONL log path")
38
46
  return HookLogger(jsonl_path=jsonl_path, console_level=config.logging.level)
@@ -40,7 +48,9 @@ def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookCo
40
48
 
41
49
  def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
42
50
  summary_dir = ensure_dir(
43
- resolve_storage_path(context.repo_root, context.git_dir, context.config.logging.summary_dir)
51
+ resolve_storage_path(
52
+ context.repo_root, context.git_dir, context.config.logging.summary_dir
53
+ )
44
54
  )
45
55
  if summary_dir is None:
46
56
  return
@@ -49,7 +59,9 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
49
59
  f"{context.run_id}.json",
50
60
  "Summary output path",
51
61
  )
52
- write_text_no_follow(summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n")
62
+ write_text_no_follow(
63
+ summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n"
64
+ )
53
65
 
54
66
 
55
67
  def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
@@ -77,7 +89,11 @@ def _run_hook_impl(
77
89
  if config.general.require_clean_worktree:
78
90
  _assert_clean_worktree(repo_root)
79
91
 
80
- actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
92
+ actual_stdin = (
93
+ list(stdin_lines)
94
+ if stdin_lines is not None
95
+ else [line.rstrip("\n") for line in sys.stdin]
96
+ )
81
97
  push_updates = parse_push_updates(actual_stdin)
82
98
  pushed_branch_updates = [
83
99
  update
@@ -92,7 +108,9 @@ def _run_hook_impl(
92
108
  )
93
109
  pushed_branches = list(
94
110
  dict.fromkeys(
95
- update.branch_name for update in pushed_branch_updates if update.branch_name is not None
111
+ update.branch_name
112
+ for update in pushed_branch_updates
113
+ if update.branch_name is not None
96
114
  )
97
115
  )
98
116
  if config.general.skip_on_sync_branch:
@@ -108,7 +126,9 @@ def _run_hook_impl(
108
126
  )
109
127
  ranges = unique_range_expressions(revision_ranges)
110
128
  changed_files = collect_changed_files(repo_root, ranges) if ranges else []
111
- diff_text = collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
129
+ diff_text = (
130
+ collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
131
+ )
112
132
  if len(pushed_branches) == 1:
113
133
  branch_name = pushed_branches[0]
114
134
  branch_selection_reason = "single pushed branch"
@@ -141,7 +161,9 @@ def _run_hook_impl(
141
161
  branch_diff_text = ""
142
162
  branch_is_new = False
143
163
  run_id = generate_run_id()
144
- run_dir = resolve_storage_path(repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}")
164
+ run_dir = resolve_storage_path(
165
+ repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}"
166
+ )
145
167
 
146
168
  context = RuntimeContext(
147
169
  repo_root=repo_root,
@@ -198,14 +220,28 @@ def _run_hook_impl(
198
220
  try:
199
221
  workflow_result = engine.run()
200
222
  logger.llm_summary()
201
- _write_summary(context, {"run_dir": str(workflow_result.run_dir), "modules": workflow_result.modules})
202
- logger.status("hook.complete", "AI push hooks workflow completed", run_dir=str(workflow_result.run_dir))
223
+ _write_summary(
224
+ context,
225
+ {
226
+ "run_dir": str(workflow_result.run_dir),
227
+ "modules": workflow_result.modules,
228
+ },
229
+ )
230
+ logger.status(
231
+ "hook.complete",
232
+ "AI push hooks workflow completed",
233
+ run_dir=str(workflow_result.run_dir),
234
+ )
203
235
  return 0
204
236
  except Exception as exc: # noqa: BLE001
205
237
  message = str(exc).strip() or exc.__class__.__name__
206
238
  logger.error("hook.failed", "AI push hooks workflow failed", error=message)
207
239
  if config.general.allow_push_on_error:
208
- logger.warn("hook.fail_open", "Allowing push because allow_push_on_error=true", error=message)
240
+ logger.warn(
241
+ "hook.fail_open",
242
+ "Allowing push because allow_push_on_error=true",
243
+ error=message,
244
+ )
209
245
  return 0
210
246
  raise
211
247
 
@@ -61,11 +61,7 @@ def pre_push_hook_script(delegate: tuple[str, ...] | None = None) -> str:
61
61
  )
62
62
  else:
63
63
  availability_check = ""
64
- return (
65
- "#!/bin/sh\n"
66
- + availability_check
67
- + f'exec {command} hook "$@"\n'
68
- )
64
+ return "#!/bin/sh\n" + availability_check + f'exec {command} hook "$@"\n'
69
65
 
70
66
 
71
67
  def _git_value(cwd: pathlib.Path, *args: str) -> str:
@@ -81,7 +77,9 @@ def _git_value(cwd: pathlib.Path, *args: str) -> str:
81
77
  except FileNotFoundError as exc:
82
78
  raise HookError("Git is required for `ai-push-hooks install`") from exc
83
79
  except subprocess.TimeoutExpired as exc:
84
- raise HookError(f"Git command timed out while resolving hook location: {' '.join(args)}") from exc
80
+ raise HookError(
81
+ f"Git command timed out while resolving hook location: {' '.join(args)}"
82
+ ) from exc
85
83
  except subprocess.CalledProcessError as exc:
86
84
  detail = (exc.stderr or exc.stdout or "not a Git repository").strip()
87
85
  raise HookError(f"Could not resolve Git hook location: {detail}") from exc
@@ -93,11 +91,15 @@ def _resolve_git_namespace(repo_root: pathlib.Path, value: str) -> pathlib.Path:
93
91
  return (repo_root / path).resolve() if not path.is_absolute() else path.resolve()
94
92
 
95
93
 
96
- def _path_is_in_namespace(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> bool:
94
+ def _path_is_in_namespace(
95
+ path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
96
+ ) -> bool:
97
97
  return any(is_path_within(path, namespace) for namespace in namespaces)
98
98
 
99
99
 
100
- def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> None:
100
+ def _validate_parent_chain(
101
+ path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
102
+ ) -> None:
101
103
  """Reject symlink/reparse parents and create only missing safe directories."""
102
104
  parent = path.parent
103
105
  existing: list[pathlib.Path] = []
@@ -129,25 +131,41 @@ def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, .
129
131
  current = current.parent
130
132
 
131
133
 
132
- def _effective_hook_path(current_dir: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
133
- repo_root = pathlib.Path(_git_value(current_dir, "rev-parse", "--show-toplevel")).resolve()
134
- git_dir = _resolve_git_namespace(repo_root, _git_value(repo_root, "rev-parse", "--git-dir"))
134
+ def _effective_hook_path(
135
+ current_dir: pathlib.Path,
136
+ ) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
137
+ repo_root = pathlib.Path(
138
+ _git_value(current_dir, "rev-parse", "--show-toplevel")
139
+ ).resolve()
140
+ git_dir = _resolve_git_namespace(
141
+ repo_root, _git_value(repo_root, "rev-parse", "--git-dir")
142
+ )
135
143
  common_dir = _resolve_git_namespace(
136
144
  repo_root, _git_value(repo_root, "rev-parse", "--git-common-dir")
137
145
  )
138
- raw_hooks_dir = pathlib.Path(_git_value(current_dir, "rev-parse", "--git-path", "hooks"))
146
+ raw_hooks_dir = pathlib.Path(
147
+ _git_value(current_dir, "rev-parse", "--git-path", "hooks")
148
+ )
139
149
  lexical_hooks_dir = (
140
150
  raw_hooks_dir if raw_hooks_dir.is_absolute() else current_dir / raw_hooks_dir
141
151
  )
142
152
  if path_is_link_or_reparse(current_dir) or not current_dir.is_dir():
143
- raise HookError(f"Refusing to install from an unsafe working directory: {current_dir}")
153
+ raise HookError(
154
+ f"Refusing to install from an unsafe working directory: {current_dir}"
155
+ )
144
156
  if any(path_is_link_or_reparse(part) for part in lexical_hooks_dir.parents):
145
- raise HookError(f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}")
157
+ raise HookError(
158
+ f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
159
+ )
146
160
  if path_is_link_or_reparse(lexical_hooks_dir):
147
- raise HookError(f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}")
161
+ raise HookError(
162
+ f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
163
+ )
148
164
  lexical_hook_path = lexical_hooks_dir / "pre-push"
149
165
  if path_is_link_or_reparse(lexical_hook_path):
150
- raise HookError(f"Refusing symlink or reparse-point hook target: {lexical_hook_path}")
166
+ raise HookError(
167
+ f"Refusing symlink or reparse-point hook target: {lexical_hook_path}"
168
+ )
151
169
  hook_path = lexical_hook_path.resolve(strict=False)
152
170
 
153
171
  namespaces = (repo_root, git_dir)
@@ -179,11 +197,15 @@ def install_hook(force: bool, cwd: pathlib.Path | None = None) -> int:
179
197
  except FileNotFoundError:
180
198
  metadata = None
181
199
  except OSError as exc:
182
- raise HookError(f"Could not inspect pre-push hook path {hook_path}: {exc}") from exc
200
+ raise HookError(
201
+ f"Could not inspect pre-push hook path {hook_path}: {exc}"
202
+ ) from exc
183
203
 
184
204
  if metadata is not None:
185
205
  if path_is_link_or_reparse(hook_path):
186
- raise HookError(f"Refusing symlink or reparse-point hook target: {hook_path}")
206
+ raise HookError(
207
+ f"Refusing symlink or reparse-point hook target: {hook_path}"
208
+ )
187
209
  if not stat.S_ISREG(metadata.st_mode):
188
210
  raise HookError(f"Refusing non-regular hook target: {hook_path}")
189
211
  if not force:
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from ..executors.exec import collect_commit_messages_for_ranges, is_feature_branch
5
+ from ..git_utils import collect_commit_messages_for_ranges, is_feature_branch
6
6
  from ..types import CollectorResult, RuntimeContext
7
7
 
8
8
 
9
- def collect_beads_status_context(context: RuntimeContext, state: Any) -> CollectorResult:
9
+ def collect_beads_status_context(
10
+ context: RuntimeContext, state: Any
11
+ ) -> CollectorResult:
10
12
  branch_name = str(context.cache.get("branch_name", ""))
11
13
  branch_selection_reason = str(
12
14
  context.cache.get("branch_selection_reason", "no pushed branch updates")
@@ -22,7 +24,9 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
22
24
  skip_reason=branch_selection_reason,
23
25
  )
24
26
  sync_branch = context.cache.get("sync_branch", "beads-sync")
25
- if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(branch_name):
27
+ if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(
28
+ branch_name
29
+ ):
26
30
  return CollectorResult(
27
31
  artifacts={"branch-context.txt": f"branch={branch_name}\n"},
28
32
  skip_module=True,
@@ -33,8 +37,12 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
33
37
  changed_files = context.cache.get(
34
38
  "branch_changed_files", context.cache.get("changed_files", [])
35
39
  )
36
- diff_text = context.cache.get("branch_diff_text", context.cache.get("diff_text", ""))
37
- commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
40
+ diff_text = context.cache.get(
41
+ "branch_diff_text", context.cache.get("diff_text", "")
42
+ )
43
+ commits = (
44
+ collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
45
+ )
38
46
  report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
39
47
  commit_lines = []
40
48
  for commit in commits:
@@ -54,8 +62,11 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
54
62
  ]
55
63
  )
56
64
  + "\n",
57
- "changed-files.txt": "\n".join(changed_files) + ("\n" if changed_files else ""),
58
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
59
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
65
+ "changed-files.txt": "\n".join(changed_files)
66
+ + ("\n" if changed_files else ""),
67
+ "push.diff": diff_text
68
+ + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
69
+ "commits.txt": "\n".join(commit_lines).strip()
70
+ + ("\n" if commit_lines else ""),
60
71
  }
61
72
  )
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import os
5
4
  import pathlib
6
5
  import re
@@ -10,7 +9,7 @@ from pathlib import PurePosixPath
10
9
  from typing import Any
11
10
 
12
11
  from ..types import CollectorResult, RuntimeContext
13
- from ..executors.exec import collect_commit_messages_for_ranges, git, path_matches, run_command
12
+ from ..git_utils import collect_commit_messages_for_ranges, git, path_matches
14
13
 
15
14
  DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
16
15
  DOC_IGNORE_PATTERNS = ("docs/archive/**",)
@@ -18,6 +17,10 @@ DOC_CONTEXT_LINES = 2
18
17
  DOC_MAX_BYTES = 64 * 1024
19
18
  DOC_CONTEXT_BUDGET = 32000
20
19
  DOC_FALLBACK_FILE_LIMIT = 8
20
+ # Matching metadata is deliberately bounded independently of the document
21
+ # inventory. The normal context budget is reached much earlier, while this
22
+ # cap prevents a pathological repeated-match file from retaining every hit.
23
+ DOC_MAX_QUERY_MATCHES = 4096
21
24
 
22
25
 
23
26
  def _path_matches(path: str, patterns: tuple[str, ...]) -> bool:
@@ -126,25 +129,70 @@ def _read_bounded_text(path: pathlib.Path, max_bytes: int | None = None) -> str:
126
129
  os.close(descriptor)
127
130
 
128
131
 
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:
132
+ class _ContextAccumulator:
133
+ """Collect bounded snippets without rescanning all prior snippets."""
134
+
135
+ def __init__(self) -> None:
136
+ self.chunks: list[str] = []
137
+ self.size = 0
138
+
139
+ def append(self, chunk: str, budget: int) -> bool:
140
+ remaining = budget - self.size
141
+ separator_size = 1 if self.chunks else 0
142
+ remaining -= separator_size
143
+ if remaining <= 0:
137
144
  return False
138
- chunk = chunk[:remaining]
139
- chunks.append(chunk)
140
- return not truncated
145
+ truncated = len(chunk) > remaining
146
+ if truncated:
147
+ if self.chunks:
148
+ return False
149
+ chunk = chunk[:remaining]
150
+ self.chunks.append(chunk)
151
+ self.size += separator_size + len(chunk)
152
+ return not truncated
141
153
 
154
+ def render(self) -> str:
155
+ return "\n".join(self.chunks)
142
156
 
143
- def _fallback_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path]) -> str:
157
+
158
+ class _QueryMatchBuffer:
159
+ """Bounded query-ranked snippet metadata for one document scan."""
160
+
161
+ def __init__(self, max_matches: int = DOC_MAX_QUERY_MATCHES) -> None:
162
+ self.max_matches = max_matches
163
+ self.matches: list[tuple[tuple[int, int], str]] = []
164
+ self._seen: set[tuple[int, int]] = set()
165
+ self.characters = 0
166
+ self.saturated = False
167
+
168
+ def add(
169
+ self,
170
+ key: tuple[int, int],
171
+ relative: str,
172
+ line_number: int,
173
+ line: str,
174
+ ) -> None:
175
+ if key in self._seen or self.saturated or len(self.matches) >= self.max_matches:
176
+ return
177
+ chunk = f"{relative}:{line_number}: {line}"
178
+ self._seen.add(key)
179
+ self.matches.append((key, chunk))
180
+ self.characters += len(chunk)
181
+ self.saturated = (
182
+ len(self.matches) >= self.max_matches
183
+ or self.characters >= DOC_CONTEXT_BUDGET
184
+ )
185
+
186
+
187
+ def _fallback_docs_context(
188
+ repo_root: pathlib.Path,
189
+ doc_files: list[pathlib.Path],
190
+ contents: dict[pathlib.Path, str] | None = None,
191
+ ) -> str:
144
192
  snippets: list[str] = []
145
193
  for path in doc_files[:DOC_FALLBACK_FILE_LIMIT]:
146
194
  relative = path.relative_to(repo_root).as_posix()
147
- content = _read_bounded_text(path)
195
+ content = _read_bounded_text(path) if contents is None else contents[path]
148
196
  block = f"--- {relative} ---\n{content}"
149
197
  current_size = len("\n\n".join(snippets))
150
198
  remaining = DOC_CONTEXT_BUDGET - current_size
@@ -156,90 +204,84 @@ def _fallback_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path
156
204
  return "\n\n".join(snippets)
157
205
 
158
206
 
159
- def _search_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]) -> str:
207
+ def _collect_query_matches(
208
+ repo_root: pathlib.Path,
209
+ doc_files: list[pathlib.Path],
210
+ queries: list[str],
211
+ *,
212
+ rg_available: bool,
213
+ ) -> tuple[list[_QueryMatchBuffer], dict[pathlib.Path, str]]:
214
+ """Read each document once and retain only bounded ranked snippet metadata.
215
+
216
+ The old rg path skipped files larger than ``DOC_MAX_BYTES`` while its
217
+ no-rg fallback read a bounded prefix. Keep that environment-dependent
218
+ compatibility behavior explicit while avoiding N query subprocesses and
219
+ without caching the whole documentation tree.
220
+ """
221
+
222
+ buffers = [_QueryMatchBuffer() for _query in queries]
223
+ fallback_contents: dict[pathlib.Path, str] = {}
224
+ for path_index, path in enumerate(doc_files):
225
+ if rg_available:
226
+ try:
227
+ if path.stat().st_size > DOC_MAX_BYTES:
228
+ continue
229
+ except OSError:
230
+ continue
231
+ content = _read_bounded_text(path)
232
+ if not rg_available and path_index < DOC_FALLBACK_FILE_LIMIT:
233
+ fallback_contents[path] = content
234
+ lines = content.splitlines()
235
+ relative = path.relative_to(repo_root).as_posix()
236
+ for line_index, line in enumerate(lines):
237
+ for query_index, query in enumerate(queries):
238
+ if query not in line:
239
+ continue
240
+ first = max(0, line_index - DOC_CONTEXT_LINES)
241
+ last = min(len(lines), line_index + DOC_CONTEXT_LINES + 1)
242
+ buffer = buffers[query_index]
243
+ for index in range(first, last):
244
+ buffer.add(
245
+ (path_index, index),
246
+ relative,
247
+ index + 1,
248
+ lines[index],
249
+ )
250
+ if all(buffer.saturated for buffer in buffers):
251
+ return buffers, fallback_contents
252
+ return buffers, fallback_contents
253
+
254
+
255
+ def _search_docs_context(
256
+ repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]
257
+ ) -> str:
160
258
  repo_root = repo_root.resolve(strict=True)
161
259
  if not doc_files:
162
260
  return ""
163
261
  if not queries:
164
262
  return _fallback_docs_context(repo_root, doc_files)
165
263
 
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)
189
- chunks: list[str] = []
190
- seen: set[tuple[str, int]] = set()
191
- for query in queries:
192
- completed = run_command(
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
- ],
207
- cwd=repo_root,
208
- check=False,
209
- )
210
- if completed.returncode not in {0, 1}:
211
- continue
212
- for line in completed.stdout.splitlines():
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
- ):
233
- continue
234
- key = (file_name, line_number)
264
+ rg_available = shutil.which("rg") is not None
265
+ matches_by_query, fallback_contents = _collect_query_matches(
266
+ repo_root,
267
+ doc_files,
268
+ queries,
269
+ rg_available=rg_available,
270
+ )
271
+ accumulator = _ContextAccumulator()
272
+ seen: set[tuple[int, int]] = set()
273
+ for query_matches in matches_by_query:
274
+ for key, chunk in query_matches.matches:
235
275
  if key in seen:
236
276
  continue
237
277
  seen.add(key)
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):
241
- return "\n".join(chunks)
242
- return "\n".join(chunks)
278
+ if not accumulator.append(chunk, DOC_CONTEXT_BUDGET):
279
+ return accumulator.render()
280
+ if accumulator.chunks:
281
+ return accumulator.render()
282
+ if rg_available:
283
+ return ""
284
+ return _fallback_docs_context(repo_root, doc_files, fallback_contents)
243
285
 
244
286
 
245
287
  def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResult:
@@ -257,7 +299,9 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
257
299
  ["log", "--oneline", "-n", "20", "--", "README.md", "docs"],
258
300
  check=False,
259
301
  )
260
- commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
302
+ commits = (
303
+ collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
304
+ )
261
305
  commit_lines = []
262
306
  for commit in commits:
263
307
  commit_lines.append(f"--- {commit['hash']}")
@@ -268,12 +312,18 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
268
312
  commit_lines.append("")
269
313
  return CollectorResult(
270
314
  artifacts={
271
- "changed-files.txt": "\n".join(changed_files) + ("\n" if changed_files else ""),
272
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
273
- "docs-inventory.txt": "\n".join(path.relative_to(context.repo_root).as_posix() for path in doc_files)
315
+ "changed-files.txt": "\n".join(changed_files)
316
+ + ("\n" if changed_files else ""),
317
+ "push.diff": diff_text
318
+ + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
319
+ "docs-inventory.txt": "\n".join(
320
+ path.relative_to(context.repo_root).as_posix() for path in doc_files
321
+ )
274
322
  + ("\n" if doc_files else ""),
275
323
  "docs-context.txt": docs_context + ("\n" if docs_context else ""),
276
- "recent-commits.txt": recent_commits + ("\n" if recent_commits and not recent_commits.endswith("\n") else ""),
277
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
324
+ "recent-commits.txt": recent_commits
325
+ + ("\n" if recent_commits and not recent_commits.endswith("\n") else ""),
326
+ "commits.txt": "\n".join(commit_lines).strip()
327
+ + ("\n" if commit_lines else ""),
278
328
  }
279
329
  )
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from ..executors.exec import (
5
+ from ..git_utils import (
6
6
  collect_commit_messages_for_ranges,
7
7
  env_bool,
8
8
  initial_pr_defer_reason,
@@ -37,7 +37,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
37
37
  break
38
38
  if flag_env and env_bool(flag_env) is not True:
39
39
  return CollectorResult(
40
- artifacts={"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"},
40
+ artifacts={
41
+ "pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"
42
+ },
41
43
  skip_module=True,
42
44
  skip_reason="PR create env flag is not enabled",
43
45
  )
@@ -81,7 +83,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
81
83
  existing_pr_url = ""
82
84
  if existing_pr_url:
83
85
  return CollectorResult(
84
- artifacts={"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"},
86
+ artifacts={
87
+ "pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"
88
+ },
85
89
  skip_module=True,
86
90
  skip_reason="open PR already exists",
87
91
  metadata={"existing_pr_url": existing_pr_url},
@@ -91,8 +95,12 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
91
95
  changed_files = context.cache.get(
92
96
  "branch_changed_files", context.cache.get("changed_files", [])
93
97
  )
94
- diff_text = context.cache.get("branch_diff_text", context.cache.get("diff_text", ""))
95
- commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
98
+ diff_text = context.cache.get(
99
+ "branch_diff_text", context.cache.get("diff_text", "")
100
+ )
101
+ commits = (
102
+ collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
103
+ )
96
104
  commit_lines = []
97
105
  for commit in commits:
98
106
  commit_lines.append(f"--- {commit['hash']}")
@@ -113,8 +121,11 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
113
121
  ]
114
122
  )
115
123
  + "\n",
116
- "changed-files.txt": "\n".join(changed_files) + ("\n" if changed_files else ""),
117
- "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
118
- "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
124
+ "changed-files.txt": "\n".join(changed_files)
125
+ + ("\n" if changed_files else ""),
126
+ "push.diff": diff_text
127
+ + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
128
+ "commits.txt": "\n".join(commit_lines).strip()
129
+ + ("\n" if commit_lines else ""),
119
130
  }
120
131
  )