code-review-forge 2.0.0a1__py3-none-any.whl
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.
- code_forge/__init__.py +14 -0
- code_forge/__main__.py +8 -0
- code_forge/autofix.py +78 -0
- code_forge/baseline.py +216 -0
- code_forge/cli.py +983 -0
- code_forge/delta.py +65 -0
- code_forge/diagnose.py +109 -0
- code_forge/diff.py +82 -0
- code_forge/disposition.py +32 -0
- code_forge/e2e_check.py +641 -0
- code_forge/env_resolver.py +91 -0
- code_forge/errors.py +34 -0
- code_forge/exit_codes.py +37 -0
- code_forge/factories.py +191 -0
- code_forge/falsify.py +85 -0
- code_forge/gate_check.py +466 -0
- code_forge/git.py +351 -0
- code_forge/hold.py +126 -0
- code_forge/install_hooks.py +331 -0
- code_forge/lock.py +162 -0
- code_forge/machine.py +792 -0
- code_forge/mode_resolver.py +60 -0
- code_forge/mutation.py +380 -0
- code_forge/parsers/__init__.py +56 -0
- code_forge/parsers/_sarif.py +77 -0
- code_forge/parsers/base.py +65 -0
- code_forge/parsers/checkpatch.py +66 -0
- code_forge/parsers/clippy.py +85 -0
- code_forge/parsers/non_ascii.py +47 -0
- code_forge/parsers/ruff.py +18 -0
- code_forge/parsers/semgrep.py +18 -0
- code_forge/parsers/shellcheck.py +56 -0
- code_forge/registry.py +153 -0
- code_forge/reporter.py +133 -0
- code_forge/runner.py +205 -0
- code_forge/sarif.py +226 -0
- code_forge/skills/adversarial-qe/SKILL.md +272 -0
- code_forge/skills/code-forge/SKILL.md +1193 -0
- code_forge/skills/code-review-expert/SKILL.md +162 -0
- code_forge/skills/code-review-expert/references/code-quality-checklist.md +130 -0
- code_forge/skills/code-review-expert/references/removal-plan.md +52 -0
- code_forge/skills/code-review-expert/references/security-checklist.md +118 -0
- code_forge/skills/code-review-expert/references/solid-checklist.md +65 -0
- code_forge/skills/kernel-fp-verify/SKILL.md +101 -0
- code_forge/skills/qodo-review/SKILL.md +135 -0
- code_forge/skills/smoke-test/SKILL.md +253 -0
- code_forge/skills/smoke-test/references/boundary-cases.md +114 -0
- code_forge/skills/smoke-test/references/concurrency-patterns.md +306 -0
- code_forge/skills/smoke-test/references/injection-payloads.md +124 -0
- code_forge/skills/smoke-test/test-library/shell/README.md +271 -0
- code_forge/skills/smoke-test/test-library/shell/primitives.sh +352 -0
- code_forge/skills/smoke-test/test-library/shell/primitives_test.sh +324 -0
- code_forge/snapshot.py +196 -0
- code_forge/source.py +64 -0
- code_forge/state.py +246 -0
- code_forge/verdict.py +43 -0
- code_review_forge-2.0.0a1.dist-info/METADATA +237 -0
- code_review_forge-2.0.0a1.dist-info/RECORD +62 -0
- code_review_forge-2.0.0a1.dist-info/WHEEL +5 -0
- code_review_forge-2.0.0a1.dist-info/entry_points.txt +2 -0
- code_review_forge-2.0.0a1.dist-info/licenses/LICENSE +179 -0
- code_review_forge-2.0.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""install-hooks subcommand: write .git/hooks/pre-commit hook.
|
|
4
|
+
|
|
5
|
+
Resolves the hooks directory (worktree-safe), backs up and chains any
|
|
6
|
+
existing hook, embeds an absolute code-forge path, and aborts when
|
|
7
|
+
core.hooksPath is set. Idempotent on re-install.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import IO, Callable, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
from .exit_codes import EXIT_FAIL, EXIT_PASS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_hooks_dir(
|
|
24
|
+
cwd: Path,
|
|
25
|
+
run_cmd: Callable = subprocess.run,
|
|
26
|
+
) -> Path:
|
|
27
|
+
"""Resolve git hooks directory via git rev-parse --git-path hooks.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
cwd: working directory (repo root)
|
|
31
|
+
run_cmd: subprocess.run callable (injected for testing)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Absolute Path to hooks directory
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RuntimeError: if not in a git repo or git command fails
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
result = run_cmd(
|
|
41
|
+
["git", "rev-parse", "--git-path", "hooks"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=True,
|
|
45
|
+
timeout=5,
|
|
46
|
+
cwd=str(cwd),
|
|
47
|
+
)
|
|
48
|
+
hooks_path = result.stdout.strip()
|
|
49
|
+
except subprocess.CalledProcessError as e:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"Not in a git repository or git command failed: %s" % e
|
|
52
|
+
) from e
|
|
53
|
+
except subprocess.TimeoutExpired as e:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"git rev-parse timed out: %s" % e
|
|
56
|
+
) from e
|
|
57
|
+
|
|
58
|
+
# If relative, resolve against repo root
|
|
59
|
+
hooks_path_obj = Path(hooks_path)
|
|
60
|
+
if not hooks_path_obj.is_absolute():
|
|
61
|
+
# Get repo root via git rev-parse --show-toplevel
|
|
62
|
+
try:
|
|
63
|
+
root_result = run_cmd(
|
|
64
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=True,
|
|
68
|
+
timeout=5,
|
|
69
|
+
cwd=str(cwd),
|
|
70
|
+
)
|
|
71
|
+
repo_root = Path(root_result.stdout.strip())
|
|
72
|
+
hooks_path_obj = repo_root / hooks_path_obj
|
|
73
|
+
except subprocess.CalledProcessError as e:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"git rev-parse --show-toplevel failed: %s" % e
|
|
76
|
+
) from e
|
|
77
|
+
|
|
78
|
+
return hooks_path_obj.resolve()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_hooks_path_override(
|
|
82
|
+
cwd: Path,
|
|
83
|
+
run_cmd: Callable = subprocess.run,
|
|
84
|
+
) -> str | None:
|
|
85
|
+
"""Check if core.hooksPath is set.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
cwd: working directory
|
|
89
|
+
run_cmd: subprocess.run callable (injected for testing)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The value of core.hooksPath if set and non-empty, None otherwise
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
result = run_cmd(
|
|
96
|
+
["git", "config", "--get", "core.hooksPath"],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
check=False,
|
|
100
|
+
timeout=5,
|
|
101
|
+
cwd=str(cwd),
|
|
102
|
+
)
|
|
103
|
+
# returncode 1 means "not set", which is normal
|
|
104
|
+
if result.returncode == 0:
|
|
105
|
+
value = result.stdout.strip()
|
|
106
|
+
return value if value else None
|
|
107
|
+
return None
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_forge_path() -> str:
|
|
113
|
+
"""Resolve absolute code-forge path for hook embedding.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Absolute path string that will be embedded in hook.
|
|
117
|
+
For single executable: "/usr/local/bin/code-forge gate-check"
|
|
118
|
+
For python module: "/usr/bin/python3 -m code_forge gate-check"
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
RuntimeError: if no valid code-forge executable path found
|
|
122
|
+
"""
|
|
123
|
+
logger = logging.getLogger("code_forge")
|
|
124
|
+
|
|
125
|
+
# Try shutil.which('code-forge') first
|
|
126
|
+
forge_exe = shutil.which("code-forge")
|
|
127
|
+
if forge_exe is not None and os.access(forge_exe, os.X_OK):
|
|
128
|
+
# Run liveness check
|
|
129
|
+
try:
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
[forge_exe, "--version"],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=1,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
if (
|
|
138
|
+
result.returncode == 0
|
|
139
|
+
and result.stdout.strip().startswith("code-forge ")
|
|
140
|
+
):
|
|
141
|
+
return "%s gate-check" % shlex.quote(forge_exe)
|
|
142
|
+
else:
|
|
143
|
+
logger.warning(
|
|
144
|
+
"code-forge at %s failed --version check; "
|
|
145
|
+
"falling back to sys.executable",
|
|
146
|
+
forge_exe,
|
|
147
|
+
)
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"code-forge at %s --version timed out; "
|
|
151
|
+
"falling back to sys.executable",
|
|
152
|
+
forge_exe,
|
|
153
|
+
)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"code-forge at %s --version raised %s; "
|
|
157
|
+
"falling back to sys.executable",
|
|
158
|
+
forge_exe,
|
|
159
|
+
e,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Fallback to sys.executable + ' -m code_forge'
|
|
163
|
+
if sys.executable and os.access(sys.executable, os.X_OK):
|
|
164
|
+
return "%s -m code_forge gate-check" % shlex.quote(sys.executable)
|
|
165
|
+
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"Cannot resolve code-forge path: 'code-forge' not on PATH and "
|
|
168
|
+
"sys.executable is not valid"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def generate_hook_content(
|
|
173
|
+
forge_invocation: str,
|
|
174
|
+
chain_path: Path | None,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Generate pre-commit hook shell script content.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
forge_invocation: absolute code-forge path + args (e.g. "/usr/local/bin/code-forge gate-check")
|
|
180
|
+
chain_path: Path to existing hook backup, or None
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Shell script content as string
|
|
184
|
+
"""
|
|
185
|
+
if chain_path is not None:
|
|
186
|
+
return f"""#!/bin/sh
|
|
187
|
+
# code-forge pre-commit gate-check (installed by code-forge install-hooks)
|
|
188
|
+
# Chained existing hook: {chain_path}
|
|
189
|
+
"{chain_path}" "$@" || exit 1
|
|
190
|
+
exec {forge_invocation}
|
|
191
|
+
"""
|
|
192
|
+
else:
|
|
193
|
+
return f"""#!/bin/sh
|
|
194
|
+
# code-forge pre-commit gate-check (installed by code-forge install-hooks)
|
|
195
|
+
exec {forge_invocation}
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_install_hooks(
|
|
200
|
+
args=None,
|
|
201
|
+
env: Optional[Mapping[str, str]] = None,
|
|
202
|
+
cwd: Optional[Path] = None,
|
|
203
|
+
stdout: Optional[IO] = None,
|
|
204
|
+
stderr: Optional[IO] = None,
|
|
205
|
+
) -> int:
|
|
206
|
+
"""Main install-hooks entry point.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
args: parsed argparse Namespace; reads args.quiet if present
|
|
210
|
+
env: environment variables (os.environ if None)
|
|
211
|
+
cwd: working directory (Path.cwd() if None)
|
|
212
|
+
stdout: output stream (sys.stdout if None)
|
|
213
|
+
stderr: error stream (sys.stderr if None)
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
EXIT_PASS (0) on success, EXIT_FAIL (1) on any error
|
|
217
|
+
"""
|
|
218
|
+
if env is None:
|
|
219
|
+
env = os.environ
|
|
220
|
+
if cwd is None:
|
|
221
|
+
cwd = Path.cwd()
|
|
222
|
+
if stdout is None:
|
|
223
|
+
stdout = sys.stdout
|
|
224
|
+
if stderr is None:
|
|
225
|
+
stderr = sys.stderr
|
|
226
|
+
|
|
227
|
+
quiet = getattr(args, "quiet", False)
|
|
228
|
+
|
|
229
|
+
def info(msg):
|
|
230
|
+
if not quiet:
|
|
231
|
+
print(msg, file=stderr)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Step a: check core.hooksPath override
|
|
235
|
+
hooks_path_override = check_hooks_path_override(cwd)
|
|
236
|
+
if hooks_path_override is not None:
|
|
237
|
+
print(
|
|
238
|
+
"code-forge: error: core.hooksPath is set to '%s'. "
|
|
239
|
+
"code-forge install-hooks cannot install to a custom hooks path. "
|
|
240
|
+
"Add 'exec /path/to/code-forge gate-check' to your pre-commit hook manually."
|
|
241
|
+
% hooks_path_override,
|
|
242
|
+
file=stderr,
|
|
243
|
+
)
|
|
244
|
+
return EXIT_FAIL
|
|
245
|
+
|
|
246
|
+
# Step b: resolve hooks directory
|
|
247
|
+
hooks_dir = resolve_hooks_dir(cwd)
|
|
248
|
+
|
|
249
|
+
# Step c: check for .pre-commit-config.yaml
|
|
250
|
+
pre_commit_config = cwd / ".pre-commit-config.yaml"
|
|
251
|
+
if pre_commit_config.exists():
|
|
252
|
+
info(
|
|
253
|
+
"code-forge: warning: pre-commit framework detected. "
|
|
254
|
+
"code-forge hook will chain after existing hooks."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Step d: resolve code-forge absolute path
|
|
258
|
+
forge_invocation = resolve_forge_path()
|
|
259
|
+
|
|
260
|
+
# Step e: check for existing pre-commit hook
|
|
261
|
+
hook_path = hooks_dir / "pre-commit"
|
|
262
|
+
backup_path = hooks_dir / "pre-commit.code-forge-backup"
|
|
263
|
+
chain_path = None
|
|
264
|
+
|
|
265
|
+
if hook_path.exists():
|
|
266
|
+
# Read first 3 lines to check if it's a code-forge-generated hook
|
|
267
|
+
try:
|
|
268
|
+
with open(hook_path, "r", encoding="utf-8") as f:
|
|
269
|
+
first_lines = [f.readline() for _ in range(3)]
|
|
270
|
+
hook_header = "".join(first_lines)
|
|
271
|
+
is_forge_hook = (
|
|
272
|
+
"code-forge gate-check" in hook_header
|
|
273
|
+
or "installed by code-forge install-hooks" in hook_header
|
|
274
|
+
)
|
|
275
|
+
except (OSError, UnicodeDecodeError):
|
|
276
|
+
is_forge_hook = False
|
|
277
|
+
|
|
278
|
+
if is_forge_hook:
|
|
279
|
+
# Idempotent re-install: skip backup, overwrite
|
|
280
|
+
info(
|
|
281
|
+
"code-forge: re-installing hook (existing is code-forge-generated)"
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
# Backup existing non-code-forge hook
|
|
285
|
+
if backup_path.exists():
|
|
286
|
+
# Backup already exists from a prior install.
|
|
287
|
+
# Current hook_path has unknown content that would be
|
|
288
|
+
# overwritten without a backup. Block and ask the user
|
|
289
|
+
# to resolve manually.
|
|
290
|
+
print(
|
|
291
|
+
"code-forge: error: pre-commit.code-forge-backup already "
|
|
292
|
+
"exists at %s and a non-code-forge hook is at %s. "
|
|
293
|
+
"Remove one of them manually, then re-run "
|
|
294
|
+
"code-forge install-hooks."
|
|
295
|
+
% (backup_path, hook_path),
|
|
296
|
+
file=stderr,
|
|
297
|
+
)
|
|
298
|
+
return EXIT_FAIL
|
|
299
|
+
else:
|
|
300
|
+
# Move existing hook to backup
|
|
301
|
+
shutil.move(str(hook_path), str(backup_path))
|
|
302
|
+
info(
|
|
303
|
+
"code-forge: existing hook backed up to %s"
|
|
304
|
+
% backup_path
|
|
305
|
+
)
|
|
306
|
+
chain_path = backup_path
|
|
307
|
+
|
|
308
|
+
# Step f: generate hook content
|
|
309
|
+
hook_content = generate_hook_content(forge_invocation, chain_path)
|
|
310
|
+
|
|
311
|
+
# Step g: write hook file
|
|
312
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
with open(hook_path, "w", encoding="utf-8") as f:
|
|
314
|
+
f.write(hook_content)
|
|
315
|
+
|
|
316
|
+
# Step h: chmod 0o755
|
|
317
|
+
os.chmod(hook_path, 0o755)
|
|
318
|
+
|
|
319
|
+
# Step i: success message
|
|
320
|
+
info(
|
|
321
|
+
"code-forge: pre-commit hook installed at %s" % hook_path
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return EXIT_PASS
|
|
325
|
+
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(
|
|
328
|
+
"code-forge: error: install-hooks failed: %s" % e,
|
|
329
|
+
file=stderr,
|
|
330
|
+
)
|
|
331
|
+
return EXIT_FAIL
|
code_forge/lock.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""STATE-11 file lock with PID liveness probing.
|
|
4
|
+
|
|
5
|
+
Race-safe atomic acquire via O_CREAT|O_EXCL. Stale-PID recovery via
|
|
6
|
+
os.kill(pid, 0) liveness check.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Optional, Type
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ForgeLockBusy(Exception):
|
|
18
|
+
"""Raised when another live forge process holds the lock.
|
|
19
|
+
|
|
20
|
+
.pid attribute carries the holder PID for caller logging.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, pid: int, path: Path):
|
|
24
|
+
self.pid = pid
|
|
25
|
+
self.path = path
|
|
26
|
+
super().__init__(
|
|
27
|
+
"another forge process is running (PID %d, lock %s)"
|
|
28
|
+
% (pid, path)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ForgeLock:
|
|
33
|
+
"""Context manager + explicit release().
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
with ForgeLock(Path(".code-forge/forge.lock")) as lock:
|
|
37
|
+
... do forge work ...
|
|
38
|
+
# released on normal exit OR on exception in body OR on
|
|
39
|
+
# SIGINT/SIGTERM.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, path: Path):
|
|
43
|
+
self.path = path
|
|
44
|
+
self._held = False
|
|
45
|
+
self._original_sigint = None
|
|
46
|
+
self._original_sigterm = None
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> "ForgeLock":
|
|
49
|
+
acquire_lock(self.path)
|
|
50
|
+
self._held = True
|
|
51
|
+
self._install_signal_handlers()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(
|
|
55
|
+
self,
|
|
56
|
+
exc_type: Optional[Type[BaseException]],
|
|
57
|
+
exc: Optional[BaseException],
|
|
58
|
+
tb: Optional[TracebackType],
|
|
59
|
+
) -> None:
|
|
60
|
+
self.release()
|
|
61
|
+
|
|
62
|
+
def release(self) -> None:
|
|
63
|
+
if self._held and self.path.exists():
|
|
64
|
+
try:
|
|
65
|
+
stored = int(self.path.read_text().strip())
|
|
66
|
+
if stored == os.getpid():
|
|
67
|
+
self.path.unlink()
|
|
68
|
+
except (ValueError, FileNotFoundError):
|
|
69
|
+
pass
|
|
70
|
+
self._held = False
|
|
71
|
+
self._restore_signal_handlers()
|
|
72
|
+
|
|
73
|
+
def _install_signal_handlers(self) -> None:
|
|
74
|
+
"""Save + chain previous handler per R1 B1."""
|
|
75
|
+
def _make_chained_handler(prev):
|
|
76
|
+
def _handler(signum, frame):
|
|
77
|
+
try:
|
|
78
|
+
self.release()
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
pass
|
|
81
|
+
if callable(prev):
|
|
82
|
+
prev(signum, frame)
|
|
83
|
+
return
|
|
84
|
+
if prev == signal.SIG_IGN:
|
|
85
|
+
return
|
|
86
|
+
raise KeyboardInterrupt
|
|
87
|
+
return _handler
|
|
88
|
+
|
|
89
|
+
prev_sigint = signal.getsignal(signal.SIGINT)
|
|
90
|
+
prev_sigterm = signal.getsignal(signal.SIGTERM)
|
|
91
|
+
self._original_sigint = prev_sigint
|
|
92
|
+
self._original_sigterm = prev_sigterm
|
|
93
|
+
signal.signal(
|
|
94
|
+
signal.SIGINT, _make_chained_handler(prev_sigint)
|
|
95
|
+
)
|
|
96
|
+
signal.signal(
|
|
97
|
+
signal.SIGTERM, _make_chained_handler(prev_sigterm)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _restore_signal_handlers(self) -> None:
|
|
101
|
+
if self._original_sigint is not None:
|
|
102
|
+
signal.signal(signal.SIGINT, self._original_sigint)
|
|
103
|
+
self._original_sigint = None
|
|
104
|
+
if self._original_sigterm is not None:
|
|
105
|
+
signal.signal(signal.SIGTERM, self._original_sigterm)
|
|
106
|
+
self._original_sigterm = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def acquire_lock(path: Path) -> None:
|
|
110
|
+
"""Acquire lock exclusively. Recovers from stale PID locks.
|
|
111
|
+
|
|
112
|
+
On EEXIST: probe PID liveness; alive -> ForgeLockBusy; dead -> remove
|
|
113
|
+
+ retry (single retry; second EEXIST -> ForgeLockBusy with pid=-1).
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ForgeLockBusy: another live forge holds the lock.
|
|
117
|
+
OSError: filesystem error (parent dir missing, EACCES, etc).
|
|
118
|
+
"""
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
for attempt in range(2):
|
|
121
|
+
try:
|
|
122
|
+
fd = os.open(
|
|
123
|
+
str(path),
|
|
124
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
125
|
+
0o644,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
os.write(fd, ("%d\n" % os.getpid()).encode("ascii"))
|
|
129
|
+
finally:
|
|
130
|
+
os.close(fd)
|
|
131
|
+
return
|
|
132
|
+
except FileExistsError:
|
|
133
|
+
if attempt == 1:
|
|
134
|
+
raise ForgeLockBusy(-1, path)
|
|
135
|
+
_handle_existing_lock(path)
|
|
136
|
+
except IsADirectoryError:
|
|
137
|
+
raise OSError(
|
|
138
|
+
"lock path %s is a directory, not a file "
|
|
139
|
+
"(remove it manually)" % path
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _handle_existing_lock(path: Path) -> None:
|
|
144
|
+
"""Read PID; check liveness; remove if dead."""
|
|
145
|
+
try:
|
|
146
|
+
pid = int(path.read_text().strip())
|
|
147
|
+
except (ValueError, FileNotFoundError):
|
|
148
|
+
try:
|
|
149
|
+
path.unlink()
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
pass
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
os.kill(pid, 0)
|
|
155
|
+
raise ForgeLockBusy(pid, path)
|
|
156
|
+
except ProcessLookupError:
|
|
157
|
+
try:
|
|
158
|
+
path.unlink()
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
pass
|
|
161
|
+
except PermissionError:
|
|
162
|
+
raise ForgeLockBusy(pid, path)
|