ai-push-hooks 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import pathlib
5
6
  import sys
6
7
  from typing import Sequence
@@ -8,28 +9,32 @@ from typing import Sequence
8
9
  from .artifacts import ArtifactStore, generate_run_id
9
10
  from .config import load_config
10
11
  from .engine import WorkflowEngine
12
+ from .paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
11
13
  from .executors.exec import (
12
14
  collect_changed_files,
13
15
  collect_diff,
14
- collect_ranges_from_stdin,
16
+ collect_revision_ranges,
15
17
  current_branch,
16
18
  ensure_dir,
19
+ env_bool,
17
20
  git,
21
+ parse_push_updates,
18
22
  resolve_git_dir,
19
23
  resolve_repo_root,
20
24
  resolve_storage_path,
21
25
  should_skip_for_sync_branch,
26
+ unique_range_expressions,
22
27
  )
23
- from .executors.llm import resolve_opencode_executable
24
28
  from .types import HookConfig, HookError, HookLogger, RuntimeContext
25
29
 
26
30
 
27
31
  def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig) -> HookLogger:
32
+ ensure_private_directory(git_dir / "ai-push-hooks")
28
33
  jsonl_path = None
29
34
  if config.logging.jsonl:
30
35
  log_dir = ensure_dir(resolve_storage_path(repo_root, git_dir, config.logging.dir))
31
36
  if log_dir is not None:
32
- jsonl_path = log_dir / "hook.jsonl"
37
+ jsonl_path = resolve_contained_path(log_dir, "hook.jsonl", "JSONL log path")
33
38
  return HookLogger(jsonl_path=jsonl_path, console_level=config.logging.level)
34
39
 
35
40
 
@@ -39,8 +44,12 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
39
44
  )
40
45
  if summary_dir is None:
41
46
  return
