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.
- package/CHANGELOG.md +63 -0
- package/README.md +312 -41
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +2 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +26 -4
- package/pyproject.toml +12 -4
- package/src/ai_push_hooks/artifacts.py +67 -11
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +284 -97
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +850 -29
- package/src/ai_push_hooks/executors/exec.py +735 -112
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +131 -21
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +55 -8
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +7 -2
- package/src/ai_push_hooks/types.py +83 -3
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
43
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
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=
|
|
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
|
-
"
|
|
107
|
-
"
|
|
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,
|
|
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 =
|
|
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
|
|
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(
|
|
21
|
-
|
|
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 = []
|