Skip to content

Commit 6604297

Browse files
authored
Merge pull request #27 from mtrdesign/feature/concurrent-codegraph-scan
feat(scan): refresh CodeGraph concurrently with the crawlers
2 parents 0062d4a + cf9512d commit 6604297

6 files changed

Lines changed: 309 additions & 51 deletions

File tree

src/whygraph/cli/commands/scan.py

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from rich.table import Table
1313
from rich.text import Text
1414

15-
from whygraph.scan import Crawler, GitCrawler, GitHubCrawler
15+
from whygraph.scan import CodeGraphCrawler, Crawler, GitCrawler, GitHubCrawler
1616

1717
from ..console import console
1818

@@ -44,12 +44,12 @@
4444
"refresh_codegraph",
4545
default=True,
4646
help=(
47-
"Refresh the CodeGraph index before crawling — `codegraph sync` when "
48-
"an index exists, `codegraph init -i` on first run. Uses the local "
49-
"`codegraph` binary if present, else runs it inside the WhyGraph "
50-
"Docker image. The crawl itself doesn't need CodeGraph (only the MCP "
51-
"rationale/evidence tools do), so a failure here warns rather than "
52-
"aborting. Default: on."
47+
"Refresh the CodeGraph index concurrently with the crawl — "
48+
"`codegraph sync` when an index exists, `codegraph init -i` on first "
49+
"run. Uses the local `codegraph` binary if present, else runs it "
50+
"inside the WhyGraph Docker image. The crawl itself doesn't need "
51+
"CodeGraph (only the MCP rationale/evidence tools do), so a failure "
52+
"here warns rather than aborting. Default: on."
5353
),
5454
)
5555
@click.option(
@@ -122,13 +122,19 @@ def scan_cmd(
122122
remote_enabled=remote,
123123
)
124124

125-
# CodeGraph refresh runs before the crawl and outside the Progress
126-
# live-display (it streams its own output on first index). It's
127-
# best-effort: the crawl doesn't depend on it.
128-
if refresh_codegraph:
129-
_refresh_codegraph(repository.root, image=codegraph_image)
130-
131125
with Progress() as progress:
126+
# CodeGraph refresh — runs concurrently as its own crawler. It
127+
# writes .codegraph/ and has no data dependency on the WhyGraph DB,
128+
# so it overlaps the entire crawl (started with phase 1, joined
129+
# last). Best-effort: failures land on .warning, not .error.
130+
codegraph_crawler = (
131+
CodeGraphCrawler(
132+
progress, project_root=repository.root, image=codegraph_image
133+
)
134+
if refresh_codegraph
135+
else None
136+
)
137+
132138
# Phase 1 — source crawlers, run concurrently.
133139
phase1: list[Crawler] = [GitCrawler(progress, repository=repository)]
134140
if github_client is not None:
@@ -148,6 +154,8 @@ def scan_cmd(
148154
)
149155
)
150156

