serpentine-parser 0.3.2__tar.gz → 0.4.0__tar.gz

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.
Files changed (65) hide show
  1. serpentine_parser-0.3.2/README.md → serpentine_parser-0.4.0/PKG-INFO +85 -0
  2. serpentine_parser-0.3.2/PKG-INFO → serpentine_parser-0.4.0/README.md +47 -34
  3. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/pyproject.toml +7 -2
  4. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/Cargo.lock +3 -3
  5. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/Cargo.toml +1 -1
  6. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/__init__.py +1 -1
  7. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/cache.py +73 -0
  8. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/cli.py +55 -1
  9. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/selector.py +15 -3
  10. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/app.py +3 -1
  11. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/routes.py +64 -0
  12. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/state.py +49 -2
  13. serpentine_parser-0.4.0/src/serpentine/static/assets/index-DgVKSbxY.js +32 -0
  14. serpentine_parser-0.4.0/src/serpentine/static/assets/index-Dmzvedun.css +1 -0
  15. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/index.html +2 -2
  16. serpentine_parser-0.4.0/src/serpentine/vcs/__init__.py +4 -0
  17. serpentine_parser-0.4.0/src/serpentine/vcs/backend.py +130 -0
  18. serpentine_parser-0.4.0/src/serpentine/vcs/manager.py +91 -0
  19. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/watcher.py +1 -1
  20. serpentine_parser-0.3.2/src/serpentine/static/assets/index-CqW3OZBs.css +0 -1
  21. serpentine_parser-0.3.2/src/serpentine/static/assets/index-DGBMc5dK.js +0 -32
  22. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/LICENSE +0 -0
  23. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/CLAUDE.md +0 -0
  24. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/events.rs +0 -0
  25. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/builder.rs +0 -0
  26. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/loaders.rs +0 -0
  27. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/mod.rs +0 -0
  28. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/pdg.rs +0 -0
  29. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/graph/resolvers.rs +0 -0
  30. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/javascript/config.rs +0 -0
  31. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/javascript/mod.rs +0 -0
  32. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/lib.rs +0 -0
  33. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/message_bus.rs +0 -0
  34. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/python/config.rs +0 -0
  35. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/python/mod.rs +0 -0
  36. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/rust_lang/config.rs +0 -0
  37. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/rust_lang/mod.rs +0 -0
  38. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/README.md +0 -0
  39. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/code_snippet.rs +0 -0
  40. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/decorators.rs +0 -0
  41. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/definitions.rs +0 -0
  42. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/event_counter.rs +0 -0
  43. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/imports.rs +0 -0
  44. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/mod.rs +0 -0
  45. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/pdg.rs +0 -0
  46. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/raw_bindings.rs +0 -0
  47. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/scope_tree.rs +0 -0
  48. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/types.rs +0 -0
  49. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/subscribers/uses.rs +0 -0
  50. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/terraform/config.rs +0 -0
  51. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/rust/src/terraform/mod.rs +0 -0
  52. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/_analyzer.pyi +0 -0
  53. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/config.py +0 -0
  54. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/__init__.py +0 -0
  55. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/server/websocket.py +0 -0
  56. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/__init__.py +0 -0
  57. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/agents-md.md +0 -0
  58. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/code-analysis.md +0 -0
  59. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/copilot-instructions.md +0 -0
  60. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/skills/cursor-rules.md +0 -0
  61. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/elk-worker.min-BqREE3LP.js +0 -0
  62. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/layoutWorker-DEFCbISa.js +0 -0
  63. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/mermaidExport-DphjeTrR.js +0 -0
  64. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/assets/svgToPng-C-twkIuO.js +0 -0
  65. {serpentine_parser-0.3.2 → serpentine_parser-0.4.0}/src/serpentine/static/logo.svg +0 -0
