Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ on:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}

steps:
- name: Check out code
Expand All @@ -25,6 +29,7 @@ jobs:
pip install -e .[dev]

- name: Run end-to-end tests
shell: bash
run: |
chmod +x tests/run_tests.sh
./tests/run_tests.sh e2e
2 changes: 1 addition & 1 deletion CGC_REPORT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CGC Report

_Generated: 2026-08-13 18:25 UTC_
_Generated: 2026-08-18 17:20 UTC_


## God Nodes — Highest Fan-In
Expand Down
134 changes: 119 additions & 15 deletions src/codegraphcontext/core/cgc_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,75 @@ def _validate_cypher_identifier(value: Any, kind: str) -> str:
return value


# Repo-scoped export rewrites the repository root to "." and every path under
# it to "./rel" (#1509). Import must invert that rewrite against a per-bundle
# destination root; otherwise every second bundle collides on path="." and
# File keys like ./README.md.
_BUNDLE_RELATIVE_ROOTS = {".", "./", ".\\"}
_NON_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")


def _sanitize_bundle_repo_slug(repo: Optional[str]) -> str:
"""Turn metadata['repo'] into a single path segment (pallets/flask → pallets__flask)."""
if not repo or not isinstance(repo, str):
return "unknown"
slug = repo.strip().replace("\\", "/").replace("..", "").strip("/")
slug = _NON_SLUG_RE.sub("__", slug).strip("_")
return slug or "unknown"


def _default_bundle_install_root(
metadata: Optional[Dict[str, Any]] = None,
destination_root: Optional[Path] = None,
) -> str:
"""Absolute posix dest root for one imported bundle: ~/.codegraphcontext/bundles/<slug>."""
slug = _sanitize_bundle_repo_slug((metadata or {}).get("repo"))
base = Path(destination_root) if destination_root is not None else (
Path.home() / ".codegraphcontext" / "bundles"
)
return (base / slug).resolve().as_posix()


def _is_bundle_relative_path(val: Any) -> bool:
"""True for portable export paths: '.', './', '.\\', './foo', '.\\foo'."""
if not isinstance(val, str) or not val:
return False
if val in _BUNDLE_RELATIVE_ROOTS:
return True
return val.startswith("./") or val.startswith(".\\")


def _is_identifying_repo_path(repo_path: Optional[str]) -> bool:
"""False for None, empty, and bundle-relative paths that are not unique."""
if not repo_path:
return False
return not _is_bundle_relative_path(repo_path)


def _rebase_bundle_path(val: str, dest_root: str) -> str:
"""Map '.' / './rel' onto dest_root. Non-relative strings are returned unchanged."""
if not _is_bundle_relative_path(val):
return val
if val in _BUNDLE_RELATIVE_ROOTS:
return dest_root
rel = val[2:].replace("\\", "/").lstrip("/")
if not rel:
return dest_root
return dest_root + "/" + rel


def _rebase_property_map(
props: Optional[Dict[str, Any]], dest_root: Optional[str]
) -> Dict[str, Any]:
"""Rewrite every bundle-relative string property in *props* in place."""
if not props or not dest_root:
return props or {}
for key, val in list(props.items()):
if isinstance(val, str) and _is_bundle_relative_path(val):
props[key] = _rebase_bundle_path(val, dest_root)
return props


class CGCBundle:
"""Handles creation and loading of .cgc bundle files."""