157+
if codegraph_crawler is not None:
158+
codegraph_crawler.start()
151159
for c in phase1:
152160
c.start()
153161
for c in phase1:
@@ -156,8 +164,15 @@ def scan_cmd(
156164
c.start()
157165
for c in phase2:
158166
c.join()
167+
if codegraph_crawler is not None:
168+
codegraph_crawler.join()
169+
170+
if codegraph_crawler is not None and codegraph_crawler.warning is not None:
171+
console.print(Text(codegraph_crawler.warning, style="yellow"))
159172

160173
crawlers = phase1 + phase2
174+
if codegraph_crawler is not None:
175+
crawlers.append(codegraph_crawler)
161176
failed = [c for c in crawlers if c.error is not None]
162177
for c in failed:
163178
click.echo(f"crawler {c.name!r} failed: {c.error}", err=True)
@@ -196,34 +211,6 @@ def _apply_github_token(config: "Config") -> None:
196211
os.environ["GH_TOKEN"] = token
197212

198213

199-
def _refresh_codegraph(project_root: Path, *, image: str | None) -> None:
200-
"""Bring the CodeGraph index up to date before the crawl.
201-
202-
Best-effort: the git / GitHub / analyze crawl does not depend on the
203-
CodeGraph index (only the MCP ``whygraph_rationale_brief`` and
204-
``whygraph_evidence_for`` tools read it), so a CodeGraph failure is
205-
reported as a warning and the scan continues.
206-
207-
Parameters
208-
----------
209-
project_root : Path
210-
Repository root whose ``.codegraph/`` index is refreshed.
211-
image : str or None
212-
Docker image override for the fallback path; ``None`` uses the
213-
pinned default. Ignored when a local ``codegraph`` binary is found.
214-
"""
215-
from whygraph.services.codegraph import (
216-
CodeGraphBootstrapError,
217-
refresh_codegraph_index,
218-
)
219-
220-
console.print("Refreshing CodeGraph index…")
221-
try:
222-
refresh_codegraph_index(project_root, image=image)
223-
except CodeGraphBootstrapError as exc:
224-
console.print(Text(f"CodeGraph refresh skipped — {exc}", style="yellow"))
225-
226-
227214
def _select_github_client(
228215
provider: str, repository: "Repository"
229216
) -> "GitHubClient | None":

src/whygraph/scan/__init__.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@
22
33
Exposes :class:`Crawler` (the threaded base class) and the concrete
44
crawlers — :class:`GitCrawler` for local git history,
5-
:class:`GitHubCrawler` for GitHub pull requests and issues, and
5+
:class:`GitHubCrawler` for GitHub pull requests and issues,
6+
:class:`CodeGraphCrawler` which refreshes the CodeGraph index, and
67
:class:`AnalyzeCrawler` which describes each commit's diff with an LLM
7-
(run after :class:`GitCrawler`). The CLI runs the source crawlers
8-
concurrently, then the analyzer, against the shared SQLite database.
8+
(run after :class:`GitCrawler`). The CLI runs the source crawlers (and
9+
CodeGraph) concurrently, then the analyzer, against the shared SQLite
10+
database.
911
"""
1012

1113
from whygraph.scan.analyze_crawler import AnalyzeCrawler
14+
from whygraph.scan.codegraph_crawler import CodeGraphCrawler
1215
from whygraph.scan.crawler import Crawler
1316
from whygraph.scan.git_crawler import GitCrawler
1417
from whygraph.scan.github_crawler import GitHubCrawler
1518

16-
__all__ = ["AnalyzeCrawler", "Crawler", "GitCrawler", "GitHubCrawler"]
19+
__all__ = [
20+
"AnalyzeCrawler",
21+
"CodeGraphCrawler",
22+
"Crawler",
23+
"GitCrawler",
24+
"GitHubCrawler",
25+
]
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""CodeGraphCrawler — refresh the CodeGraph index alongside the crawl.
2+
3+
Runs ``codegraph init -i`` (first index) or ``codegraph sync -q``
4+
(incremental) as one more :class:`Crawler` thread, so the index builds
5+
concurrently with the git / GitHub / analyze crawlers instead of blocking
6+
before them. CodeGraph writes ``.codegraph/`` and has no data dependency
7+
on the WhyGraph DB, so it can safely overlap the entire scan.
8+
9+
The refresh is **best-effort**: only the MCP rationale / evidence tools
10+
read the index, not the crawl. A :class:`CodeGraphBootstrapError` (tool
11+
missing, non-zero exit) is therefore swallowed into :attr:`warning`
12+
rather than failing the scan; any other exception propagates into the
13+
base class's :attr:`Crawler.error` and surfaces as a real failure.
14+
15+
Subprocess output is captured (``capture=True``) rather than streamed so
16+
it cannot corrupt the shared :class:`rich.progress.Progress` display; the
17+
captured tail is folded into :attr:`warning` on failure.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pathlib import Path
23+
24+
from rich.progress import Progress
25+
26+
from whygraph.services.codegraph import (
27+
CodeGraphBootstrapError,
28+
refresh_codegraph_index,
29+
)
30+
from whygraph.services.codegraph.paths import CODEGRAPH_DB_RELPATH
31+
32+
from .crawler import Crawler
33+
34+
35+
class CodeGraphCrawler(Crawler):
36+
"""Refresh ``<project_root>/.codegraph/codegraph.db`` concurrently.
37+
38+
Drives an indeterminate (pulsing) progress task — CodeGraph reports no
39+
granular progress — and completes it cleanly on success. CodeGraph
40+
bootstrap failures are recorded on :attr:`warning` rather than
41+
:attr:`Crawler.error`, preserving the best-effort contract.
42+
43+
Parameters
44+
----------
45+
progress : rich.progress.Progress
46+
Shared Progress instance owned by the orchestrator.
47+
project_root : Path
48+
Repository root whose ``.codegraph/`` index is refreshed.
49+
image : str or None
50+
Docker image override for the CodeGraph fallback path; ``None``
51+
uses the pinned default. Ignored when a local ``codegraph`` binary
52+
is found.
53+
54+
Attributes
55+
----------
56+
warning : str or None
57+
Message describing a swallowed :class:`CodeGraphBootstrapError`,
58+
for the orchestrator to surface after the crawl. ``None`` on
59+
success.
60+
"""
61+
62+
def __init__(
63+
self, progress: Progress, *, project_root: Path, image: str | None
64+
) -> None:
65+
super().__init__("codegraph", progress, total=None)
66+
self._project_root = project_root
67+
self._image = image
68+
self.warning: str | None = None
69+
70+
def work(self) -> None:
71+
db_path = self._project_root / CODEGRAPH_DB_RELPATH
72+
verb = "sync" if db_path.exists() else "init -i"
73+
self.advance(0, description=f"codegraph {verb}")
74+
75+
try:
76+
refresh_codegraph_index(self._project_root, image=self._image, capture=True)
77+
except CodeGraphBootstrapError as exc:
78+
self.warning = f"CodeGraph refresh skipped — {exc}"
79+
return
80+
81+
# CodeGraph reports no granular progress, so land the pulsing bar
82+
# on a clean "complete" once the refresh returns.
83+
self.set_total(1)
84+
self.advance(1)

src/whygraph/services/codegraph/bootstrap.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def ensure_codegraph_db(
4646
project_root: Path,
4747
*,
4848
image: str | None = None,
49+
capture: bool = False,
4950
) -> Path:
5051
"""Idempotently materialize ``<project_root>/.codegraph/codegraph.db``.
5152
@@ -61,6 +62,11 @@ def ensure_codegraph_db(
6162
image : str, optional
6263
Docker image tag for the fallback path. Defaults to
6364
:data:`DEFAULT_CODEGRAPH_IMAGE`.
65+
capture : bool, optional
66+
When ``True``, capture the subprocess output instead of letting it
67+
stream to the terminal (so it can't corrupt a concurrent progress
68+
display). The captured tail is folded into the error message on
69+
failure. Default ``False`` (stream live).
6470
6571
Returns
6672
-------
@@ -78,7 +84,7 @@ def ensure_codegraph_db(
7884
if db_path.exists():
7985
return db_path
8086

81-
_run_codegraph(project_root, ["init", "-i"], image=image)
87+
_run_codegraph(project_root, ["init", "-i"], image=image, capture=capture)
8288

8389
if not db_path.exists():
8490
raise CodeGraphBootstrapError(
@@ -92,6 +98,7 @@ def refresh_codegraph_index(
9298
project_root: Path,
9399
*,
94100
image: str | None = None,
101+
capture: bool = False,
95102
) -> Path:
96103
"""Bring ``<project_root>/.codegraph/codegraph.db`` up to date.
97104
@@ -107,6 +114,11 @@ def refresh_codegraph_index(
107114
image : str, optional
108115
Docker image tag for the fallback path. Defaults to
109116
:data:`DEFAULT_CODEGRAPH_IMAGE`.
117+
capture : bool, optional
118+
When ``True``, capture the subprocess output instead of streaming
119+
it (see :func:`ensure_codegraph_db`). ``whygraph scan`` passes this
120+
so the refresh can run concurrently under a live progress display.
121+
Default ``False``.
110122
111123
Returns
112124
-------
@@ -122,9 +134,9 @@ def refresh_codegraph_index(
122134
project_root = project_root.resolve()
123135
db_path = project_root / CODEGRAPH_DB_RELPATH
124136
if not db_path.exists():
125-
return ensure_codegraph_db(project_root, image=image)
137+
return ensure_codegraph_db(project_root, image=image, capture=capture)
126138

127-
_run_codegraph(project_root, ["sync", "-q"], image=image)
139+
_run_codegraph(project_root, ["sync", "-q"], image=image, capture=capture)
128140
return db_path
129141

130142

@@ -133,6 +145,7 @@ def _run_codegraph(
133145
args: list[str],
134146
*,
135147
image: str | None,
148+
capture: bool = False,
136149
) -> None:
137150
"""Run a ``codegraph`` subcommand against ``project_root``.
138151
@@ -150,6 +163,10 @@ def _run_codegraph(
150163
image : str or None
151164
Docker image tag for the fallback path; ``None`` uses
152165
:data:`DEFAULT_CODEGRAPH_IMAGE`.
166+
capture : bool, optional
167+
When ``True``, capture stdout/stderr rather than streaming them to
168+
the terminal, and fold the captured tail into the error message on
169+
failure. Default ``False`` (stream live).
153170
154171
Raises
155172
------
@@ -187,8 +204,14 @@ def _run_codegraph(
187204
)
188205

189206
try:
190-
subprocess.run(cmd, check=True, cwd=cwd)
207+
subprocess.run(cmd, check=True, cwd=cwd, capture_output=capture, text=capture)
191208
except subprocess.CalledProcessError as exc:
209+
if capture:
210+
tail = (exc.stderr or exc.stdout or "").strip()
211+
detail = f"\n{tail}" if tail else ""
212+
raise CodeGraphBootstrapError(
213+
f"`codegraph {label}` failed (exit {exc.returncode}){detail}"
214+
) from exc
192215
raise CodeGraphBootstrapError(
193216
f"`codegraph {label}` failed (exit {exc.returncode}) — see output above"
194217
) from exc

0 commit comments

Comments
 (0)