@@ -1,3 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: serpentine-parser
3
+ Version: 0.4.0
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Environment :: Console
6
+ Classifier: Environment :: Web Environment
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: Apache Software License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Rust
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: watchdog>=6.0.0
20
+ Requires-Dist: click>=8.1.0
21
+ Requires-Dist: starlette>=1.3.1
22
+ Requires-Dist: uvicorn[standard]>=0.30.0
23
+ Requires-Dist: websockets>=12.0
24
+ Requires-Dist: pygit2>=1.15 ; extra == 'git'
25
+ Provides-Extra: git
26
+ Provides-Extra: hg
27
+ Provides-Extra: svn
28
+ License-File: LICENSE
29
+ Summary: Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects
30
+ Keywords: dependency-graph,visualization,static-analysis,python,javascript,rust,terraform,tree-sitter
31
+ Author: Christopher Garretty
32
+ Requires-Python: >=3.10
33
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
34
+ Project-URL: Homepage, https://github.com/serpentine-parser/serpentine
35
+ Project-URL: Issues, https://github.com/serpentine-parser/serpentine/issues
36
+ Project-URL: Repository, https://github.com/serpentine-parser/serpentine
37
+
1
38
  <p align="center">
2
39
  <img src="frontend/public/logo.svg" alt="Serpentine" width="64" />
3
40
  </p>
@@ -18,6 +55,7 @@ Serpentine builds a **code reference graph** for Python, JavaScript/TypeScript,
18
55
  - **Full reference resolution**: Traces calls, type annotations, assignments, and inheritance — not just import edges
19
56
  - **Interactive graph**: Expandable nodes, search, pan/zoom, collapsible modules
20
57
  - **Real-time updates**: File watcher pushes changes via WebSocket
58
+ - **VCS integration**: Compare any two git refs and see added, modified, and deleted nodes highlighted on the graph
21
59
  - **Agent-ready CLI**: Structured JSON output with selectors for use in AI agent workflows
22
60
 
23
61
  ### What counts as a code reference?
@@ -307,6 +345,52 @@ Each edge has a `from` and `to` field with node IDs, and a `type` field:
307
345
 
308
346
  ---
309
347
 
348
+ ## VCS Integration
349
+
350
+ When Serpentine is run inside a git repository, it automatically detects available refs (branches, tags, and recent commits) and enables the **compare** toolbar in the UI.
351
+
352
+ ### Comparing refs
353
+
354
+ Click the compare toolbar to select a baseline ref and a target ref. Serpentine analyzes the graph at each ref and overlays change status on every node:
355
+
356
+ | Status | Meaning |
357
+ |---|---|
358
+ | `modified` | Node exists in both refs but its dependencies changed |
359
+ | `added` | Node exists in the target ref but not the baseline |
360
+ | `deleted` | Node existed in the baseline ref but was removed |
361
+
362
+ Unchanged nodes remain visible for context.
363
+
364
+ ### Filtering by state
365
+
366
+ Use the `state:` selector method in the search bar to filter the graph to only changed nodes:
367
+
368
+ ```
369
+ state:modified
370
+ state:added
371
+ state:deleted
372
+ ```
373
+
374
+ Selectors compose with all graph operators:
375
+
376
+ ```
377
+ # All nodes modified on the current branch, plus their upstream dependencies
378
+ +state:modified
379
+
380
+ # Full connected component of every added node
381
+ @state:added
382
+ ```
383
+
384
+ ### Checkpoints
385
+
386
+ The **checkpoint** button marks the current live graph state as the comparison baseline. This is useful when you want to track changes made during a session rather than comparing against a git ref.
387
+
388
+ ### Configuration
389
+
390
+ Files excluded via `.serpentine.yml` (`exclude_dirs`, `exclude_patterns`) are excluded from VCS snapshot analysis as well — ensuring consistent results between live analysis and historical comparison.
391
+
392
+ ---
393
+
310
394
  ## Configuration
311
395
 
312
396
  Serpentine looks for `.serpentine.yml` or `serpentine.yml` in the project root. All settings are optional.
@@ -448,3 +532,4 @@ These three events are the minimum required for the graph builder to resolve all
448
532
  ## License
449
533
 
450
534
  [Apache 2.0](LICENSE)
535
+
@@ -1,36 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: serpentine-parser
3
- Version: 0.3.2
4
- Classifier: Development Status :: 4 - Beta
5
- Classifier: Environment :: Console
6
- Classifier: Environment :: Web Environment
7
- Classifier: Intended Audience :: Developers
8
- Classifier: License :: OSI Approved :: Apache Software License
9
- Classifier: Operating System :: OS Independent
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.10
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Classifier: Programming Language :: Python :: 3.14
16
- Classifier: Programming Language :: Rust
17
- Classifier: Topic :: Software Development :: Quality Assurance
18
- Classifier: Topic :: Utilities
19
- Requires-Dist: watchdog>=6.0.0
20
- Requires-Dist: click>=8.1.0
21
- Requires-Dist: starlette>=0.38.0
22
- Requires-Dist: uvicorn[standard]>=0.30.0
23
- Requires-Dist: websockets>=12.0
24
- License-File: LICENSE
25
- Summary: Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects
26
- Keywords: dependency-graph,visualization,static-analysis,python,javascript,rust,terraform,tree-sitter
27
- Author: Christopher Garretty
28
- Requires-Python: >=3.10
29
- Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
30
- Project-URL: Homepage, https://github.com/serpentine-parser/serpentine
31
- Project-URL: Issues, https://github.com/serpentine-parser/serpentine/issues
32
- Project-URL: Repository, https://github.com/serpentine-parser/serpentine
33
-
34
1
  <p align="center">