Expand Down Expand Up @@ -259,6 +328,7 @@ def import_from_bundle(
password: Optional[str] = None,
verify_key: Optional[str] = None,
graph_name: str = None,
destination_root: Optional[Path] = None,
) -> Tuple[bool, str]:
self._active_graph = graph_name
"""
Expand All @@ -268,6 +338,11 @@ def import_from_bundle(
bundle_path: Path to the .cgc file
clear_existing: Whether to clear existing graph data first
readonly: If True, mount as read-only (future feature)
password: Optional password to decrypt an encrypted bundle
verify_key: Optional HMAC key to verify a signed bundle
destination_root: Optional base directory used to re-absolutize
repo-scoped relative paths ('.' / './…'). Defaults to
~/.codegraphcontext/bundles/<sanitized repo>.

Returns:
Tuple[bool, str]: (success, message)
Expand Down Expand Up @@ -304,10 +379,15 @@ def import_from_bundle(

info_logger(f"Loading bundle: {metadata.get('repo', 'unknown')}")
info_logger(f"Bundle version: {metadata.get('cgc_version', 'unknown')}")

dest_root = _default_bundle_install_root(metadata, destination_root)

# Step 4: Handle existing data
repo_name = metadata.get('repo', 'unknown')
repo_path = metadata.get('repo_path')
if _is_bundle_relative_path(repo_path):
repo_path = _rebase_bundle_path(repo_path, dest_root)
metadata["repo_path"] = repo_path

if clear_existing:
# User explicitly wants to clear - remove everything
Expand All @@ -330,11 +410,11 @@ def import_from_bundle(

# Step 6: Import nodes
info_logger("Importing nodes...")
node_count = self._import_nodes(payload_path / "nodes.jsonl")
node_count = self._import_nodes(payload_path / "nodes.jsonl", dest_root)

# Step 7: Import edges
info_logger("Importing edges...")
edge_count = self._import_edges(payload_path / "edges.jsonl")
edge_count = self._import_edges(payload_path / "edges.jsonl", dest_root)

success_msg = f"✅ Successfully imported {bundle_path.name}\n"
success_msg += f" Repository: {metadata.get('repo', 'unknown')}\n"
Expand Down Expand Up @@ -1310,8 +1390,10 @@ def _check_existing_repository(self, repo_name: str, repo_path: Optional[str]) -
if result.single():
return True

# If repo_path is provided, also check by path
if repo_path:
# Path '.' / './…' is the portable export of every repo-scoped
# bundle, not a unique identity — matching on it refuses flask
# then requests as duplicates (#1509).
if _is_identifying_repo_path(repo_path):
result = session.run(
"MATCH (r:Repository {path: $path}) RETURN r LIMIT 1",
path=repo_path
Expand All @@ -1338,6 +1420,13 @@ def _delete_repository(self, repo_identifier: str):
return

repo_path = record['path']
if not _is_identifying_repo_path(repo_path):
warning_logger(
f"Refusing to delete repository '{repo_identifier}': "
f"path {repo_path!r} is not identifying and would match "
"every repo-scoped bundle import"
)
return

repo_prefix = repo_path if repo_path.endswith("/") else f"{repo_path}/"
# Delete all nodes that belong to this repository
Expand Down Expand Up @@ -1387,7 +1476,7 @@ def _import_schema(self, schema_file: Path):
# This is a placeholder for future enhancement
debug_log("Schema import not yet implemented - relying on application schema")

def _import_nodes(self, nodes_file: Path) -> int:
def _import_nodes(self, nodes_file: Path, dest_root: Optional[str] = None) -> int:
"""Import nodes from JSONL file."""
count = 0
batch_size = 1000
Expand Down Expand Up @@ -1416,12 +1505,12 @@ def _import_nodes(self, nodes_file: Path) -> int:
batch.append((labels, node_data, old_id))

if len(batch) >= batch_size:
count += self._import_node_batch(session, batch, id_mapping)
count += self._import_node_batch(session, batch, id_mapping, dest_root)
batch = []

# Import remaining nodes
if batch:
count += self._import_node_batch(session, batch, id_mapping)
count += self._import_node_batch(session, batch, id_mapping, dest_root)

# Store ID mapping for edge import
self._id_mapping = id_mapping
Expand Down Expand Up @@ -1469,7 +1558,13 @@ def _import_nodes(self, nodes_file: Path) -> int:
'Object': ['name', 'path', 'line_number'],
}

def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> int:
def _import_node_batch(
self,
session,
batch: List[Tuple],
id_mapping: Dict,
dest_root: Optional[str] = None,
) -> int:
"""Import a batch of nodes."""
id_function = self._get_id_function()

Expand All @@ -1483,9 +1578,14 @@ def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> i
label_str = ':'.join(labels)
primary_label = labels[0]

if dest_root:
_rebase_property_map(properties, dest_root)

pk_field = self._PK_MAP.get(primary_label)
if pk_field == 'uid' and 'uid' not in properties:
parts = self._UID_PARTS.get(primary_label, [])
parts = self._UID_PARTS.get(primary_label, []) if pk_field == 'uid' else []
# After rebasing './…' paths, uid must include the dest root so
# Function/Class MERGE keys do not collide across bundles (#1509).
if pk_field == 'uid' and parts and (dest_root or 'uid' not in properties):
properties['uid'] = ''.join(str(properties.get(p, '')) for p in parts)

if pk_field and pk_field in properties:
Expand All @@ -1511,7 +1611,7 @@ def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> i

return len(batch)

def _import_edges(self, edges_file: Path) -> int:
def _import_edges(self, edges_file: Path, dest_root: Optional[str] = None) -> int:
"""Import edges from JSONL file."""
count = 0
batch_size = 1000
Expand All @@ -1524,16 +1624,18 @@ def _import_edges(self, edges_file: Path) -> int:
batch.append(edge_data)

if len(batch) >= batch_size:
count += self._import_edge_batch(session, batch)
count += self._import_edge_batch(session, batch, dest_root)
batch = []

# Import remaining edges
if batch:
count += self._import_edge_batch(session, batch)
count += self._import_edge_batch(session, batch, dest_root)

return count

def _import_edge_batch(self, session, batch: List[Dict]) -> int:
def _import_edge_batch(
self, session, batch: List[Dict], dest_root: Optional[str] = None
) -> int:
"""Import a batch of edges."""
id_mapping = getattr(self, '_id_mapping', {})
# Detect database backend to use appropriate ID function
Expand All @@ -1548,7 +1650,9 @@ def _import_edge_batch(self, session, batch: List[Dict]) -> int:
if isinstance(old_to, dict):
old_to = (old_to.get('table', 0), old_to.get('offset', 0))
rel_type = _validate_cypher_identifier(edge.get('type'), "relationship type")
properties = edge.get('properties', {})
properties = edge.get('properties', {}) or {}
if dest_root:
properties = _rebase_property_map(properties, dest_root)

# Map old IDs to new IDs
new_from = id_mapping.get(old_from)
Expand Down
8 changes: 8 additions & 0 deletions src/codegraphcontext/core/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ def find_active_job_by_path(self, path: str) -> Optional[JobInfo]:

return None

def list_active_jobs(self) -> List[JobInfo]:
"""Return all jobs that are still PENDING or RUNNING (#1536)."""
with self.lock:
return [
job for job in self.jobs.values()
if job.status in (JobStatus.PENDING, JobStatus.RUNNING)
]

def cleanup_old_jobs(self, max_age_hours: int = 24):
"""Removes old jobs from memory to prevent memory leaks.

Expand Down
32 changes: 31 additions & 1 deletion src/codegraphcontext/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,25 @@ def _start_watcher_if(self, should_start: bool) -> None:
except Exception as exc:
warning_logger(f"Failed to start new code watcher after context switch: {exc}")

def _active_jobs_block_context_switch(self) -> Optional[Dict[str, Any]]:
"""Refuse context switches while indexing jobs still hold the current DB (#1536)."""
active = self.job_manager.list_active_jobs()
if not active:
return None
job_ids = [j.job_id for j in active]
details = [
{"job_id": j.job_id, "status": j.status.value, "path": j.path}
for j in active
]
return {
"error": (
"Cannot switch context while indexing jobs are still active "
f"({', '.join(job_ids)}). Wait for them to finish "
"(check_job_status / list_jobs), then retry switch_context."
),
"active_jobs": details,
}

def switch_context_tool(self, **args) -> Dict[str, Any]:
raw_path = args.get("context_path", "")
should_save = args.get("save", True)
Expand All @@ -566,6 +585,9 @@ def switch_context_tool(self, **args) -> Dict[str, Any]:

# --- Special case: switch back to the global context ---
if raw_path == "global":
blocked = self._active_jobs_block_context_switch()
if blocked:
return blocked
try:
watcher_was_running = self._stop_current_watcher()
try:
Expand Down Expand Up @@ -622,6 +644,10 @@ def switch_context_tool(self, **args) -> Dict[str, Any]:
if not cgc_dir.exists() or not cgc_dir.is_dir():
return {"error": f"No .codegraphcontext directory found at {cgc_dir}."}

blocked = self._active_jobs_block_context_switch()
if blocked:
return blocked

local_db = "falkordb"
local_yaml = cgc_dir / "config.yaml"
if local_yaml.exists():
Expand Down Expand Up @@ -692,10 +718,14 @@ async def handle_tool_call(self, tool_name: str, args: Dict[str, Any]) -> Dict[s
# different meanings (path = file path, repo_path = repo filter).
if isinstance(args, dict):
args = dict(args)
# path_means_file: for these tools `path` is a file disambiguator and
# `repo_path` is a separate repo-prefix filter. Aliasing either way
# collapses the two meanings (#1532: path → repo_path made
# calculate_cyclomatic_complexity return null).
path_means_file = tool_name in ("calculate_cyclomatic_complexity",)
if "repo_path" in args and "path" not in args and not path_means_file:
args["path"] = args["repo_path"]
elif "path" in args and "repo_path" not in args:
elif "path" in args and "repo_path" not in args and not path_means_file:
args["repo_path"] = args["path"]

tool_map: Dict[str, Coroutine] = {
Expand Down
5 changes: 4 additions & 1 deletion src/codegraphcontext/tool_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,10 @@

"switch_context": {
"name": "switch_context",
"description": "Switch active graph context.",
"description": (
"Switch active graph context. Refuses while any indexing job is "
"PENDING or RUNNING — wait for check_job_status / list_jobs, then retry."
),
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Loading
Loading