-
-
Notifications
You must be signed in to change notification settings - Fork 40
doc-gate: git failures are indistinguishable from a doc violation (bad base ref -> raw traceback, exit 1) #2393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,10 +42,13 @@ | |
| # for argparse, never our own code), 3 a config error (broken, missing, or | ||
| # unparseable config). 3 is kept off 2 so a typo'd flag is never mistaken for | ||
| # a bad config: a misconfigured gate must be distinguishable from both a real | ||
| # documentation-drift violation and a usage mistake. | ||
| # documentation-drift violation and a usage mistake. 4 is a git infrastructure | ||
| # failure (missing ref, network error, shallow clone, etc.) so it is never | ||
| # confused with a rule violation. | ||
| EXIT_OK = 0 | ||
| EXIT_VIOLATION = 1 | ||
| EXIT_CONFIG_ERROR = 3 | ||
| EXIT_GIT_ERROR = 4 | ||
|
|
||
| # A path-like token: one of the four known repo prefixes followed by a run of | ||
| # non-whitespace / non-quoting characters. The negative lookbehind stops us | ||
|
|
@@ -283,11 +286,22 @@ def evaluate_rules( | |
| return failures | ||
|
|
||
|
|
||
| def _run_git(args: list[str]) -> str: | ||
| result = subprocess.run( | ||
| ["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=True, | ||
| ) | ||
| return result.stdout | ||
| class GitCommandError(Exception): | ||
| """Raised when a git command fails, so infrastructure failures are | ||
| distinguishable from genuine doc-gate violations.""" | ||
|
|
||
|
|
||
| def _run_git(args: list[str], ref: str | None = None) -> str: | ||
| try: | ||
| result = subprocess.run( | ||
| ["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=True, | ||
| ) | ||
| return result.stdout | ||
| except subprocess.CalledProcessError: | ||
| msg = f"git {' '.join(args)} failed" | ||
| if ref: | ||
| msg += f" (ref: {ref})" | ||
| raise GitCommandError(msg) from None | ||
|
Comment on lines
+295
to
+304
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target structure ---'
ast-grep outline scripts/check_doc_gate.py
printf '%s\n' '--- target implementation ---'
sed -n '270,315p' scripts/check_doc_gate.py
printf '%s\n' '--- exception handling and tests ---'
sed -n '390,430p' scripts/check_doc_gate.py
rg -n --glob '*.py' 'GitCommandError|subprocess\.run|check_doc_gate|FileNotFoundError|CalledProcessError' .Repository: jaylfc/taOS Length of output: 26250 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- existing Git error tests ---'
sed -n '1,55p' tests/test_check_doc_gate.py
sed -n '205,255p' tests/test_check_doc_gate.py
printf '%s\n' '--- Git helper call sites ---'
sed -n '315,350p' scripts/check_doc_gate.py
printf '%s\n' '--- CLI parser and main flow ---'
sed -n '350,425p' scripts/check_doc_gate.pyRepository: jaylfc/taOS Length of output: 8728 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import ast
import subprocess
path = "scripts/check_doc_gate.py"
tree = ast.parse(open(path, encoding="utf-8").read())
run_calls = [
node for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "run"
]
run = next(
node for node in run_calls
if isinstance(node.func.value, ast.Attribute)
and isinstance(node.func.value.value, ast.Name)
and node.func.value.value.id == "subprocess"
)
parent = None
for node in ast.walk(tree):
if isinstance(node, ast.Try) and run in ast.walk(node):
parent = node
break
print("subprocess.run call found:", bool(run_calls))
print("handler exception types:", [
ast.unparse(handler.type) for handler in parent.handlers
])
print("main catches GitCommandError:", any(
isinstance(node, ast.ExceptHandler)
and isinstance(node.type, ast.Name)
and node.type.id == "GitCommandError"
for node in ast.walk(tree)
))
try:
subprocess.run(
["__taos_missing_git_probe__"],
capture_output=True,
text=True,
check=True,
)
except OSError as exc:
print("missing executable raises:", type(exc).__name__)
PYRepository: jaylfc/taOS Length of output: 237 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import ast
import subprocess
path = "scripts/check_doc_gate.py"
tree = ast.parse(open(path, encoding="utf-8").read())
run = next(
node for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "run"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "subprocess"
)
parent = next(
node for node in ast.walk(tree)
if isinstance(node, ast.Try) and run in ast.walk(node)
)
print("subprocess.run call found:", True)
print("handler exception types:", [
ast.unparse(handler.type) for handler in parent.handlers
])
print("main catches GitCommandError:", any(
isinstance(node, ast.ExceptHandler)
and isinstance(node.type, ast.Name)
and node.type.id == "GitCommandError"
for node in ast.walk(tree)
))
try:
subprocess.run(
["__taos_missing_git_probe__"],
capture_output=True,
text=True,
check=True,
)
except OSError as exc:
print("missing executable raises:", type(exc).__name__)
PYRepository: jaylfc/taOS Length of output: 320 Translate Git process-launch failures.
🧰 Tools🪛 ast-grep (0.45.1)[error] 293-295: Command coming from incoming request (subprocess-from-request) 🪛 Ruff (0.16.1)[error] 294-294: (S603) [error] 295-295: Starting a process with a partial executable path (S607) 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _parse_name_status(output: str) -> list[tuple[str, str]]: | ||
|
|
@@ -309,11 +323,11 @@ def _git_changed_staged() -> list[tuple[str, str]]: | |
|
|
||
|
|
||
| def _git_changed_base(base_ref: str) -> list[tuple[str, str]]: | ||
| return _parse_name_status(_run_git(["diff", "--name-status", f"{base_ref}...HEAD"])) | ||
| return _parse_name_status(_run_git(["diff", "--name-status", f"{base_ref}...HEAD"], ref=base_ref)) | ||
|
|
||
|
|
||
| def _git_commit_messages(base_ref: str) -> list[str]: | ||
| out = _run_git(["log", f"{base_ref}..HEAD", "--format=%B%x00"]) | ||
| out = _run_git(["log", f"{base_ref}..HEAD", "--format=%B%x00"], ref=base_ref) | ||
| return [m for m in out.split("\x00") if m.strip()] | ||
|
|
||
|
|
||
|
|
@@ -323,7 +337,7 @@ def _git_commits_with_messages(base_ref: str) -> list[tuple[str, str, str]]: | |
| # inside it. A record terminator distinct from the field separator is what | ||
| # makes this parseable: with one separator for both, the flat split cannot | ||
| # tell a new commit's hash from the previous commit's body. | ||
| out = _run_git(["log", f"{base_ref}..HEAD", "--format=%H%x1f%an%x1f%B%x1e"]) | ||
| out = _run_git(["log", f"{base_ref}..HEAD", "--format=%H%x1f%an%x1f%B%x1e"], ref=base_ref) | ||
| commits: list[tuple[str, str, str]] = [] | ||
| for record in out.split("\x1e"): | ||
| if not record.strip(): | ||
|
|
@@ -393,14 +407,18 @@ def main(argv: list[str] | None = None) -> int: | |
| return 0 | ||
|
|
||
| # diff-gate | ||
| if args.staged: | ||
| changed = _git_changed_staged() | ||
| commit_messages: list[str] = [] | ||
| else: | ||
| changed = _git_changed_base(args.base) | ||
| commits_meta = _git_commits_with_messages(args.base) | ||
| commit_messages = [msg for _hash, _author, msg in commits_meta] | ||
| _log_trailer_usage(commits_meta, get_trailer(config)) | ||
| try: | ||
| if args.staged: | ||
| changed = _git_changed_staged() | ||
| commit_messages: list[str] = [] | ||
| else: | ||
| changed = _git_changed_base(args.base) | ||
| commits_meta = _git_commits_with_messages(args.base) | ||
| commit_messages = [msg for _hash, _author, msg in commits_meta] | ||
| _log_trailer_usage(commits_meta, get_trailer(config)) | ||
| except GitCommandError as e: | ||
| print(f"doc-gate: git error: {e}", file=sys.stderr) | ||
| return EXIT_GIT_ERROR | ||
|
|
||
| failures = evaluate_rules(changed, commit_messages, config) | ||
| return _report(failures) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import importlib.util | ||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
|
|
@@ -216,3 +218,45 @@ def test_extractor_ignores_hyphen_glued_prefix(self): | |
| "read ~/.claude/projects/-home-x-tinyagentos/memory/MEMORY.md at start" | ||
| ) | ||
| assert toks == [] | ||
|
|
||
|
|
||
| class TestGitCommandErrorHandling: | ||
| """Git infrastructure failures must never be confused with rule violations.""" | ||
|
|
||
| def test_nonexistent_base_ref_exits_git_error(self, capsys): | ||
| """A bad --base ref should produce the new exit code with a clear message.""" | ||
| error = subprocess.CalledProcessError( | ||
| 128, | ||
| ["git", "diff", "--name-status", "origin/no-such-ref...HEAD"], | ||
| ) | ||
| error.stderr = "fatal: bad revision 'origin/no-such-ref'\n" | ||
|
|
||
| with patch.object(_MOD.subprocess, "run", side_effect=error): | ||
| code = _MOD.main(["diff-gate", "--base", "origin/no-such-ref"]) | ||
| assert code == _MOD.EXIT_GIT_ERROR | ||
| captured = capsys.readouterr() | ||
| assert "diff" in captured.err | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Test does not verify stderr preservation The test sets Reply with |
||
| assert "origin/no-such-ref" in captured.err | ||
| assert "Traceback" not in captured.err | ||
|
|
||
| def test_genuine_violation_still_exits_1(self, capsys): | ||
| """A real rule violation must still exit 1.""" | ||
| mock_result_diff = MagicMock() | ||
| mock_result_diff.stdout = "A\ttinyagentos/routes/themes.py\n" | ||
| mock_result_log = MagicMock() | ||
| mock_result_log.stdout = "\x00" | ||
|
|
||
| with patch.object(_MOD.subprocess, "run", side_effect=[mock_result_diff, mock_result_log]): | ||
| code = _MOD.main(["diff-gate", "--base", "origin/HEAD"]) | ||
| assert code == _MOD.EXIT_VIOLATION | ||
|
|
||
| def test_clean_run_still_exits_0(self, capsys): | ||
| """A clean run must still exit 0.""" | ||
| mock_result_diff = MagicMock() | ||
| mock_result_diff.stdout = "" | ||
| mock_result_log = MagicMock() | ||
| mock_result_log.stdout = "\x00" | ||
|
|
||
| with patch.object(_MOD.subprocess, "run", side_effect=[mock_result_diff, mock_result_log]): | ||
| code = _MOD.main(["diff-gate", "--base", "origin/HEAD"]) | ||
| assert code == _MOD.EXIT_OK | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING:
CalledProcessError.stderris discardedThe
except subprocess.CalledProcessError:block does not bind the exception, so the actual git error output (e.g.,fatal: bad revision 'origin/no-such-ref') is lost. Users see a generic "git diff ... failed" message without the underlying cause, making debugging harder.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.