42
- summary_path = summary_dir / f"{context.run_id}.json"
43
- summary_path.write_text(json.dumps(result, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
47
+ summary_path = resolve_contained_path(
48
+ summary_dir,
49
+ f"{context.run_id}.json",
50
+ "Summary output path",
51
+ )
52
+ write_text_no_follow(summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n")
44
53
 
45
54
 
46
55
  def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
@@ -49,7 +58,7 @@ def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
49
58
  raise HookError("Hook requires a clean worktree but local changes are present")
50
59
 
51
60
 
52
- def run_hook(
61
+ def _run_hook_impl(
53
62
  remote_name: str = "",
54
63
  remote_url: str = "",
55
64
  stdin_lines: Sequence[str] | None = None,
@@ -59,6 +68,7 @@ def run_hook(
59
68
  repo_root = resolve_repo_root(current_dir)
60
69
  git_dir = resolve_git_dir(repo_root)
61
70
  config, _config_path = load_config(repo_root)
71
+ ensure_private_directory(git_dir / "ai-push-hooks")
62
72
  logger = _build_logger(repo_root, git_dir, config)
63
73
 
64
74
  if not config.general.enabled:
@@ -66,28 +76,73 @@ def run_hook(
66
76
  return 0
67
77
  if config.general.require_clean_worktree:
68
78
  _assert_clean_worktree(repo_root)
79
+
80
+ actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
81
+ push_updates = parse_push_updates(actual_stdin)
82
+ pushed_branch_updates = [
83
+ update
84
+ for update in push_updates
85
+ if update.ref_kind == "branch" and update.operation != "delete"
86
+ ]
87
+ if len(pushed_branch_updates) > 1:
88
+ pushed_refs = ", ".join(update.remote_ref for update in pushed_branch_updates)
89
+ raise HookError(
90
+ "Ambiguous push contains multiple branch updates; refusing to skip branch gates: "
91
+ + pushed_refs
92
+ )
93
+ pushed_branches = list(
94
+ dict.fromkeys(
95
+ update.branch_name for update in pushed_branch_updates if update.branch_name is not None
96
+ )
97
+ )
69
98
  if config.general.skip_on_sync_branch:
70
- skip_sync, reason = should_skip_for_sync_branch(repo_root)
99
+ skip_sync, reason = should_skip_for_sync_branch(
100
+ repo_root, pushed_branches, push_updates
101
+ )
71
102
  if skip_sync:
72
103
  logger.status("hook.skip_sync_branch", f"Skipping AI push hooks: {reason}")
73
104
  return 0
74
105
 
75
- actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
76
- ranges = collect_ranges_from_stdin(repo_root, remote_name or "origin", actual_stdin)
106
+ revision_ranges = collect_revision_ranges(
107
+ repo_root, remote_name or "origin", push_updates, config.general.base_branch
108
+ )
109
+ ranges = unique_range_expressions(revision_ranges)
77
110
  changed_files = collect_changed_files(repo_root, ranges) if ranges else []
78
111
  diff_text = collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
112
+ if len(pushed_branches) == 1:
113
+ branch_name = pushed_branches[0]
114
+ branch_selection_reason = "single pushed branch"
115
+ branch_revision_ranges = [
116
+ item for item in revision_ranges if item.update.branch_name == branch_name
117
+ ]
118
+ branch_ranges = unique_range_expressions(branch_revision_ranges)
119
+ if branch_ranges == ranges:
120
+ branch_changed_files = changed_files
121
+ branch_diff_text = diff_text
122
+ else:
123
+ branch_changed_files = (
124
+ collect_changed_files(repo_root, branch_ranges) if branch_ranges else []
125
+ )
126
+ branch_diff_text = (
127
+ collect_diff(repo_root, branch_ranges, config.llm.max_diff_bytes)
128
+ if branch_ranges
129
+ else ""
130
+ )
131
+ branch_is_new = any(
132
+ update.branch_name == branch_name and update.operation == "create"
133
+ for update in push_updates
134
+ )
135
+ else:
136
+ branch_name = ""
137
+ branch_selection_reason = "no pushed branch updates"
138
+ branch_revision_ranges = []
139
+ branch_ranges = []
140
+ branch_changed_files = []
141
+ branch_diff_text = ""
142
+ branch_is_new = False
79
143
  run_id = generate_run_id()
80
144
  run_dir = resolve_storage_path(repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}")
81
145
 
82
- opencode_executable = None
83
- if any(
84
- step.type in {"llm", "apply"}
85
- for module in config.modules.values()
86
- for step in module.steps
87
- if module.enabled
88
- ):
89
- opencode_executable = resolve_opencode_executable()
90
-
91
146
  context = RuntimeContext(
92
147
  repo_root=repo_root,
93
148
  git_dir=git_dir,
@@ -98,21 +153,46 @@ def run_hook(
98
153
  stdin_lines=actual_stdin,
99
154
  run_id=run_id,
100
155
  run_dir=run_dir,
101
- opencode_executable=opencode_executable,
156
+ opencode_executable=None,
102
157
  cache={
103
158
  "ranges": ranges,
159
+ "revision_ranges": revision_ranges,
104
160
  "changed_files": changed_files,
105
161
  "diff_text": diff_text,
106
- "branch_name": current_branch(repo_root),
107
- "sync_branch": "beads-sync",
162
+ "push_updates": push_updates,
163
+ "pushed_branch_updates": pushed_branch_updates,
164
+ "pushed_branches": pushed_branches,
165
+ "branch_name": branch_name,
166
+ "branch_selection_reason": branch_selection_reason,
167
+ "branch_revision_ranges": branch_revision_ranges,
168
+ "branch_ranges": branch_ranges,
169
+ "branch_changed_files": branch_changed_files,
170
+ "branch_diff_text": branch_diff_text,
171
+ "branch_is_new": branch_is_new,
172
+ "checked_out_branch": current_branch(repo_root),
173
+ "base_branch": config.general.base_branch,
174
+ "sync_branch": os.getenv("BEADS_SYNC_BRANCH", "beads-sync"),
108
175
  },
109
176
  )
110
177
  logger.status(
111
178
  "hook.start",
112
179
  "Starting AI push hooks workflow",
113
180
  branch=context.cache["branch_name"],
181
+ checked_out_branch=context.cache["checked_out_branch"],
182
+ branch_selection_reason=branch_selection_reason,
114
183
  changed_files=len(changed_files),
115
184
  ranges=ranges,
185
+ push_updates=[
186
+ {
187
+ "local_ref": update.local_ref,
188
+ "local_sha": update.local_sha,
189
+ "remote_ref": update.remote_ref,
190
+ "remote_sha": update.remote_sha,
191
+ "ref_kind": update.ref_kind,
192
+ "operation": update.operation,
193
+ }
194
+ for update in push_updates
195
+ ],
116
196
  )
117
197
  engine = WorkflowEngine(context=context, artifacts=ArtifactStore(run_dir))
118
198
  try:
@@ -128,3 +208,33 @@ def run_hook(
128
208
  logger.warn("hook.fail_open", "Allowing push because allow_push_on_error=true", error=message)
129
209
  return 0
130
210
  raise
211
+
212
+
213
+ def run_hook(
214
+ remote_name: str = "",
215
+ remote_url: str = "",
216
+ stdin_lines: Sequence[str] | None = None,
217
+ cwd: pathlib.Path | None = None,
218
+ ) -> int:
219
+ if env_bool("AI_PUSH_HOOKS_SKIP") is True:
220
+ return 0
221
+ try:
222
+ return _run_hook_impl(remote_name, remote_url, stdin_lines, cwd)
223
+ except Exception as exc: # noqa: BLE001
224
+ allow_on_error = env_bool("AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR")
225
+ if allow_on_error is None:
226
+ try:
227
+ repo_root = resolve_repo_root(cwd or pathlib.Path.cwd())
228
+ config, _ = load_config(repo_root)
229
+ allow_on_error = config.general.allow_push_on_error
230
+ except Exception: # noqa: BLE001
231
+ allow_on_error = False
232
+ if allow_on_error:
233
+ message = str(exc).strip() or exc.__class__.__name__
234
+ sys.stderr.write(
235
+ "[ai-push-hooks] Allowing push because allow_push_on_error=true: "
236
+ + message
237
+ + "\n"
238
+ )
239
+ return 0
240
+ raise
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import pathlib
5
+ import shlex
6
+ import shutil
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+
11
+ from .paths import atomic_write_bytes, is_path_within, path_is_link_or_reparse
12
+ from .types import HookError
13
+
14
+ _GIT_QUERY_TIMEOUT = 10.0
15
+ _HOOK_MODE = 0o755
16
+
17
+
18
+ def _regular_absolute_path(raw: str | None) -> str | None:
19
+ if not raw:
20
+ return None
21
+ candidate = pathlib.Path(raw)
22
+ if not candidate.is_absolute():
23
+ return None
24
+ try:
25
+ resolved = candidate.resolve(strict=True)
26
+ metadata = resolved.lstat()
27
+ except (OSError, RuntimeError):
28
+ return None
29
+ if not stat.S_ISREG(metadata.st_mode) or path_is_link_or_reparse(resolved):
30
+ return None
31
+ return str(resolved)
32
+
33
+
34
+ def _delegate_argv() -> tuple[str, ...]:
35
+ node = _regular_absolute_path(os.environ.get("AI_PUSH_HOOKS_NODE_EXECUTABLE"))
36
+ node_script = _regular_absolute_path(os.environ.get("AI_PUSH_HOOKS_NODE_SCRIPT"))
37
+ if node and node_script:
38
+ return (node, node_script)
39
+
40
+ invoked_as = pathlib.Path(sys.argv[0])
41
+ if invoked_as.name == "ai-push-hooks":
42
+ executable = _regular_absolute_path(str(invoked_as))
43
+ if executable is None and not invoked_as.is_absolute():
44
+ executable = _regular_absolute_path(shutil.which(str(invoked_as)))
45
+ if executable:
46
+ return (executable,)
47
+ return ("ai-push-hooks",)
48
+
49
+
50
+ def pre_push_hook_script(delegate: tuple[str, ...] | None = None) -> str:
51
+ """Return the small, argument/stdin/exit-status preserving hook delegate."""
52
+ delegate = delegate or ("ai-push-hooks",)
53
+ command = " ".join(shlex.quote(part) for part in delegate)
54
+ if delegate == ("ai-push-hooks",):
55
+ availability_check = (
56
+ "if ! command -v ai-push-hooks >/dev/null 2>&1; then\n"
57
+ " echo '[ai-push-hooks] ai-push-hooks is not on PATH for the Git hook process.' >&2\n"
58
+ " echo '[ai-push-hooks] Add the Python/npm installation bin directory to PATH.' >&2\n"
59
+ " exit 127\n"
60
+ "fi\n"
61
+ )
62
+ else:
63
+ availability_check = ""
64
+ return (
65
+ "#!/bin/sh\n"
66
+ + availability_check
67
+ + f'exec {command} hook "$@"\n'
68
+ )
69
+
70
+
71
+ def _git_value(cwd: pathlib.Path, *args: str) -> str:
72
+ try:
73
+ completed = subprocess.run(
74
+ ["git", *args],
75
+ cwd=cwd,
76
+ check=True,
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=_GIT_QUERY_TIMEOUT,
80
+ )
81
+ except FileNotFoundError as exc:
82
+ raise HookError("Git is required for `ai-push-hooks install`") from exc
83
+ except subprocess.TimeoutExpired as exc:
84
+ raise HookError(f"Git command timed out while resolving hook location: {' '.join(args)}") from exc
85
+ except subprocess.CalledProcessError as exc:
86
+ detail = (exc.stderr or exc.stdout or "not a Git repository").strip()
87
+ raise HookError(f"Could not resolve Git hook location: {detail}") from exc
88
+ return completed.stdout.strip()
89
+
90
+
91
+ def _resolve_git_namespace(repo_root: pathlib.Path, value: str) -> pathlib.Path:
92
+ path = pathlib.Path(value)
93
+ return (repo_root / path).resolve() if not path.is_absolute() else path.resolve()
94
+
95
+
96
+ def _path_is_in_namespace(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> bool:
97
+ return any(is_path_within(path, namespace) for namespace in namespaces)
98
+
99
+
100
+ def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]) -> None:
101
+ """Reject symlink/reparse parents and create only missing safe directories."""
102
+ parent = path.parent
103
+ existing: list[pathlib.Path] = []
104
+ current = parent
105
+ while not current.exists():
106
+ existing.append(current)
107
+ current = current.parent
108
+ if path_is_link_or_reparse(current) or not current.is_dir():
109
+ raise HookError(f"Refusing unsafe hook parent: {parent}")
110
+
111
+ for directory in reversed(existing):
112
+ if not _path_is_in_namespace(directory.resolve(strict=False), namespaces):
113
+ raise HookError(f"Refusing hook parent outside the repository: {directory}")
114
+ try:
115
+ directory.mkdir(mode=0o755)
116
+ except FileExistsError:
117
+ pass
118
+ if path_is_link_or_reparse(directory) or not directory.is_dir():
119
+ raise HookError(f"Refusing unsafe hook parent: {directory}")
120
+
121
+ current = parent
122
+ while True:
123
+ if path_is_link_or_reparse(current) or not current.is_dir():
124
+ raise HookError(f"Refusing unsafe hook parent: {current}")
125
+ if _path_is_in_namespace(current.resolve(strict=False), namespaces):
126
+ break
127
+ if current.parent == current:
128
+ raise HookError(f"Refusing hook parent outside the repository: {parent}")
129
+ current = current.parent
130
+
131
+
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"))
135
+ common_dir = _resolve_git_namespace(
136
+ repo_root, _git_value(repo_root, "rev-parse", "--git-common-dir")
137
+ )
138
+ raw_hooks_dir = pathlib.Path(_git_value(current_dir, "rev-parse", "--git-path", "hooks"))
139
+ lexical_hooks_dir = (
140
+ raw_hooks_dir if raw_hooks_dir.is_absolute() else current_dir / raw_hooks_dir
141
+ )
142
+ 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}")
144
+ 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}")
146
+ 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}")
148
+ lexical_hook_path = lexical_hooks_dir / "pre-push"
149
+ if path_is_link_or_reparse(lexical_hook_path):
150
+ raise HookError(f"Refusing symlink or reparse-point hook target: {lexical_hook_path}")
151
+ hook_path = lexical_hook_path.resolve(strict=False)
152
+
153
+ namespaces = (repo_root, git_dir)
154
+ if not _path_is_in_namespace(hook_path, namespaces):
155
+ raise HookError(
156
+ "Refusing external or shared hooks path; configure a repository-local "
157
+ "core.hooksPath instead"
158
+ )
159
+ if common_dir != git_dir and is_path_within(hook_path, common_dir):
160
+ raise HookError("Refusing shared hooks path used by linked worktrees")
161
+ return repo_root, git_dir, common_dir, hook_path
162
+
163
+
164
+ def resolve_pre_push_hook_path(cwd: pathlib.Path) -> pathlib.Path:
165
+ """Resolve Git's effective pre-push path without changing Git configuration."""
166
+ return _effective_hook_path(cwd.resolve())[3]
167
+
168
+
169
+ def install_hook(force: bool, cwd: pathlib.Path | None = None) -> int:
170
+ current_dir = (cwd or pathlib.Path.cwd()).resolve()
171
+ _repo_root, git_dir, common_dir, hook_path = _effective_hook_path(current_dir)
172
+ if common_dir != git_dir and is_path_within(hook_path, common_dir):
173
+ raise HookError("Refusing shared hooks path used by linked worktrees")
174
+
175
+ namespaces = (_repo_root, git_dir)
176
+ _validate_parent_chain(hook_path, namespaces)
177
+ try:
178
+ metadata = hook_path.lstat()
179
+ except FileNotFoundError:
180
+ metadata = None
181
+ except OSError as exc:
182
+ raise HookError(f"Could not inspect pre-push hook path {hook_path}: {exc}") from exc
183
+
184
+ if metadata is not None:
185
+ if path_is_link_or_reparse(hook_path):
186
+ raise HookError(f"Refusing symlink or reparse-point hook target: {hook_path}")
187
+ if not stat.S_ISREG(metadata.st_mode):
188
+ raise HookError(f"Refusing non-regular hook target: {hook_path}")
189
+ if not force:
190
+ raise HookError(
191
+ f"Refusing to overwrite existing pre-push hook without --force: {hook_path}"
192
+ )
193
+
194
+ try:
195
+ atomic_write_bytes(
196
+ hook_path,
197
+ pre_push_hook_script(_delegate_argv()).encode("utf-8"),
198
+ mode=_HOOK_MODE,
199
+ )
200
+ except HookError:
201
+ raise
202
+ except OSError as exc:
203
+ raise HookError(f"Could not install pre-push hook {hook_path}: {exc}") from exc
204
+ sys.stdout.write(str(hook_path) + "\n")
205
+ return 0
@@ -2,23 +2,38 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from ..executors.exec import collect_commit_messages_for_ranges, current_branch, is_feature_branch
5
+ from ..executors.exec import collect_commit_messages_for_ranges, is_feature_branch
6
6
  from ..types import CollectorResult, RuntimeContext
7
7
 
8
8
 
9
9
  def collect_beads_status_context(context: RuntimeContext, state: Any) -> CollectorResult:
10
- branch_name = current_branch(context.repo_root)
10
+ branch_name = str(context.cache.get("branch_name", ""))
11
+ branch_selection_reason = str(
12
+ context.cache.get("branch_selection_reason", "no pushed branch updates")
13
+ )
14
+ if not branch_name:
15
+ return CollectorResult(
16
+ artifacts={
17
+ "branch-context.txt": (
18
+ f"branch=\nbranch_selection_reason={branch_selection_reason}\n"
19
+ )
20
+ },
21
+ skip_module=True,
22
+ skip_reason=branch_selection_reason,
23
+ )
11
24
  sync_branch = context.cache.get("sync_branch", "beads-sync")
12
- if not branch_name or branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(branch_name):
25
+ if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(branch_name):
13
26
  return CollectorResult(
14
27
  artifacts={"branch-context.txt": f"branch={branch_name}\n"},
15
28
  skip_module=True,
16
29
  skip_reason="branch does not require beads alignment",
17
30
  )
18
31
 
19
- ranges = context.cache.get("ranges", [])
20
- changed_files = context.cache.get("changed_files", [])
21
- diff_text = context.cache.get("diff_text", "")
32
+ ranges = context.cache.get("branch_ranges", context.cache.get("ranges", []))
33
+ changed_files = context.cache.get(
34
+ "branch_changed_files", context.cache.get("changed_files", [])
35
+ )
36
+ diff_text = context.cache.get("branch_diff_text", context.cache.get("diff_text", ""))
22
37
  commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
23
38
  report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
24
39
  commit_lines = []