codegraphcontext 0.4.17__py3-none-any.whl → 0.4.18__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.
- codegraphcontext/api/app.py +5 -0
- codegraphcontext/cli/cli_helpers.py +52 -4
- codegraphcontext/cli/config_manager.py +33 -8
- codegraphcontext/cli/hook_manager.py +220 -0
- codegraphcontext/cli/main.py +121 -15
- codegraphcontext/cli/setup_wizard.py +96 -10
- codegraphcontext/core/cgc_bundle.py +14 -4
- codegraphcontext/core/database_kuzu.py +10 -0
- codegraphcontext/core/watcher.py +48 -5
- codegraphcontext/tools/code_finder.py +1 -1
- codegraphcontext/tools/graph_builder.py +38 -178
- codegraphcontext/tools/handlers/management_handlers.py +6 -2
- codegraphcontext/tools/handlers/watcher_handlers.py +7 -2
- codegraphcontext/tools/indexing/discovery.py +1 -2
- codegraphcontext/tools/indexing/persistence/utils.py +46 -0
- codegraphcontext/tools/indexing/persistence/writer.py +264 -93
- codegraphcontext/tools/indexing/pipeline.py +27 -13
- codegraphcontext/tools/indexing/resolution/calls.py +21 -10
- codegraphcontext/tools/indexing/resolution/post_resolution.py +18 -6
- codegraphcontext/tools/indexing/scip_pipeline.py +38 -3
- codegraphcontext/tools/languages/python.py +10 -11
- codegraphcontext/tools/report_generator.py +11 -11
- codegraphcontext/utils/path_sandbox.py +2 -1
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/METADATA +5 -5
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/RECORD +29 -27
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/WHEEL +0 -0
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/entry_points.txt +0 -0
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/licenses/LICENSE +0 -0
- {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/top_level.txt +0 -0
codegraphcontext/api/app.py
CHANGED
|
@@ -24,6 +24,11 @@ def create_app() -> FastAPI:
|
|
|
24
24
|
|
|
25
25
|
app.include_router(router, prefix="/api/v1")
|
|
26
26
|
|
|
27
|
+
@app.get("/health")
|
|
28
|
+
async def health():
|
|
29
|
+
"""Liveness probe for load balancers and k8s."""
|
|
30
|
+
return {"status": "ok"}
|
|
31
|
+
|
|
27
32
|
# MCP-over-SSE Endpoints
|
|
28
33
|
app.add_api_route("/api/v1/mcp/sse", handle_sse, methods=["GET"])
|
|
29
34
|
app.add_api_route("/api/v1/mcp/messages", handle_messages, methods=["POST"])
|
|
@@ -260,6 +260,10 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
260
260
|
"""Synchronously indexes a repository in a given context."""
|
|
261
261
|
time_start = time.time()
|
|
262
262
|
path_obj = Path(path).resolve()
|
|
263
|
+
# Normalize to forward slashes for cross-platform DB consistency.
|
|
264
|
+
# The graph DB always stores paths via Path.resolve().as_posix(),
|
|
265
|
+
# so Cypher queries must also use forward slashes on Windows.
|
|
266
|
+
repo_path_str = path_obj.as_posix()
|
|
263
267
|
index_cwd = path_obj if path_obj.is_dir() else path_obj.parent
|
|
264
268
|
services = _initialize_services(context, cwd=index_cwd)
|
|
265
269
|
if not all(services[:3]):
|
|
@@ -283,7 +287,7 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
283
287
|
with db_manager.get_driver().session() as session:
|
|
284
288
|
result = session.run(
|
|
285
289
|
"MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(DISTINCT f) as file_count",
|
|
286
|
-
path=
|
|
290
|
+
path=repo_path_str
|
|
287
291
|
)
|
|
288
292
|
record = result.single()
|
|
289
293
|
file_count = record["file_count"] if record else 0
|
|
@@ -415,6 +419,49 @@ def delete_helper(repo_path: str, context: Optional[str] = None):
|
|
|
415
419
|
finally:
|
|
416
420
|
db_manager.close_driver()
|
|
417
421
|
|
|
422
|
+
def _print_query_exception(e: Exception, query: str) -> None:
|
|
423
|
+
"""
|
|
424
|
+
Pretty-print a database query exception, surfacing the raw driver
|
|
425
|
+
error message so Cypher syntax problems are clearly visible.
|
|
426
|
+
"""
|
|
427
|
+
import traceback
|
|
428
|
+
|
|
429
|
+
error_type = type(e).__name__
|
|
430
|
+
error_module = type(e).__module__ or ""
|
|
431
|
+
|
|
432
|
+
# Neo4j: CypherSyntaxError and other ClientError subclasses carry
|
|
433
|
+
# a .message and .code attribute with the full server-side detail.
|
|
434
|
+
if "neo4j" in error_module:
|
|
435
|
+
code = getattr(e, "code", None)
|
|
436
|
+
msg = getattr(e, "message", None) or str(e)
|
|
437
|
+
console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
|
|
438
|
+
if code:
|
|
439
|
+
console.print(f" [yellow]Code:[/yellow] {code}")
|
|
440
|
+
console.print(f" [yellow]Message:[/yellow] {msg}")
|
|
441
|
+
|
|
442
|
+
# FalkorDB: ResponseError / exceptions in falkordb or redis packages
|
|
443
|
+
elif "falkordb" in error_module or "redis" in error_module:
|
|
444
|
+
console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
|
|
445
|
+
console.print(f" [yellow]Database message:[/yellow] {e}")
|
|
446
|
+
|
|
447
|
+
# KuzuDB: RuntimeError from the kuzu extension
|
|
448
|
+
# KuzuDB: RuntimeError from the kuzu extension or database_kuzu wrapper
|
|
449
|
+
elif "kuzu" in error_module or (
|
|
450
|
+
error_type == "RuntimeError" and "Parser exception" in str(e)
|
|
451
|
+
):
|
|
452
|
+
console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
|
|
453
|
+
console.print(f" [yellow]Database message:[/yellow] {e}")
|
|
454
|
+
|
|
455
|
+
else:
|
|
456
|
+
# Fallback: unknown backend — print type + message + traceback
|
|
457
|
+
console.print(f"[bold red]An error occurred while executing query ({error_type}):[/bold red]")
|
|
458
|
+
console.print(f" [yellow]Message:[/yellow] {e}")
|
|
459
|
+
console.print("[dim]--- Traceback ---[/dim]")
|
|
460
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
461
|
+
|
|
462
|
+
console.print(f"\n[dim]Failed query:[/dim]")
|
|
463
|
+
console.print(f"[dim] {query}[/dim]")
|
|
464
|
+
|
|
418
465
|
|
|
419
466
|
def cypher_helper(query: str, context: Optional[str] = None):
|
|
420
467
|
"""Executes a read-only Cypher query."""
|
|
@@ -440,7 +487,7 @@ def cypher_helper(query: str, context: Optional[str] = None):
|
|
|
440
487
|
records = [record.data() for record in result]
|
|
441
488
|
console.print(json.dumps(records, indent=2))
|
|
442
489
|
except Exception as e:
|
|
443
|
-
|
|
490
|
+
_print_query_exception(e, query)
|
|
444
491
|
db_manager.close_driver()
|
|
445
492
|
raise typer.Exit(code=1)
|
|
446
493
|
finally:
|
|
@@ -466,7 +513,7 @@ def cypher_helper_visual(query: str, context: Optional[str] = None):
|
|
|
466
513
|
try:
|
|
467
514
|
visualize_cypher_results(query)
|
|
468
515
|
except Exception as e:
|
|
469
|
-
|
|
516
|
+
_print_query_exception(e, query)
|
|
470
517
|
db_manager.close_driver()
|
|
471
518
|
raise typer.Exit(code=1)
|
|
472
519
|
finally:
|
|
@@ -844,10 +891,11 @@ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional
|
|
|
844
891
|
|
|
845
892
|
# Add the directory to watch
|
|
846
893
|
if is_indexed:
|
|
847
|
-
console.print("[green]✓[/green] Already indexed
|
|
894
|
+
console.print("[green]✓[/green] Already indexed. Synchronizing current files...")
|
|
848
895
|
watcher.watch_directory(
|
|
849
896
|
str(path_obj),
|
|
850
897
|
perform_initial_scan=False,
|
|
898
|
+
sync_on_start=True,
|
|
851
899
|
cgcignore_path=ctx.cgcignore_path,
|
|
852
900
|
)
|
|
853
901
|
else:
|
|
@@ -160,6 +160,14 @@ CONFIG_VALIDATORS = {
|
|
|
160
160
|
"CGC_EMBEDDING_MODEL": ["local", "openai"],
|
|
161
161
|
"FUZZY_SEARCH": ["true", "false"],
|
|
162
162
|
}
|
|
163
|
+
|
|
164
|
+
SUPPORTED_DATABASES: List[str] = CONFIG_VALIDATORS["DEFAULT_DATABASE"]
|
|
165
|
+
DATABASE_CLI_HELP = (
|
|
166
|
+
"Database backend ("
|
|
167
|
+
+ "|".join(SUPPORTED_DATABASES)
|
|
168
|
+
+ "). Defaults to DEFAULT_DATABASE from config."
|
|
169
|
+
)
|
|
170
|
+
|
|
163
171
|
DEFAULT_CGCIGNORE_PATTERNS = """\
|
|
164
172
|
# Default .cgcignore patterns
|
|
165
173
|
# Lines starting with # are comments; blank lines are ignored.
|
|
@@ -279,18 +287,25 @@ def load_config() -> Dict[str, str]:
|
|
|
279
287
|
def should_apply_project_dotenv() -> bool:
|
|
280
288
|
"""True when cwd-local ``.codegraphcontext/.env`` should merge with global config.
|
|
281
289
|
|
|
282
|
-
|
|
283
|
-
|
|
290
|
+
Project env is loaded only in **per-repo** context mode (or when
|
|
291
|
+
``CGC_LOAD_PROJECT_ENV=1``). In **global** / **named** mode, ``~/.codegraphcontext/.env``
|
|
292
|
+
wins so clones with a checked-in ``.codegraphcontext/.env`` do not hijack config.
|
|
293
|
+
|
|
294
|
+
Set ``CGC_IGNORE_PROJECT_ENV=1`` to force skip; ``CGC_LOAD_PROJECT_ENV=1`` to force load.
|
|
284
295
|
"""
|
|
285
296
|
if os.getenv("CGC_IGNORE_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
|
|
286
297
|
return False
|
|
287
298
|
if os.getenv("CGC_LOAD_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
|
|
288
299
|
return True
|
|
300
|
+
cfg = load_context_config()
|
|
301
|
+
if cfg.mode != "per-repo":
|
|
302
|
+
return False
|
|
289
303
|
try:
|
|
290
304
|
Path.cwd().resolve().relative_to(Path.home().resolve())
|
|
291
305
|
return True
|
|
292
306
|
except ValueError:
|
|
293
|
-
|
|
307
|
+
# Per-repo indexing from /tmp with an isolated HOME (common in E2E/CI).
|
|
308
|
+
return True
|
|
294
309
|
|
|
295
310
|
|
|
296
311
|
def find_local_env() -> Optional[Path]:
|
|
@@ -869,23 +884,33 @@ def resolve_context(
|
|
|
869
884
|
local_cgc = cwd / ".codegraphcontext"
|
|
870
885
|
local_cgc.mkdir(parents=True, exist_ok=True)
|
|
871
886
|
(local_cgc / "db").mkdir(exist_ok=True)
|
|
872
|
-
|
|
887
|
+
|
|
888
|
+
inherited_db = load_config().get("DEFAULT_DATABASE", "falkordb")
|
|
889
|
+
|
|
873
890
|
# Copy global .env into local context for easy per-repo tweaking
|
|
874
891
|
import shutil
|
|
875
892
|
if CONFIG_FILE.exists():
|
|
876
893
|
shutil.copy2(CONFIG_FILE, local_cgc / ".env")
|
|
877
|
-
|
|
878
|
-
|
|
894
|
+
|
|
895
|
+
local_yaml = local_cgc / "config.yaml"
|
|
896
|
+
if not local_yaml.exists():
|
|
897
|
+
with open(local_yaml, "w", encoding="utf-8") as f:
|
|
898
|
+
yaml.safe_dump({"database": inherited_db}, f)
|
|
899
|
+
|
|
900
|
+
console.print(
|
|
901
|
+
f"[dim]Auto-initialized per-repo context at {local_cgc} "
|
|
902
|
+
f"(Database: {inherited_db})[/dim]"
|
|
903
|
+
)
|
|
879
904
|
|
|
880
905
|
if local_cgc is not None:
|
|
881
906
|
# Read local config.yaml if present
|
|
882
907
|
local_yaml = local_cgc / "config.yaml"
|
|
883
|
-
local_db = "falkordb"
|
|
908
|
+
local_db = load_config().get("DEFAULT_DATABASE", "falkordb")
|
|
884
909
|
if local_yaml.exists():
|
|
885
910
|
try:
|
|
886
911
|
with open(local_yaml, encoding="utf-8") as f:
|
|
887
912
|
local_raw = yaml.safe_load(f) or {}
|
|
888
|
-
local_db = local_raw.get("database",
|
|
913
|
+
local_db = local_raw.get("database", local_db)
|
|
889
914
|
except Exception:
|
|
890
915
|
pass
|
|
891
916
|
db_path = str(local_cgc / "db" / local_db)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Git hook management for keeping CGC indexes in sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MANAGED_MARKER = "# CGC_MANAGED_HOOK"
|
|
12
|
+
HOOK_NAMES = ("post-commit", "post-checkout")
|
|
13
|
+
GITATTRIBUTES_ENTRY = "*.cgc merge=cgc-bundle"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HookError(RuntimeError):
|
|
17
|
+
"""Raised when hook installation cannot proceed safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GitRepository:
|
|
22
|
+
root: Path
|
|
23
|
+
git_dir: Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HookStatus:
|
|
28
|
+
repo_root: Path
|
|
29
|
+
git_dir: Path
|
|
30
|
+
installed_hooks: tuple[str, ...]
|
|
31
|
+
unmanaged_hooks: tuple[str, ...]
|
|
32
|
+
has_merge_driver: bool
|
|
33
|
+
has_gitattributes_entry: bool
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def installed(self) -> bool:
|
|
37
|
+
return (
|
|
38
|
+
bool(self.installed_hooks)
|
|
39
|
+
and self.has_merge_driver
|
|
40
|
+
and self.has_gitattributes_entry
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_git_repository(start: Path | str | None = None) -> GitRepository:
|
|
45
|
+
"""Find the nearest Git repository root and git directory."""
|
|
46
|
+
current = Path(start or Path.cwd()).resolve()
|
|
47
|
+
if current.is_file():
|
|
48
|
+
current = current.parent
|
|
49
|
+
|
|
50
|
+
for candidate in (current, *current.parents):
|
|
51
|
+
git_path = candidate / ".git"
|
|
52
|
+
if git_path.is_dir():
|
|
53
|
+
return GitRepository(root=candidate, git_dir=git_path)
|
|
54
|
+
if git_path.is_file():
|
|
55
|
+
target = _read_worktree_gitdir(git_path)
|
|
56
|
+
return GitRepository(root=candidate, git_dir=target)
|
|
57
|
+
|
|
58
|
+
raise HookError("No Git repository found from the current directory.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def install_hooks(start: Path | str | None = None, *, force: bool = False) -> HookStatus:
|
|
62
|
+
repo = find_git_repository(start)
|
|
63
|
+
hooks_dir = repo.git_dir / "hooks"
|
|
64
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
script = _hook_script(repo.root)
|
|
67
|
+
for hook_name in HOOK_NAMES:
|
|
68
|
+
hook_path = hooks_dir / hook_name
|
|
69
|
+
if hook_path.exists() and not _is_managed_hook(hook_path) and not force:
|
|
70
|
+
raise HookError(
|
|
71
|
+
f"{hook_name} already exists and is not managed by CGC. "
|
|
72
|
+
"Re-run with --force to replace it."
|
|
73
|
+
)
|
|
74
|
+
hook_path.write_text(script, encoding="utf-8")
|
|
75
|
+
mode = hook_path.stat().st_mode
|
|
76
|
+
hook_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
77
|
+
|
|
78
|
+
_ensure_gitattributes(repo.root)
|
|
79
|
+
_configure_merge_driver(repo.root)
|
|
80
|
+
return get_hook_status(repo.root)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def uninstall_hooks(start: Path | str | None = None) -> HookStatus:
|
|
84
|
+
repo = find_git_repository(start)
|
|
85
|
+
hooks_dir = repo.git_dir / "hooks"
|
|
86
|
+
|
|
87
|
+
for hook_name in HOOK_NAMES:
|
|
88
|
+
hook_path = hooks_dir / hook_name
|
|
89
|
+
if hook_path.exists() and _is_managed_hook(hook_path):
|
|
90
|
+
hook_path.unlink()
|
|
91
|
+
|
|
92
|
+
_remove_gitattributes_entry(repo.root)
|
|
93
|
+
_unset_git_config(repo.root, "merge.cgc-bundle.name")
|
|
94
|
+
_unset_git_config(repo.root, "merge.cgc-bundle.driver")
|
|
95
|
+
return get_hook_status(repo.root)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_hook_status(start: Path | str | None = None) -> HookStatus:
|
|
99
|
+
repo = find_git_repository(start)
|
|
100
|
+
hooks_dir = repo.git_dir / "hooks"
|
|
101
|
+
installed_hooks: list[str] = []
|
|
102
|
+
unmanaged_hooks: list[str] = []
|
|
103
|
+
|
|
104
|
+
for hook_name in HOOK_NAMES:
|
|
105
|
+
hook_path = hooks_dir / hook_name
|
|
106
|
+
if not hook_path.exists():
|
|
107
|
+
continue
|
|
108
|
+
if _is_managed_hook(hook_path):
|
|
109
|
+
installed_hooks.append(hook_name)
|
|
110
|
+
else:
|
|
111
|
+
unmanaged_hooks.append(hook_name)
|
|
112
|
+
|
|
113
|
+
return HookStatus(
|
|
114
|
+
repo_root=repo.root,
|
|
115
|
+
git_dir=repo.git_dir,
|
|
116
|
+
installed_hooks=tuple(installed_hooks),
|
|
117
|
+
unmanaged_hooks=tuple(unmanaged_hooks),
|
|
118
|
+
has_merge_driver=_has_git_config(repo.root, "merge.cgc-bundle.driver"),
|
|
119
|
+
has_gitattributes_entry=_has_gitattributes_entry(repo.root),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_worktree_gitdir(git_file: Path) -> Path:
|
|
124
|
+
content = git_file.read_text(encoding="utf-8").strip()
|
|
125
|
+
prefix = "gitdir:"
|
|
126
|
+
if not content.lower().startswith(prefix):
|
|
127
|
+
raise HookError(f"Unsupported .git file format at {git_file}")
|
|
128
|
+
|
|
129
|
+
raw_path = content[len(prefix):].strip()
|
|
130
|
+
git_dir = Path(raw_path)
|
|
131
|
+
if not git_dir.is_absolute():
|
|
132
|
+
git_dir = (git_file.parent / git_dir).resolve()
|
|
133
|
+
return git_dir
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _hook_script(repo_root: Path) -> str:
|
|
137
|
+
repo_root_value = _sh_quote(str(repo_root))
|
|
138
|
+
return (
|
|
139
|
+
"#!/bin/sh\n"
|
|
140
|
+
f"{MANAGED_MARKER}: CodeGraphContext auto-update hook\n"
|
|
141
|
+
f"CGC_REPO_ROOT={repo_root_value}\n"
|
|
142
|
+
"if command -v cgc >/dev/null 2>&1; then\n"
|
|
143
|
+
' cgc update "$CGC_REPO_ROOT" --quiet\n'
|
|
144
|
+
"else\n"
|
|
145
|
+
' python -m codegraphcontext update "$CGC_REPO_ROOT" --quiet\n'
|
|
146
|
+
"fi\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sh_quote(value: str) -> str:
|
|
151
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_managed_hook(path: Path) -> bool:
|
|
155
|
+
try:
|
|
156
|
+
return MANAGED_MARKER in path.read_text(encoding="utf-8", errors="ignore")
|
|
157
|
+
except OSError:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _ensure_gitattributes(repo_root: Path) -> None:
|
|
162
|
+
path = repo_root / ".gitattributes"
|
|
163
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
164
|
+
lines = existing.splitlines()
|
|
165
|
+
if any(line.strip() == GITATTRIBUTES_ENTRY for line in lines):
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
prefix = existing
|
|
169
|
+
if prefix and not prefix.endswith("\n"):
|
|
170
|
+
prefix += "\n"
|
|
171
|
+
path.write_text(f"{prefix}{GITATTRIBUTES_ENTRY}\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _remove_gitattributes_entry(repo_root: Path) -> None:
|
|
175
|
+
path = repo_root / ".gitattributes"
|
|
176
|
+
if not path.exists():
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
lines = [
|
|
180
|
+
line
|
|
181
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
182
|
+
if line.strip() != GITATTRIBUTES_ENTRY
|
|
183
|
+
]
|
|
184
|
+
if lines:
|
|
185
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
186
|
+
else:
|
|
187
|
+
path.unlink()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _has_gitattributes_entry(repo_root: Path) -> bool:
|
|
191
|
+
path = repo_root / ".gitattributes"
|
|
192
|
+
if not path.exists():
|
|
193
|
+
return False
|
|
194
|
+
return any(
|
|
195
|
+
line.strip() == GITATTRIBUTES_ENTRY
|
|
196
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _configure_merge_driver(repo_root: Path) -> None:
|
|
201
|
+
_git_config(repo_root, "merge.cgc-bundle.name", "CodeGraphContext bundle merge driver")
|
|
202
|
+
_git_config(repo_root, "merge.cgc-bundle.driver", "cgc bundle merge %O %A %B")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _git_config(repo_root: Path, key: str, value: str) -> None:
|
|
206
|
+
subprocess.run(["git", "-C", str(repo_root), "config", key, value], check=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _unset_git_config(repo_root: Path, key: str) -> None:
|
|
210
|
+
subprocess.run(["git", "-C", str(repo_root), "config", "--unset-all", key], check=False)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _has_git_config(repo_root: Path, key: str) -> bool:
|
|
214
|
+
result = subprocess.run(
|
|
215
|
+
["git", "-C", str(repo_root), "config", "--get", key],
|
|
216
|
+
check=False,
|
|
217
|
+
stdout=subprocess.DEVNULL,
|
|
218
|
+
stderr=subprocess.DEVNULL,
|
|
219
|
+
)
|
|
220
|
+
return result.returncode == 0
|
codegraphcontext/cli/main.py
CHANGED
|
@@ -9,6 +9,7 @@ Commands:
|
|
|
9
9
|
- help: Displays help information.
|
|
10
10
|
- version: Show the installed version.
|
|
11
11
|
"""
|
|
12
|
+
import sys
|
|
12
13
|
import typer
|
|
13
14
|
from rich.console import Console
|
|
14
15
|
from rich.table import Table
|
|
@@ -34,6 +35,7 @@ from .cli_helpers import (
|
|
|
34
35
|
cypher_helper_visual,
|
|
35
36
|
visualize_helper,
|
|
36
37
|
reindex_helper,
|
|
38
|
+
update_helper,
|
|
37
39
|
clean_helper,
|
|
38
40
|
stats_helper,
|
|
39
41
|
_initialize_services,
|
|
@@ -42,6 +44,7 @@ from .cli_helpers import (
|
|
|
42
44
|
list_watching_helper,
|
|
43
45
|
setup_scip_helper,
|
|
44
46
|
)
|
|
47
|
+
from .hook_manager import HookError, get_hook_status, install_hooks, uninstall_hooks
|
|
45
48
|
|
|
46
49
|
# Set the log level for the noisy neo4j, asyncio, and urllib3 loggers to keep the output clean.
|
|
47
50
|
# Get the log level from config, defaulting to WARNING
|
|
@@ -249,7 +252,7 @@ def context_list():
|
|
|
249
252
|
@context_app.command("create")
|
|
250
253
|
def context_create(
|
|
251
254
|
name: str = typer.Argument(..., help="Name of the new context"),
|
|
252
|
-
database: str = typer.Option(None, "--database", "--db", "-db", "-d", help=
|
|
255
|
+
database: str = typer.Option(None, "--database", "--db", "-db", "-d", help=config_manager.DATABASE_CLI_HELP),
|
|
253
256
|
db_path: str = typer.Option(None, "--db-path", help="Explicit path for the DB (defaults to ~/.codegraphcontext/contexts/<name>/db)"),
|
|
254
257
|
):
|
|
255
258
|
"""Create a new logical context."""
|
|
@@ -290,7 +293,7 @@ def _load_credentials(cli_context_flag: Optional[str] = None):
|
|
|
290
293
|
Uses per-variable precedence - each variable is loaded from the highest priority source.
|
|
291
294
|
Priority order (highest to lowest):
|
|
292
295
|
1. Runtime environment variables (shell/CI)
|
|
293
|
-
2. Local `.codegraphcontext/.env` and `.env` in the current project directory (
|
|
296
|
+
2. Local `.codegraphcontext/.env` and `.env` in the current project directory (per-repo mode only)
|
|
294
297
|
3. Global `~/.codegraphcontext/.env` (user defaults, including `cgc config set`)
|
|
295
298
|
4. Local `mcp.json` env vars (project defaults)
|
|
296
299
|
|
|
@@ -653,10 +656,30 @@ def bundle_export(
|
|
|
653
656
|
finally:
|
|
654
657
|
db_manager.close_driver()
|
|
655
658
|
|
|
659
|
+
def _confirm_bundle_clear(clear: bool, yes: bool) -> bool:
|
|
660
|
+
"""Return True if import may proceed; False if user cancelled."""
|
|
661
|
+
if not clear:
|
|
662
|
+
return True
|
|
663
|
+
console.print("[yellow]⚠️ Warning: This will clear all existing graph data![/yellow]")
|
|
664
|
+
if yes:
|
|
665
|
+
return True
|
|
666
|
+
if not sys.stdin.isatty():
|
|
667
|
+
console.print(
|
|
668
|
+
"[bold red]Refusing to clear graph in non-interactive mode. "
|
|
669
|
+
"Pass --yes / -y to confirm.[/bold red]"
|
|
670
|
+
)
|
|
671
|
+
raise typer.Exit(code=1)
|
|
672
|
+
if not typer.confirm("Are you sure you want to continue?", default=False):
|
|
673
|
+
console.print("[yellow]Import cancelled[/yellow]")
|
|
674
|
+
return False
|
|
675
|
+
return True
|
|
676
|
+
|
|
677
|
+
|
|
656
678
|
@bundle_app.command("import")
|
|
657
679
|
def bundle_import(
|
|
658
680
|
bundle_file: str = typer.Argument(..., help="Path to the .cgc bundle file to import"),
|
|
659
681
|
clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before importing"),
|
|
682
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
|
|
660
683
|
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
|
|
661
684
|
):
|
|
662
685
|
"""
|
|
@@ -667,7 +690,7 @@ def bundle_import(
|
|
|
667
690
|
|
|
668
691
|
Examples:
|
|
669
692
|
cgc bundle import numpy.cgc
|
|
670
|
-
cgc bundle import my-project.cgc --clear
|
|
693
|
+
cgc bundle import my-project.cgc --clear --yes
|
|
671
694
|
"""
|
|
672
695
|
_load_credentials()
|
|
673
696
|
from codegraphcontext.core.cgc_bundle import CGCBundle
|
|
@@ -684,11 +707,8 @@ def bundle_import(
|
|
|
684
707
|
console.print(f"[bold red]Bundle file not found: {bundle_path}[/bold red]")
|
|
685
708
|
raise typer.Exit(code=1)
|
|
686
709
|
|
|
687
|
-
if clear:
|
|
688
|
-
|
|
689
|
-
if not typer.confirm("Are you sure you want to continue?", default=False):
|
|
690
|
-
console.print("[yellow]Import cancelled[/yellow]")
|
|
691
|
-
return
|
|
710
|
+
if not _confirm_bundle_clear(clear, yes):
|
|
711
|
+
return
|
|
692
712
|
|
|
693
713
|
console.print(f"[cyan]Importing bundle from {bundle_path}...[/cyan]")
|
|
694
714
|
|
|
@@ -710,7 +730,8 @@ def bundle_import(
|
|
|
710
730
|
@bundle_app.command("load")
|
|
711
731
|
def bundle_load(
|
|
712
732
|
bundle_name: str = typer.Argument(..., help="Bundle name or path to load (e.g., 'numpy' or 'numpy.cgc')"),
|
|
713
|
-
clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading")
|
|
733
|
+
clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
|
|
734
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
|
|
714
735
|
):
|
|
715
736
|
"""
|
|
716
737
|
Load a pre-indexed bundle (download if needed, then import).
|
|
@@ -731,7 +752,7 @@ def bundle_load(
|
|
|
731
752
|
|
|
732
753
|
# If it's an absolute path or has .cgc extension and exists, use it directly
|
|
733
754
|
if bundle_path.is_absolute() or (bundle_path.suffix == '.cgc' and bundle_path.exists()):
|
|
734
|
-
bundle_import(str(bundle_path), clear=clear)
|
|
755
|
+
bundle_import(str(bundle_path), clear=clear, yes=yes)
|
|
735
756
|
return
|
|
736
757
|
|
|
737
758
|
# Add .cgc extension if not present
|
|
@@ -741,7 +762,7 @@ def bundle_load(
|
|
|
741
762
|
# Check if exists locally
|
|
742
763
|
if bundle_path.exists():
|
|
743
764
|
console.print(f"[dim]Found local bundle: {bundle_path}[/dim]")
|
|
744
|
-
bundle_import(str(bundle_path), clear=clear)
|
|
765
|
+
bundle_import(str(bundle_path), clear=clear, yes=yes)
|
|
745
766
|
return
|
|
746
767
|
|
|
747
768
|
# Try to download from registry
|
|
@@ -759,7 +780,7 @@ def bundle_load(
|
|
|
759
780
|
|
|
760
781
|
if downloaded_path:
|
|
761
782
|
# Import the downloaded bundle
|
|
762
|
-
bundle_import(downloaded_path, clear=clear)
|
|
783
|
+
bundle_import(downloaded_path, clear=clear, yes=yes)
|
|
763
784
|
else:
|
|
764
785
|
console.print(f"[bold red]Failed to download bundle '{name}'[/bold red]")
|
|
765
786
|
raise typer.Exit(code=1)
|
|
@@ -769,6 +790,71 @@ def bundle_load(
|
|
|
769
790
|
console.print("[dim]Use 'cgc registry list' to see available bundles[/dim]")
|
|
770
791
|
raise typer.Exit(code=1)
|
|
771
792
|
|
|
793
|
+
# ============================================================================
|
|
794
|
+
# HOOK COMMAND GROUP - Git integration
|
|
795
|
+
# ============================================================================
|
|
796
|
+
|
|
797
|
+
hook_app = typer.Typer(help="Install Git hooks that keep the CGC graph in sync")
|
|
798
|
+
app.add_typer(hook_app, name="hook")
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@hook_app.command("install")
|
|
802
|
+
def hook_install(
|
|
803
|
+
path: str = typer.Argument(".", help="Path inside the Git repository"),
|
|
804
|
+
force: bool = typer.Option(False, "--force", "-f", help="Replace existing non-CGC hook files"),
|
|
805
|
+
):
|
|
806
|
+
"""
|
|
807
|
+
Install CGC-managed Git hooks in the nearest repository.
|
|
808
|
+
|
|
809
|
+
The installed hooks run `cgc update <repo> --quiet` after commits and
|
|
810
|
+
checkouts. Existing non-CGC hooks are preserved unless --force is used.
|
|
811
|
+
"""
|
|
812
|
+
try:
|
|
813
|
+
status = install_hooks(path, force=force)
|
|
814
|
+
except HookError as exc:
|
|
815
|
+
console.print(f"[bold red]Hook install failed:[/bold red] {exc}")
|
|
816
|
+
raise typer.Exit(code=1)
|
|
817
|
+
|
|
818
|
+
console.print(f"[green]✓[/green] Installed CGC hooks in [bold]{status.repo_root}[/bold]")
|
|
819
|
+
console.print(f"[dim]Git directory: {status.git_dir}[/dim]")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@hook_app.command("uninstall")
|
|
823
|
+
def hook_uninstall(
|
|
824
|
+
path: str = typer.Argument(".", help="Path inside the Git repository"),
|
|
825
|
+
):
|
|
826
|
+
"""Remove CGC-managed Git hooks and local merge-driver config."""
|
|
827
|
+
try:
|
|
828
|
+
status = uninstall_hooks(path)
|
|
829
|
+
except HookError as exc:
|
|
830
|
+
console.print(f"[bold red]Hook uninstall failed:[/bold red] {exc}")
|
|
831
|
+
raise typer.Exit(code=1)
|
|
832
|
+
|
|
833
|
+
console.print(f"[green]✓[/green] Removed CGC hooks from [bold]{status.repo_root}[/bold]")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@hook_app.command("status")
|
|
837
|
+
def hook_status(
|
|
838
|
+
path: str = typer.Argument(".", help="Path inside the Git repository"),
|
|
839
|
+
):
|
|
840
|
+
"""Show whether CGC-managed Git hooks are installed."""
|
|
841
|
+
try:
|
|
842
|
+
status = get_hook_status(path)
|
|
843
|
+
except HookError as exc:
|
|
844
|
+
console.print(f"[bold red]Hook status failed:[/bold red] {exc}")
|
|
845
|
+
raise typer.Exit(code=1)
|
|
846
|
+
|
|
847
|
+
table = Table(title="CGC Git Hook Status", show_header=True, header_style="bold magenta")
|
|
848
|
+
table.add_column("Check", style="cyan")
|
|
849
|
+
table.add_column("Status")
|
|
850
|
+
table.add_row("Repository", str(status.repo_root))
|
|
851
|
+
table.add_row("Git directory", str(status.git_dir))
|
|
852
|
+
table.add_row("Managed hooks", ", ".join(status.installed_hooks) or "none")
|
|
853
|
+
table.add_row("Unmanaged hooks", ", ".join(status.unmanaged_hooks) or "none")
|
|
854
|
+
table.add_row("Merge driver", "installed" if status.has_merge_driver else "missing")
|
|
855
|
+
table.add_row(".gitattributes", "installed" if status.has_gitattributes_entry else "missing")
|
|
856
|
+
console.print(table)
|
|
857
|
+
|
|
772
858
|
# Shortcut commands at root level
|
|
773
859
|
@app.command("export", rich_help_panel="Bundle Shortcuts")
|
|
774
860
|
def export_shortcut(
|
|
@@ -783,10 +869,11 @@ def export_shortcut(
|
|
|
783
869
|
@app.command("load", rich_help_panel="Bundle Shortcuts")
|
|
784
870
|
def load_shortcut(
|
|
785
871
|
bundle_name: str = typer.Argument(..., help="Bundle name or path to load"),
|
|
786
|
-
clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading")
|
|
872
|
+
clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
|
|
873
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
|
|
787
874
|
):
|
|
788
875
|
"""Shortcut for 'cgc bundle load'"""
|
|
789
|
-
bundle_load(bundle_name, clear)
|
|
876
|
+
bundle_load(bundle_name, clear, yes=yes)
|
|
790
877
|
|
|
791
878
|
# ============================================================================
|
|
792
879
|
# REGISTRY COMMAND GROUP - Browse and Download Bundles
|
|
@@ -1173,6 +1260,23 @@ def index(
|
|
|
1173
1260
|
else:
|
|
1174
1261
|
index_helper(path, context)
|
|
1175
1262
|
|
|
1263
|
+
@app.command()
|
|
1264
|
+
def update(
|
|
1265
|
+
path: Optional[str] = typer.Argument(None, help="Path to refresh. Defaults to current directory."),
|
|
1266
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Reduce output when running from automation"),
|
|
1267
|
+
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use (overrides mode/default)"),
|
|
1268
|
+
):
|
|
1269
|
+
"""
|
|
1270
|
+
Refresh an existing repository index.
|
|
1271
|
+
|
|
1272
|
+
This command is intentionally small and hook-friendly; Git hooks installed
|
|
1273
|
+
with `cgc hook install` call it after commits and checkouts.
|
|
1274
|
+
"""
|
|
1275
|
+
_load_credentials()
|
|
1276
|
+
if path is None:
|
|
1277
|
+
path = str(Path.cwd())
|
|
1278
|
+
update_helper(path, context)
|
|
1279
|
+
|
|
1176
1280
|
@app.command()
|
|
1177
1281
|
def clean(
|
|
1178
1282
|
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
|
|
@@ -2708,7 +2812,9 @@ def main(
|
|
|
2708
2812
|
"--database",
|
|
2709
2813
|
"--db",
|
|
2710
2814
|
"-db",
|
|
2711
|
-
help="[Global] Temporarily override database backend (
|
|
2815
|
+
help="[Global] Temporarily override database backend ("
|
|
2816
|
+
+ "|".join(config_manager.SUPPORTED_DATABASES)
|
|
2817
|
+
+ ") for any command"
|
|
2712
2818
|
),
|
|
2713
2819
|
visual: bool = typer.Option(
|
|
2714
2820
|
False,
|