Skip to content

fix: raise GitTimeoutError instead of returning empty on git/svn timeout - #922

Open
yzxcj797 wants to merge 1 commit into
tirth8205:stagingfrom
yzxcj797:fix/913-git-timeout-status
Open

yzxcj797 wants to merge 1 commit into
tirth8205:stagingfrom
yzxcj797:fix/913-git-timeout-status

Conversation

@yzxcj797

Copy link
Copy Markdown
Contributor

Summary

A git or svn diff that exceeded CRG_GIT_TIMEOUT was caught by a broad OSError/SubprocessError handler and returned an empty dict — indistinguishable from a genuine no-change diff. The process still exited 0, so an MCP caller received a well-formed zero-change review with no indication the underlying command never completed (#913).

subprocess.TimeoutExpired is now caught separately at both the git diff and svn diff call sites and raises GitTimeoutError, a RuntimeError subclass carrying:

  • command — which VCS command timed out ("git diff" or "svn diff")
  • timeout — the configured CRG_GIT_TIMEOUT value
  • a message that says the diff was not read and the review analysed nothing

Non-timeout OSError and SubprocessError cases still return {} as before — the distinction is only for the timeout path where silence is most dangerous.

Testing

New tests/test_git_timeout.py (6 tests):

  • GitTimeoutError is a RuntimeError; its message names the command and timeout; attributes are accessible.
  • parse_git_diff_ranges raises GitTimeoutError when subprocess.run raises TimeoutExpired (mocked).
  • Non-timeout SubprocessError and OSError still return {} (no behavior change for those paths).
pytest tests/test_git_timeout.py -q
→ 6 passed

pytest tests/test_changes.py -q
→ 38 passed

ruff check code_review_graph/changes.py tests/test_git_timeout.py
→ All checks passed!

Fixes #913

A git or svn diff that exceeded CRG_GIT_TIMEOUT was caught by a broad OSError/SubprocessError handler and returned an empty dict, indistinguishable from a genuine no-change diff. The process still exited 0, so an MCP caller received a well-formed zero-change review with no indication the underlying command never completed (tirth8205#913).

subprocess.TimeoutExpired is now caught separately and raises GitTimeoutError, a RuntimeError carrying the command name and timeout value. Callers can distinguish an actual timeout from a legitimate empty diff and surface it.
@github-actions

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 12 changed function(s)/class(es), 0 affected flow(s), 3 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/changes.py::GitTimeoutError code_review_graph/changes.py:37 yes
0.35 low code_review_graph/changes.py::GitTimeoutError.__init__ code_review_graph/changes.py:44 yes
0.30 low code_review_graph/changes.py::parse_git_diff_ranges code_review_graph/changes.py:61 yes
0.30 low tests/test_git_timeout.py::TestGitTimeoutError.test_is_runtime_error tests/test_git_timeout.py:11 (test)
0.20 low tests/test_git_timeout.py::TestGitTimeoutError tests/test_git_timeout.py:10 no
0.20 low tests/test_git_timeout.py::TestGitTimeoutError.test_message_names_command_and_timeout tests/test_git_timeout.py:14 (test)
0.20 low tests/test_git_timeout.py::TestGitTimeoutError.test_carries_command_and_timeout_attributes tests/test_git_timeout.py:20 (test)
0.10 low code_review_graph/changes.py::parse_svn_diff_ranges code_review_graph/changes.py:101 no
0.05 low tests/test_git_timeout.py::TestParseGitDiffRangesTimeout tests/test_git_timeout.py:26 no
0.05 low tests/test_git_timeout.py::TestParseGitDiffRangesTimeout.test_timeout_raises_git_timeout_error tests/test_git_timeout.py:27 (test)

Test gaps

  • code_review_graph/changes.py::parse_svn_diff_ranges (code_review_graph/changes.py:101)
  • tests/test_git_timeout.py::TestGitTimeoutError (tests/test_git_timeout.py:10)
  • tests/test_git_timeout.py::TestParseGitDiffRangesTimeout (tests/test_git_timeout.py:26)

Token savings: this graph-backed report used ~3,138 fewer tokens (~55%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205

Copy link
Copy Markdown
Owner

The premise is right — a timeout that reads as "no changes" is the worst possible failure for a review tool, which is why I filed #913. But this doesn't fix the path the issue describes, and it breaks one that works today. I'd like it reworked rather than merged.

It doesn't fix the reported symptom. #913's complaint is a well-formed response saying zero changed functions, zero flows, risk 0.00. On this branch, with a forced timeout:

detect_changes_func(base="HEAD~1", ...)
{"status": "ok", "summary": "No changed files detected.", "risk_score": 0.0, ...}

code-review-graph detect-changes --repo .
No changes detected.        exit code: 0

The reason is upstream of your change: review.py:573 calls get_changed_files() before parse_diff_ranges(), and incremental.py:875 is

except (FileNotFoundError, subprocess.TimeoutExpired):
    return []

— a silent [], no log line, same again at :959 for get_staged_and_unstaged. With an empty file list detect_changes_func short-circuits at review.py:577 to the "No changed files detected" response and your GitTimeoutError is never reached. The CLI short-circuits identically at cli.py:2090. The fix has to land in incremental.py.

It converts a designed degradation into a crash. changes.py:466 deliberately falls back to whole-file node analysis when the diff ranges are empty but the changed-file list is known, so that case produces a correct, lower-granularity review with a real risk score — not a false "nothing changed". Turning it into an exception loses that. Concretely, on get_minimal_context, the tool CLAUDE.md tells every agent to call first:

parent:  {"status":"ok","summary":"5942 nodes, 49847 edges across 269 files. Risk: high (0.75). 39 test gaps.", ...}
this PR: GitTimeoutError: git diff timed out after 0s; the diff was not read so the review analysed nothing

context.py:126 catches (ImportError, OSError, ValueError, sqlite3.Error, subprocess.SubprocessError); GitTimeoutError is a RuntimeError, so it escapes, and neither get_minimal_context nor main.py:214 has a handler. The CLI is the same story — cli.py:2097 is unguarded, so the realistic case where git diff --name-status succeeds and the expensive --unified=0 times out prints a raw traceback at exit 1, where the parent printed a full report at exit 0. That also hard-fails the shipped Action step at action.yml:93. Non-zero exit is what #913 asked for; a traceback is not — cli.py:1725 shows the convention (SystemExit(1) with a clean stderr message).

What I'd take instead:

  1. Raise from get_changed_files / get_staged_and_unstaged on timeout, since that is where "empty" becomes indistinguishable from "no changes".
  2. Catch it at every boundary that has a defined failure shape: detect_changes_func already has a blanket handler and returns a structured error (verified, no regression there), but get_minimal_context needs GitTimeoutError in its tuple, and the CLI needs SystemExit(1) with a one-line message.
  3. Keep the empty-ranges-with-known-files path as graceful degradation — do not raise there.
  4. Say what to do in the message. Name CRG_GIT_TIMEOUT as the knob, the way main.py:729 already does for CRG_TOOL_TIMEOUT.

Two smaller things for the rework: parse_git_diff_ranges and parse_svn_diff_ranges still document "Returns an empty dict on error" while now raising, and both are in tools/__init__.__all__, so that is a silent breaking change for library callers — GitTimeoutError itself is exported nowhere, so a caller cannot even catch it without reaching into code_review_graph.changes. And compute_file_churn (changes.py:287) still swallows the same timeout into {}, which is the third site #913 names.

One warning about the interaction with #921: that PR accepts CRG_GIT_TIMEOUT=0 unclamped, which forces TimeoutExpired on every git call. With both merged as they stand, one typo'd env var makes get_minimal_context_tool raise on every single invocation.

Gates are green on the branch (2991 passed, ruff and mypy clean, 13/13) — all six new tests patch subprocess.run and assert on parse_git_diff_ranges in isolation, which is exactly why CI cannot see either regression above. A test through detect_changes_func and one through the CLI would have caught both.

@tirth8205

Copy link
Copy Markdown
Owner

Changes required: the false-clean response remains at changed-file discovery, before the modified range parser is reached. On a built Git graph, make code_review_graph.incremental.subprocess.run raise subprocess.TimeoutExpired and call code_review_graph.tools.review.detect_changes_func(base='HEAD~1', repo_root=...); this PR still returns status='ok', no changes and zero risk. Report discovery failures at the public boundary while preserving conservative whole-file context when filenames are known but line ranges are unavailable.

@tirth8205
tirth8205 changed the base branch from main to staging September 15, 2026 13:11
@tirth8205

tirth8205 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Checks pass and the code looks right. Missing: tests for the edge cases it introduces.

  • Test for parse_svn_diff_ranges raising GitTimeoutError('svn diff', _GIT_TIMEOUT) on subprocess.TimeoutExpired (the second code path changed by the PR is untested).
  • End-to-end test for the MCP tool: detect_changes_func with get_changed_files patched to a non-empty list and parse_diff_ranges/subprocess.run raising TimeoutExpired must return status='error' whose error mentions 'timed out', not status='ok' with risk_score 0 (extend the pattern in tests/test_change...
  • Decide and test CLI behavior for detect-changes on timeout: currently an uncaught traceback with exit 1. Either catch GitTimeoutError in cli.py (print 'Error: ...' to stderr, sys.exit(1), like the watch branch at cli.py:2015) and test the exit code and message, or explicitly document the traceba...
  • Decide and test update --brief on timeout: the update itself succeeds and then the process dies with a traceback (regression from exit 0). Likely should catch GitTimeoutError around the brief summary and warn rather than fail the update.
  • Decide and test get_minimal_context on timeout: it now raises GitTimeoutError out of the tool instead of degrading to risk='unknown'; either add RuntimeError/GitTimeoutError to the except tuple at tools/context.py:127 (with a test asserting risk='unknown' and the response still returns), or return a...

Every new branch in the code should have a test, including the failure branch. Disagree with any of these? Say so here first.

PRs now target staging, not main. Yours was retargeted already, so nothing to do there.

@tirth8205 tirth8205 added the needs-tests Code is fine, edge cases are untested label Sep 15, 2026
tirth8205 added a commit to gowrishacv/code-review-graph that referenced this pull request Sep 18, 2026
)

Three of the four causes in the tirth8205#262 diagnosis, measured rather than
assumed. Cause 4 (a timed-out `git diff` returning an empty list, tirth8205#913)
is deliberately untouched: PR tirth8205#922 is open for it.

A. One offload helper, not seven copies.

`_offload` in main.py runs a tool body on a worker thread and, when
`CRG_TOOL_TIMEOUT` is set above 0, bounds it. 28 of the 30 registered
tools now route through it; only `get_docs_section_tool` and
`list_repos_tool` stay inline, and each reads one small file.
`detect_changes_tool` loses its private copy of the timeout logic and
keeps its exact observable behaviour, including the advice naming
`CRG_MAX_CHANGED_FUNCS` / `CRG_MAX_TRANSITIVE_FRONTIER`.

Measured against origin/staging, driving a real `code-review-graph serve`
over stdio: the loop is NOT blocked on staging today, because fastmcp 3
runs sync tool bodies in a worker thread itself (`run_in_thread=True`).
A negative-control server with `run_in_thread=False` shows the probe can
detect blocking, so that result is the instrument working, not failing.
What staging does not do is bound the call: with `CRG_TOOL_TIMEOUT=5`,
`get_impact_radius_tool` ran 25.2s and answered only when it was ready;
it now answers at 6.0s with `status: error` naming the tool and the
budget. An unbounded request is what a client reports as -32001.

B. Change discovery gets its own budget.

`CRG_DISCOVERY_TIMEOUT` (default 5s, never above `CRG_GIT_TIMEOUT`,
read at call time rather than frozen at import) applies only to the
read-only discovery chain. `CRG_GIT_TIMEOUT` keeps its 30-second
default for build, update and watch, which legitimately run long.
The two byte-identical `_GIT_TIMEOUT` definitions, at changes.py:35 and
incremental.py:731, are now one constant in constants.py that both
modules alias.

`discover_review_changes` is that chain: resolve the base, diff, fall
back to the working tree. review.py (3 sites), query.py and cli.py (2
sites) call it instead of repeating the three steps, so the budget and
the scope below cannot be applied to five of six call sites.

C. The working-tree fallback stops walking every untracked file.

`get_staged_and_unstaged` takes `untracked=`, defaulting to `"all"`, so
no existing caller changes. Discovery passes `"normal"`: a new module in
a tracked package is still named, a wholly-untracked directory is not
descended into, and the unusable `dir/` placeholder is dropped rather
than handed on as a file path.

Verified first that no caller needs `"all"`: all six are the discovery
fallback, and `incremental_update` never calls it at all -- on
origin/staging as on this branch, `update` does not pick up a
never-committed module, so scoping this walk cannot regress it. A test
pins that.

On a repository with an empty `git diff HEAD~1` and 250,000 untracked
files, `get_impact_radius_tool` goes from 16.8s -- reporting all 250,000
as "changed" -- to 0.02s and "No changed files detected". The discovery
chain alone: 0.397s to 0.016s, the walk 0.377s to 0.006s. That is macOS
with a warm page cache; the report is from Windows, where each of those
stats costs far more.

D. Tests.

test_main.py: the heavy-tool guard covers all 28 offloaded tools and
checks delegation plus one threading implementation; a new guard fails
when a tool is added as a plain `def`; the loop-responsiveness test
drives a real event loop rather than asserting `iscoroutinefunction`,
and fails when `_offload` is mutated to run inline.
test_incremental.py: the budget's precedence, call-time reads, invalid
values, the chain's wiring, and that build-side callers keep the git
budget. test_integration_git.py: the three untracked modes against real
git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tirth8205 added a commit to gowrishacv/code-review-graph that referenced this pull request Sep 18, 2026
Merges origin/staging (PR tirth8205#1029, which this branch predated and no longer
applied over), then fixes what three reviewers found. Reproduced each one
first; each fix is verified with the reporter's own reproducer.

C is withdrawn, not repaired.

`get_staged_and_unstaged(untracked=)` and the `--untracked-files=normal`
discovery walk are gone; the walk is back to `all`. Under `normal`, git
collapses a wholly-untracked directory to one `dir/` record, which is not a
path any caller can open, and the code dropped it -- so the first commit of
any new package vanished from all four review tools and both CLI review
commands, with `status: ok` and no warning at any log level. Reproduced:
a repo whose only change is `feature/newmod/{a.py,b.py}` reported
"No changes detected"; it now reports both files. The measured prize was
0.377s -> 0.006s of git time on 250,000 untracked files. Being slow is a
bug; confidently reviewing nothing is a worse one, and the task's own
instruction was to say so rather than trade one for the other.

The 5s budget stays, because it no longer lies.

staging landed `ChangeDiscoveryError` and `require_vcs` while this branch
was out. `discover_review_changes` now passes `require_vcs=True` to all
three steps, so exhausting the short budget raises instead of returning
`[]`. That is what licenses shortening it at all: the reviewers were right
that cutting 30s -> 5s on a path whose timeout returns an empty list makes
tirth8205#913's false all-clear several times easier to hit, and extends it to
`resolve_review_base`, where a timed-out merge base silently degrades a
three-dot diff to two-dot. tirth8205#913 itself is still untouched --
`get_changed_files`'s default is unchanged and `require_vcs` is staging's
own flag, so PR tirth8205#922 keeps its ground.

Verified with a `git` shim that sleeps 7s: before, `detect_changes_tool`
answered `status: ok` with `changed_files: ["scratch_note.txt"]` -- an
unrelated file. Now all four tools answer `status: error` naming the budget
and `CRG_DISCOVERY_TIMEOUT`, and `detect-changes` exits 1 rather than 0.

`discovery_timeout()` no longer clamps to `min(5, GIT_TIMEOUT)`: an
explicitly set `CRG_GIT_TIMEOUT` is honoured verbatim. It predates this
variable and is what tirth8205#262's reporters were already told to raise; a
documented knob that silently stops working is worse than one that never
existed. `CRG_GIT_TIMEOUT=120` with the same 7s git returns the correct
file list again.

`CRG_TOOL_TIMEOUT` keeps its scope and its guard.

`_offload` gained `bounded=`, and the five writing tools pass False:
build, postprocess, embed, wiki and apply_refactor. `asyncio.wait_for`
cancels the await, never the worker, so bounding these reported failure to
the client while the build kept writing graph.db (verified: the "failed"
run left 52,000 nodes behind) and while apply_refactor's rename landed on
disk, whose retry then failed again with "not found or expired". They were
also unbounded before the shared helper existed, and `CRG_TOOL_TIMEOUT` is
what tirth8205#262 tells a user to set -- the documented remedy must not break
builds.

Parsing goes through staging's `env_int`, so `CRG_TOOL_TIMEOUT=2.5` (or
"", or "abc") no longer raises `ValueError` out of every tool. This also
satisfies staging's `test_no_new_unguarded_numeric_env_parse` gate, which
the pre-merge branch failed in both directions.

The offload runs on anyio's limiter, not asyncio's executor.

`anyio.to_thread.run_sync(abandon_on_cancel=True)` replaces
`asyncio.to_thread`. Making these tools `async def` had moved them off the
40-slot limiter FastMCP dispatches sync bodies through and onto asyncio's
default executor, capped at `min(32, cpu+4)` -- 18 here, 8 on a 4-core
Windows box. At the reviewer's N=19 burst a cheap tool waited 10.15s and
got a false "timed out"; it now answers in 0.01s, and still does at N=30.
anyio is declared in pyproject rather than borrowed from fastmcp.

`get_minimal_context_tool` is on the budget too.

The entry point CLAUDE.md tells agents to call first was the one tool the
previous commit skipped: `resolve_review_base` (30s) + two hardcoded 10s
probes + `get_changed_files` (30s). One `discover_review_changes` call
replaces all five subprocesses. Measured with a 6s git: 31.2s -> 6.0s. A
discovery failure appends a "Degraded:" note, following the churn note's
existing contract, rather than reporting risk it could not compute.

Also: the timeout hint no longer names `changed_files` / `max_depth` /
`max_results` as though every bounded tool accepted them; `_vcs_unavailable`
names the budget that actually expired and the knob that governs it; and the
CHANGELOG and all five README env-var tables say which tools
`CRG_TOOL_TIMEOUT` bounds and that a discovery timeout is an error rather
than an all-clear.

Tests: the two that pinned the collapsed-directory drop are replaced by ones
that pin the opposite, including an end-to-end check that a new package
reaches all four review tools against real git (verified to fail when the
drop is reintroduced). New coverage for per-step require_vcs, the budget
precedence, the unbounded writing tools, malformed CRG_TOOL_TIMEOUT, and the
anyio limiter.

Gates: 4163 passed, 848 skipped, 2 xfailed, 2 xpassed (both xpassed are
pre-existing R-notebook tests). ruff and mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tirth8205

Copy link
Copy Markdown
Owner

This no longer merges into staging. The one conflict is code_review_graph/changes.py, and the branch is 226 commits behind.

Worth knowing before you resolve:

  • code_review_graph/changes.py: staging moved _GIT_TIMEOUT into constants.py as GIT_TIMEOUT and added a separate discovery budget.
git fetch origin
git merge origin/staging
# resolve, then
uv run pytest tests/ -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
git push

I have not reviewed the change itself yet. That comes once it merges and the checks run against the merged state, since staging has moved a long way and the result is what matters.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-tests Code is fine, edge cases are untested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A git timeout in changes.py is returned as 'no changes': same empty result, exit code 0

2 participants