35
2
  <img src="frontend/public/logo.svg" alt="Serpentine" width="64" />
36
3
  </p>
@@ -51,6 +18,7 @@ Serpentine builds a **code reference graph** for Python, JavaScript/TypeScript,
51
18
  - **Full reference resolution**: Traces calls, type annotations, assignments, and inheritance — not just import edges
52
19
  - **Interactive graph**: Expandable nodes, search, pan/zoom, collapsible modules
53
20
  - **Real-time updates**: File watcher pushes changes via WebSocket
21
+ - **VCS integration**: Compare any two git refs and see added, modified, and deleted nodes highlighted on the graph
54
22
  - **Agent-ready CLI**: Structured JSON output with selectors for use in AI agent workflows
55
23
 
56
24
  ### What counts as a code reference?
@@ -340,6 +308,52 @@ Each edge has a `from` and `to` field with node IDs, and a `type` field:
340
308
 
341
309
  ---
342
310
 
311
+ ## VCS Integration
312
+
313
+ When Serpentine is run inside a git repository, it automatically detects available refs (branches, tags, and recent commits) and enables the **compare** toolbar in the UI.
314
+
315
+ ### Comparing refs
316
+
317
+ Click the compare toolbar to select a baseline ref and a target ref. Serpentine analyzes the graph at each ref and overlays change status on every node:
318
+
319
+ | Status | Meaning |
320
+ |---|---|
321
+ | `modified` | Node exists in both refs but its dependencies changed |
322
+ | `added` | Node exists in the target ref but not the baseline |
323
+ | `deleted` | Node existed in the baseline ref but was removed |
324
+
325
+ Unchanged nodes remain visible for context.
326
+
327
+ ### Filtering by state
328
+
329
+ Use the `state:` selector method in the search bar to filter the graph to only changed nodes:
330
+
331
+ ```
332
+ state:modified
333
+ state:added
334
+ state:deleted
335
+ ```
336
+
337
+ Selectors compose with all graph operators:
338
+
339
+ ```
340
+ # All nodes modified on the current branch, plus their upstream dependencies
341
+ +state:modified
342
+
343
+ # Full connected component of every added node
344
+ @state:added
345
+ ```
346
+
347
+ ### Checkpoints
348
+
349
+ The **checkpoint** button marks the current live graph state as the comparison baseline. This is useful when you want to track changes made during a session rather than comparing against a git ref.
350
+
351
+ ### Configuration
352
+
353
+ Files excluded via `.serpentine.yml` (`exclude_dirs`, `exclude_patterns`) are excluded from VCS snapshot analysis as well — ensuring consistent results between live analysis and historical comparison.
354
+
355
+ ---
356
+
343
357
  ## Configuration
344
358
 
345
359
  Serpentine looks for `.serpentine.yml` or `serpentine.yml` in the project root. All settings are optional.
@@ -481,4 +495,3 @@ These three events are the minimum required for the graph builder to resolve all
481
495
  ## License
482
496
 
483
497
  [Apache 2.0](LICENSE)
484
-
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "serpentine-parser"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Fast dependency graph analysis and visualization for Python, JavaScript/TypeScript, Rust, and Terraform projects"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -30,11 +30,16 @@ classifiers = [
30
30
  dependencies = [
31
31
  "watchdog>=6.0.0",
32
32
  "click>=8.1.0",
33
- "starlette>=0.38.0",
33
+ "starlette>=1.3.1",
34
34
  "uvicorn[standard]>=0.30.0",
35
35
  "websockets>=12.0",
36
36
  ]
37
37
 
38
+ [project.optional-dependencies]
39
+ git = ["pygit2>=1.15"]
40
+ hg = []
41
+ svn = []
42
+
38
43
  [project.urls]
