豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 9ed6dad

Browse files
authored
chore: add Codex Stop hook for targeted Ruff tidy (#2795)
1 parent ce0d792 commit 9ed6dad

File tree

3 files changed

+241
-0
lines changed

3 files changed

+241
-0
lines changed

.codex/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#:schema https://developers.openai.com/codex/config-schema.json
2+
3+
[features]
4+
codex_hooks = true

.codex/hooks.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"Stop": [
4+
{
5+
"hooks": [
6+
{
7+
"type": "command",
8+
"command": "uv run python \"$(git rev-parse --show-toplevel)/.codex/hooks/stop_repo_tidy.py\"",
9+
"timeout": 20
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

.codex/hooks/stop_repo_tidy.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import subprocess
8+
import sys
9+
import tempfile
10+
from dataclasses import asdict, dataclass
11+
from pathlib import Path
12+
13+
MAX_RUFF_FIX_FILES = 20
14+
PYTHON_SUFFIXES = {".py", ".pyi"}
15+
16+
17+
@dataclass
18+
class HookState:
19+
last_tidy_fingerprint: str | None = None
20+
21+
22+
def write_stop_block(reason: str, system_message: str) -> None:
23+
sys.stdout.write(
24+
json.dumps(
25+
{
26+
"decision": "block",
27+
"reason": reason,
28+
"systemMessage": system_message,
29+
}
30+
)
31+
)
32+
33+
34+
def run_command(cwd: str, *args: str) -> subprocess.CompletedProcess[str]:
35+
try:
36+
return subprocess.run(
37+
args,
38+
cwd=cwd,
39+
capture_output=True,
40+
check=False,
41+
text=True,
42+
)
43+
except FileNotFoundError as exc:
44+
return subprocess.CompletedProcess(args, returncode=127, stdout="", stderr=str(exc))
45+
46+
47+
def run_git(cwd: str, *args: str) -> subprocess.CompletedProcess[str]:
48+
return run_command(cwd, "git", *args)
49+
50+
51+
def git_root(cwd: str) -> str:
52+
result = run_git(cwd, "rev-parse", "--show-toplevel")
53+
if result.returncode != 0:
54+
raise RuntimeError(result.stderr.strip() or "git root lookup failed")
55+
return result.stdout.strip()
56+
57+
58+
def parse_status_paths(repo_root: str) -> list[str]:
59+
unstaged = run_git(repo_root, "diff", "--name-only", "--diff-filter=ACMR")
60+
untracked = run_git(repo_root, "ls-files", "--others", "--exclude-standard")
61+
if unstaged.returncode != 0 or untracked.returncode != 0:
62+
return []
63+
64+
paths = {
65+
line.strip()
66+
for result in (unstaged, untracked)
67+
for line in result.stdout.splitlines()
68+
if line.strip()
69+
}
70+
return sorted(paths)
71+
72+
73+
def untracked_paths(repo_root: str, paths: list[str]) -> set[str]:
74+
if not paths:
75+
return set()
76+
77+
result = run_git(repo_root, "ls-files", "--others", "--exclude-standard", "--", *paths)
78+
if result.returncode != 0:
79+
return set()
80+
81+
return {line.strip() for line in result.stdout.splitlines() if line.strip()}
82+
83+
84+
def fingerprint_for_paths(repo_root: str, paths: list[str]) -> str | None:
85+
if not paths:
86+
return None
87+
88+
repo_root_path = Path(repo_root)
89+
untracked = untracked_paths(repo_root, paths)
90+
tracked_paths = [file_path for file_path in paths if file_path not in untracked]
91+
diff_parts: list[str] = []
92+
93+
if tracked_paths:
94+
diff = run_git(repo_root, "diff", "--no-ext-diff", "--binary", "--", *tracked_paths)
95+
if diff.returncode == 0:
96+
diff_parts.append(diff.stdout)
97+
98+
for file_path in sorted(untracked):
99+
try:
100+
digest = hashlib.sha256((repo_root_path / file_path).read_bytes()).hexdigest()
101+
except OSError:
102+
continue
103+
diff_parts.append(f"untracked:{file_path}:{digest}")
104+
105+
if not diff_parts:
106+
return None
107+
108+
return hashlib.sha256("\n".join(diff_parts).encode("utf-8")).hexdigest()
109+
110+
111+
def state_dir() -> Path:
112+
return Path(tempfile.gettempdir()) / "openai-agents-python-codex-hooks"
113+
114+
115+
def state_path(session_id: str, repo_root: str) -> Path:
116+
root_hash = hashlib.sha256(repo_root.encode("utf-8")).hexdigest()[:12]
117+
safe_session_id = "".join(
118+
ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in session_id
119+
)
120+
return state_dir() / f"{safe_session_id}-{root_hash}.json"
121+
122+
123+
def load_state(session_id: str, repo_root: str) -> HookState:
124+
file_path = state_path(session_id, repo_root)
125+
if not file_path.exists():
126+
return HookState()
127+
128+
try:
129+
payload = json.loads(file_path.read_text())
130+
except (OSError, json.JSONDecodeError):
131+
return HookState()
132+
133+
return HookState(last_tidy_fingerprint=payload.get("last_tidy_fingerprint"))
134+
135+
136+
def save_state(session_id: str, repo_root: str, state: HookState) -> None:
137+
file_path = state_path(session_id, repo_root)
138+
file_path.parent.mkdir(parents=True, exist_ok=True)
139+
file_path.write_text(json.dumps(asdict(state), indent=2))
140+
141+
142+
def lint_fix_paths(repo_root: str) -> list[str]:
143+
return [
144+
file_path
145+
for file_path in parse_status_paths(repo_root)
146+
if Path(file_path).suffix in PYTHON_SUFFIXES
147+
]
148+
149+
150+
def main() -> None:
151+
try:
152+
payload = json.loads(sys.stdin.read() or "null")
153+
except json.JSONDecodeError:
154+
return
155+
156+
if not isinstance(payload, dict):
157+
return
158+
159+
session_id = payload.get("session_id")
160+
cwd = payload.get("cwd")
161+
if not isinstance(session_id, str) or not isinstance(cwd, str):
162+
return
163+
164+
if payload.get("stop_hook_active"):
165+
return
166+
167+
repo_root = git_root(cwd)
168+
current_paths = lint_fix_paths(repo_root)
169+
if not current_paths or len(current_paths) > MAX_RUFF_FIX_FILES:
170+
return
171+
172+
state = load_state(session_id, repo_root)
173+
current_fingerprint = fingerprint_for_paths(repo_root, current_paths)
174+
if current_fingerprint is None or state.last_tidy_fingerprint == current_fingerprint:
175+
return
176+
177+
format_result = run_command(repo_root, "uv", "run", "ruff", "format", "--", *current_paths)
178+
check_result: subprocess.CompletedProcess[str] | None = None
179+
if format_result.returncode == 0:
180+
check_result = run_command(
181+
repo_root,
182+
"uv",
183+
"run",
184+
"ruff",
185+
"check",
186+
"--fix",
187+
"--",
188+
*current_paths,
189+
)
190+
191+
if format_result.returncode != 0:
192+
write_stop_block(
193+
"`uv run ruff format -- ...` failed for the touched Python files. "
194+
"Review the formatting step before wrapping up.",
195+
"Repo hook: targeted Ruff format failed.",
196+
)
197+
return
198+
199+
if check_result and check_result.returncode != 0:
200+
write_stop_block(
201+
"`uv run ruff check --fix -- ...` failed for the touched Python files. "
202+
"Review the lint output before wrapping up.",
203+
"Repo hook: targeted Ruff lint fix failed.",
204+
)
205+
return
206+
207+
updated_paths = lint_fix_paths(repo_root)
208+
updated_fingerprint = fingerprint_for_paths(repo_root, updated_paths)
209+
state.last_tidy_fingerprint = updated_fingerprint
210+
save_state(session_id, repo_root, state)
211+
212+
if updated_fingerprint != current_fingerprint:
213+
write_stop_block(
214+
"I ran targeted tidy steps on the touched Python files "
215+
"(`ruff format` and `ruff check --fix`). Review the updated diff, "
216+
"then continue or wrap up.",
217+
"Repo hook: ran targeted Ruff tidy on touched files.",
218+
)
219+
220+
221+
if __name__ == "__main__":
222+
main()

0 commit comments

Comments
 (0)