39
44
  Homepage = "https://github.com/serpentine-parser/serpentine"
40
45
  Repository = "https://github.com/serpentine-parser/serpentine"
@@ -184,9 +184,9 @@ dependencies = [
184
184
 
185
185
  [[package]]
186
186
  name = "quote"
187
- version = "1.0.45"
187
+ version = "1.0.46"
188
188
  source = "registry+https://github.com/rust-lang/crates.io-index"
189
- checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
189
+ checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
190
190
  dependencies = [
191
191
  "proc-macro2",
192
192
  ]
@@ -286,7 +286,7 @@ dependencies = [
286
286
 
287
287
  [[package]]
288
288
  name = "serpentine"
289
- version = "0.3.2"
289
+ version = "0.4.0"
290
290
  dependencies = [
291
291
  "indexmap",
292
292
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "serpentine"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
 
@@ -22,7 +22,7 @@ Or programmatically:
22
22
  app = create_app(state)
23
23
  """
24
24
 
25
- __version__ = "0.3.2"
25
+ __version__ = "0.4.0"
26
26
 
27
27
  from serpentine.server import create_app
28
28
  from serpentine.state import GraphStateManager
@@ -180,3 +180,76 @@ class PerFileCacheManager:
180
180
  entry.write_text(results_json, encoding="utf-8")
181
181
  except Exception as e:
182
182
  logger.debug(f"Per-file cache save failed for {file_path}: {e}")
183
+
184
+
185
+ REFS_CACHE_SUBDIR = "refs"
186
+ # Bump when VCS ref cache format changes in a breaking way.
187
+ VCS_REF_CACHE_VERSION = 1
188
+
189
+
190
+ class VcsRefCacheManager:
191
+ """
192
+ Cache for graphs computed from historical VCS commits.
193
+
194
+ Stored under .serpentine/refs/<key>.json, keyed by
195
+ sha256(commit_hash + ":" + config_fingerprint)[:24].
196
+
197
+ Historical commits are immutable so entries never expire — only
198
+ invalidated when the analyzer binary changes (same sentinel pattern
199
+ as PerFileCacheManager).
200
+ """
201
+
202
+ def __init__(self, project_path: Path) -> None:
203
+ self._cache_dir = project_path / CACHE_DIR / REFS_CACHE_SUBDIR
204
+ self._ensure_version()
205
+
206
+ def _version_tag(self) -> str:
207
+ package_dir = Path(__file__).parent
208
+ binary = _find_analyzer_binary(package_dir)
209
+ binary_mtime: int = 0
210
+ if binary:
211
+ try:
212
+ binary_mtime = binary.stat().st_mtime_ns
213
+ except OSError:
214
+ pass
215
+ return f"{VCS_REF_CACHE_VERSION}:{binary_mtime}"
216
+
217
+ def _ensure_version(self) -> None:
218
+ version_file = self._cache_dir / ".version"
219
+ expected = self._version_tag()
220
+ try:
221
+ if version_file.exists() and version_file.read_text(encoding="utf-8").strip() == expected:
222
+ return
223
+ except Exception:
224
+ pass
225
+ try:
226
+ if self._cache_dir.exists():
227
+ shutil.rmtree(self._cache_dir)
228
+ except Exception as e:
229
+ logger.debug(f"VCS ref cache clear failed: {e}")
230
+ try:
231
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
232
+ version_file.write_text(expected + "\n", encoding="utf-8")
233
+ except Exception as e:
234
+ logger.debug(f"VCS ref cache version write failed: {e}")
235
+
236
+ def _entry_path(self, commit_hash: str, config_fp: str) -> Path:
237
+ key = hashlib.sha256(f"{commit_hash}:{config_fp}".encode()).hexdigest()[:24]
238
+ return self._cache_dir / f"{key}.json"
239
+
240
+ def get(self, commit_hash: str, config_fp: str) -> str | None:
241
+ try:
242
+ entry = self._entry_path(commit_hash, config_fp)
243
+ if entry.exists():
244
+ return entry.read_text(encoding="utf-8")
245
+ except Exception as e:
246
+ logger.debug(f"VCS ref cache load failed: {e}")
247
+ return None
248
+
249
+ def put(self, commit_hash: str, config_fp: str, graph_json: str) -> None:
250
+ try:
251
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
252
+ entry = self._entry_path(commit_hash, config_fp)
253
+ entry.write_text(graph_json, encoding="utf-8")
254
+ except Exception as e:
255
+ logger.debug(f"VCS ref cache save failed: {e}")
@@ -23,9 +23,12 @@ import click
23
23
  import uvicorn
24
24
 
25
25
  from serpentine import __version__
26
+ from serpentine.cache import VcsRefCacheManager
26
27
  from serpentine.selector import GraphSelector, filter_by_state
27
28
  from serpentine.server import create_app
28
29
  from serpentine.state import GraphStateManager
30
+ from serpentine.vcs.backend import detect_backend
31
+ from serpentine.vcs.manager import VcsManager
29
32
  from serpentine.watcher import FileWatcher
30
33
 
31
34
  _DEFAULT_CONFIG_YAML = """\
@@ -135,8 +138,15 @@ def serve(
135
138
  f"✅ Found {state_manager.node_count} nodes, {state_manager.edge_count} edges"
136
139
  )
137
140
 
141
+ # Set up VCS manager if a backend is available
142
+ vcs_manager: VcsManager | None = None
143
+ backend = detect_backend(project_path)
144
+ if backend is not None:
145
+ vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
146
+ click.echo("🔀 VCS integration enabled")
147
+
138
148
  # Create the web application
139
- app = create_app(state_manager, static_dir=_get_static_dir())
149
+ app = create_app(state_manager, static_dir=_get_static_dir(), vcs_manager=vcs_manager)
140
150
 
141
151
  # Set up file watcher if enabled
142
152
  watcher: FileWatcher | None = None
@@ -255,6 +265,13 @@ def serve(
255
265
  default=False,
256
266
  help="Include assignment nodes in text output (default: excluded, only with --format text)",
257
267
  )
268
+ @click.option(
269
+ "--compare",
270
+ "compare_ref",
271
+ type=str,
272
+ default=None,
273
+ help="VCS ref to compare against (branch, tag, or commit hash). Shows added/removed/modified nodes.",
274
+ )
258
275
  def analyze(
259
276
  path: str,
260
277
  output: str | None,
@@ -270,6 +287,7 @@ def analyze(
270
287
  fmt: str,
271
288
  source: bool,
272
289
  include_assignments: bool,
290
+ compare_ref: str | None,
273
291
  ) -> None:
274
292
  """Analyze a project and output the code reference graph.
275
293
 
@@ -287,6 +305,20 @@ def analyze(
287
305
  state_manager = GraphStateManager(project_path)
288
306
  state_manager.analyze_project(project_path, force_fresh=source)
289
307
 
308
+ if compare_ref is not None:
309
+ backend = detect_backend(project_path)
310
+ if backend is None:
311
+ raise click.ClickException(
312
+ "No VCS backend found. Is this a git repo? "
313
+ "Install git support with: pip install serpentine[git]"
314
+ )
315
+ try:
316
+ vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
317
+ from_graph_json = vcs_manager.get_graph_at(compare_ref)
318
+ except Exception as e:
319
+ raise click.ClickException(f"Could not resolve ref '{compare_ref}': {e}") from e
320
+ state_manager.set_vcs_comparison(from_graph_json, to_graph_json=None)
321
+
290
322
  # Get the graph data as a dict for post-processing
291
323
  graph_data = state_manager.get_graph_data()
292
324
 
@@ -421,6 +453,13 @@ def analyze(
421
453
  default="text",
422
454
  help="Output format: text (default) or json",
423
455
  )
456
+ @click.option(
457
+ "--compare",
458
+ "compare_ref",
459
+ type=str,
460
+ default=None,
461
+ help="VCS ref to compare against (branch, tag, or commit hash). Shows added/removed/modified nodes.",
462
+ )
424
463
  def catalog(
425
464
  path: str,
426
465
  filters: tuple[str, ...],
@@ -431,6 +470,7 @@ def catalog(
431
470
  pretty: bool,
432
471
  state: str | None,
433
472
  fmt: str,
473
+ compare_ref: str | None,
434
474
  ) -> None:
435
475
  """Flat list of all nodes for agent discovery.
436
476
 
@@ -448,6 +488,20 @@ def catalog(
448
488
  state_manager = GraphStateManager(project_path)
449
489
  state_manager.analyze_project(project_path)
450
490
 
491
+ if compare_ref is not None:
492
+ backend = detect_backend(project_path)
493
+ if backend is None:
494
+ raise click.ClickException(
495
+ "No VCS backend found. Is this a git repo? "
496
+ "Install git support with: pip install serpentine[git]"
497
+ )
498
+ try:
499
+ vcs_manager = VcsManager(backend, VcsRefCacheManager(project_path), state_manager.config)
500
+ from_graph_json = vcs_manager.get_graph_at(compare_ref)
501
+ except Exception as e:
502
+ raise click.ClickException(f"Could not resolve ref '{compare_ref}': {e}") from e
503
+ state_manager.set_vcs_comparison(from_graph_json, to_graph_json=None)
504
+
451
505
  graph_data = state_manager.get_graph_data()
452
506
 
453
507
  if fmt == "text":
@@ -173,8 +173,12 @@ class GraphSelector:
173
173
 
174
174
  if at_op:
175
175
  # @pattern — full connected component
176
- matched = self._match_pattern(base_pattern)
177
- selected.update(self._expand_component(matched))
176
+ matched = self._match_state(base_pattern[len("state:"):]) if base_pattern.startswith("state:") else self._match_pattern(base_pattern)
177
+ roots: Set[str] = set()
178
+ for node_id in matched:
179
+ roots.add(node_id)
180
+ roots.update(self._get_all_descendants(node_id))
181
+ selected.update(self._expand_component(roots))
178
182
  continue
179
183
 
180
184
  # left_n/right_n are None when the +/N+ prefix/suffix is absent entirely.
@@ -185,7 +189,7 @@ class GraphSelector:
185
189
  up_depth = int(left_n) if left_n else None # None = unlimited
186
190
  down_depth = int(right_n) if right_n else None # None = unlimited
187
191
 
188
- matched = self._match_pattern(base_pattern)
192
+ matched = self._match_state(base_pattern[len("state:"):]) if base_pattern.startswith("state:") else self._match_pattern(base_pattern)
189
193
  expanded = matched.copy()
190
194
 
191
195
  for node_id in matched:
@@ -222,6 +226,14 @@ class GraphSelector:
222
226
  queue.append((neighbor, depth + 1))
223
227
  return visited
224
228
 
229
+ def _match_state(self, value: str) -> Set[str]:
230
+ """Match nodes by change_status value."""
231
+ return {
232
+ node_id
233
+ for node_id, node in self.flat_nodes.items()
234
+ if node.get("change_status") == value
235
+ }
236
+
225
237
  def _match_pattern(self, pattern: str) -> Set[str]:
226
238
  """Match pattern against flat node ids using glob matching."""
227
239
  # Normalize ** to * — in dotted IDs, * already matches dots
@@ -28,6 +28,7 @@ from starlette.staticfiles import StaticFiles
28
28
 
29
29
  from serpentine.server.routes import create_routes
30
30
  from serpentine.server.websocket import ConnectionManager, set_connection_manager
31
+ from serpentine.vcs.manager import VcsManager
31
32
 
32
33
  if TYPE_CHECKING:
33
34
  from serpentine.state import GraphStateManager
@@ -42,6 +43,7 @@ def create_app(
42
43
  state_manager: "GraphStateManager",
43
44
  static_dir: Path | None = None,
44
45
  debug: bool = False,
46
+ vcs_manager: VcsManager | None = None,
45
47
  ) -> Starlette:
46
48
  """
47
49
  Create and configure the Starlette application.
@@ -91,7 +93,7 @@ def create_app(
91
93
  _event_loop = None
92
94
 
93
95
  # Create API routes
94
- routes = create_routes(state_manager, connection_manager)
96
+ routes = create_routes(state_manager, connection_manager, vcs_manager)
95
97
 
96
98
  # Add static file serving if directory has a built frontend
97
99
  if static_dir and (static_dir / "index.html").exists():
@@ -22,6 +22,7 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
22
22
 
23
23
  from serpentine.selector import GraphSelector, filter_by_state
24
24
  from serpentine.server.websocket import ConnectionManager
25
+ from serpentine.vcs.manager import VcsManager
25
26
 
26
27
  if TYPE_CHECKING:
27
28
  from serpentine.state import GraphStateManager
@@ -32,6 +33,7 @@ logger = logging.getLogger(__name__)
32
33
  def create_routes(
33
34
  state_manager: "GraphStateManager",
34
35
  connection_manager: ConnectionManager,
36
+ vcs_manager: VcsManager | None = None,
35
37
  ) -> list[Route | WebSocketRoute]:
36
38
  """
37
39
  Create all HTTP and WebSocket routes for the application.
@@ -178,6 +180,19 @@ def create_routes(
178
180
  yield node
179
181
  yield from _count_nodes(node.get("children", []))
180
182
 
183
+ async def get_vcs_refs(request: Request) -> Response:
184
+ """Return available VCS refs for the ref picker."""
185
+ if vcs_manager is None:
186
+ return Response(
187
+ content=json.dumps({"available": False, "refs": []}),
188
+ media_type="application/json",
189
+ )
190
+ refs = [{"id": r.id, "display": r.display, "kind": r.kind} for r in vcs_manager.list_refs()]
191
+ return Response(
192
+ content=json.dumps({"available": True, "refs": refs}),
193
+ media_type="application/json",
194
+ )
195
+
181
196
  async def websocket_endpoint(websocket: WebSocket) -> None:
182
197
  """
183
198
  WebSocket endpoint for real-time graph updates.
@@ -231,6 +246,54 @@ def create_routes(
231
246
  "type": "node_code",
232
247
  "data": {"qualname": qualname, "code": code},
233
248
  })
249
+ elif data.get("action") == "set_vcs_comparison":
250
+ action_data = data.get("data", {})
251
+ from_ref: str = action_data.get("from", "@start")
252
+ to_ref: str = action_data.get("to", "@current")
253
+
254
+ try:
255
+ # Resolve from_graph_json
256
+ if from_ref == "@current":
257
+ from_graph_json = json.dumps(state_manager._graph_data)
258
+ elif from_ref == "@start":
259
+ from_graph_json = json.dumps(state_manager._start_graph_data)
260
+ elif vcs_manager is None:
261
+ await websocket.send_json({"type": "error", "data": {"message": "VCS not available"}})
262
+ continue
263
+ else:
264
+ valid_ids = {r.id for r in vcs_manager.list_refs()}
265
+ if from_ref not in valid_ids:
266
+ await websocket.send_json({"type": "error", "data": {"message": f"Unknown ref: {from_ref}"}})
267
+ continue
268
+ from_graph_json = vcs_manager.get_graph_at(from_ref)
269
+
270
+ # Resolve to_graph_json
271
+ to_graph_json: str | None
272
+ if to_ref == "@current":
273
+ to_graph_json = None
274
+ elif to_ref == "@start":
275
+ to_graph_json = json.dumps(state_manager._start_graph_data)
276
+ elif vcs_manager is None:
277
+ await websocket.send_json({"type": "error", "data": {"message": "VCS not available"}})
278
+ continue
279
+ else:
280
+ valid_ids = {r.id for r in vcs_manager.list_refs()}
281
+ if to_ref not in valid_ids:
282
+ await websocket.send_json({"type": "error", "data": {"message": f"Unknown ref: {to_ref}"}})
283
+ continue
284
+ to_graph_json = vcs_manager.get_graph_at(to_ref)
285
+
286
+ state_manager.set_vcs_comparison(from_graph_json, to_graph_json)
287
+ await connection_manager.send_graph_update(state_manager.get_graph_json())
288
+ except Exception as e:
289
+ logger.error(f"[vcs] set_vcs_comparison failed: {e}", exc_info=True)
290
+ await websocket.send_json({"type": "error", "data": {"message": f"Comparison failed: {e}"}})
291
+ elif data.get("action") == "clear_vcs_comparison":
292
+ state_manager.clear_vcs_comparison()
293
+ await connection_manager.send_graph_update(state_manager.get_graph_json())
294
+ elif data.get("action") == "update_start":
295
+ state_manager.update_start()
296
+ await connection_manager.send_graph_update(state_manager.get_graph_json())
234
297
  else:
235
298
  logger.debug(f"Unknown message type: {data.get('type')}")
236
299
 
@@ -244,5 +307,6 @@ def create_routes(
244
307
  Route("/api/health", health, methods=["GET"]),
245
308
  Route("/api/graph", get_graph, methods=["GET"]),
246
309
  Route("/api/catalog", get_catalog, methods=["GET"]),
310
+ Route("/api/vcs/refs", get_vcs_refs, methods=["GET"]),
247
311
  WebSocketRoute("/ws", websocket_endpoint),
248
312
  ]