codegraph-engine 2.2.1__py3-none-any.whl → 2.3.0__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.
- codegraph/__init__.py +1 -1
- codegraph/agent_capabilities.py +245 -4
- codegraph/branch_comparison.py +197 -0
- codegraph/change_impact.py +337 -0
- codegraph/cli.py +238 -0
- codegraph/context_freshness.py +349 -0
- codegraph/git_state.py +543 -0
- codegraph/mcp/server.py +69 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/structural_diff.py +540 -0
- codegraph/symbol_history.py +275 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/METADATA +126 -20
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/RECORD +17 -10
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-2.3.0.dist-info}/top_level.txt +0 -0
codegraph/git_state.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Git state tracking and freshness engine.
|
|
2
|
+
|
|
3
|
+
Tracks:
|
|
4
|
+
- current branch
|
|
5
|
+
- current HEAD commit SHA
|
|
6
|
+
- indexed HEAD commit SHA
|
|
7
|
+
- working-tree state (modified, staged, untracked relevant files, deleted)
|
|
8
|
+
- explicit freshness states: CLEAN, DIRTY, STALE, REINDEXING, ERROR
|
|
9
|
+
|
|
10
|
+
Enforces:
|
|
11
|
+
- sub-50ms quick freshness checks
|
|
12
|
+
- incremental synchronization of affected graph state without full re-indexing
|
|
13
|
+
- deterministic evidence-backed status reporting
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sqlite3
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from codegraph.errors import SecurityError
|
|
26
|
+
from codegraph.security import safe_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitFreshnessState(StrEnum):
|
|
30
|
+
CLEAN = "CLEAN" # Current HEAD == indexed HEAD, working tree clean
|
|
31
|
+
DIRTY = "DIRTY" # Current HEAD == indexed HEAD, but uncommitted working tree changes
|
|
32
|
+
STALE = "STALE" # Current HEAD != indexed HEAD (commits advanced or branch switched)
|
|
33
|
+
REINDEXING = "REINDEXING" # Index lock active / reindexing underway
|
|
34
|
+
ERROR = "ERROR" # Not a git repo, git binary missing, or fatal error
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WorkingTreeStatus:
|
|
39
|
+
is_dirty: bool
|
|
40
|
+
modified_files: list[str] = field(default_factory=list)
|
|
41
|
+
staged_files: list[str] = field(default_factory=list)
|
|
42
|
+
untracked_files: list[str] = field(default_factory=list)
|
|
43
|
+
deleted_files: list[str] = field(default_factory=list)
|
|
44
|
+
renamed_files: list[dict[str, str]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"is_dirty": self.is_dirty,
|
|
49
|
+
"modified_files": self.modified_files,
|
|
50
|
+
"staged_files": self.staged_files,
|
|
51
|
+
"untracked_files": self.untracked_files,
|
|
52
|
+
"deleted_files": self.deleted_files,
|
|
53
|
+
"renamed_files": self.renamed_files,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class GitStateReport:
|
|
59
|
+
is_git: bool
|
|
60
|
+
branch: str | None
|
|
61
|
+
current_head: str | None
|
|
62
|
+
indexed_head: str | None
|
|
63
|
+
freshness: GitFreshnessState
|
|
64
|
+
working_tree: WorkingTreeStatus
|
|
65
|
+
reindex_required: bool
|
|
66
|
+
detail: str = ""
|
|
67
|
+
last_indexed_at: int | None = None
|
|
68
|
+
affected_files_count: int = 0
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"is_git": self.is_git,
|
|
73
|
+
"branch": self.branch,
|
|
74
|
+
"current_head": self.current_head,
|
|
75
|
+
"indexed_head": self.indexed_head,
|
|
76
|
+
"freshness": self.freshness.value,
|
|
77
|
+
"working_tree": self.working_tree.as_dict(),
|
|
78
|
+
"reindex_required": self.reindex_required,
|
|
79
|
+
"detail": self.detail,
|
|
80
|
+
"last_indexed_at": self.last_indexed_at,
|
|
81
|
+
"affected_files_count": self.affected_files_count,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_git_cmd(repository: Path, *args: str, timeout: int = 10) -> str | None:
|
|
86
|
+
"""Execute an allowlisted git command safely within repository boundary."""
|
|
87
|
+
for arg in args:
|
|
88
|
+
if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
res = subprocess.run(
|
|
92
|
+
["git", *args],
|
|
93
|
+
cwd=str(repository),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
return res.stdout if res.returncode == 0 else None
|
|
99
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_indexed_commit(con: sqlite3.Connection | None) -> str | None:
|
|
104
|
+
"""Read indexed commit SHA from metadata table if available."""
|
|
105
|
+
if con is None:
|
|
106
|
+
return None
|
|
107
|
+
for table in ("metadata", "codegraph_meta"):
|
|
108
|
+
try:
|
|
109
|
+
row = con.execute(f"SELECT value FROM {table} WHERE key='indexed_commit'").fetchone()
|
|
110
|
+
if row and row[0]:
|
|
111
|
+
return str(row[0]).strip()
|
|
112
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
113
|
+
continue
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_last_indexed_timestamp(con: sqlite3.Connection | None) -> int | None:
|
|
118
|
+
"""Read index_timestamp from metadata table if available."""
|
|
119
|
+
if con is None:
|
|
120
|
+
return None
|
|
121
|
+
for table in ("metadata", "codegraph_meta"):
|
|
122
|
+
try:
|
|
123
|
+
row = con.execute(f"SELECT value FROM {table} WHERE key='index_timestamp'").fetchone()
|
|
124
|
+
if row and row[0]:
|
|
125
|
+
return int(row[0])
|
|
126
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError, ValueError):
|
|
127
|
+
continue
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_reindexing_active(repository: Path) -> bool:
|
|
132
|
+
"""Check if index lock or reindexing marker is currently active."""
|
|
133
|
+
lock_file = repository / ".codegraph" / "indexing.lock"
|
|
134
|
+
legacy_lock = repository / ".codegraph.lock"
|
|
135
|
+
return lock_file.exists() or legacy_lock.exists()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Recognized source extensions for tracking relevant untracked files
|
|
139
|
+
RELEVANT_EXTENSIONS = {".py", ".ts", ".js", ".tsx", ".jsx", ".sql", ".prisma", ".json", ".toml", ".yaml", ".yml"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fast_read_git_head_and_branch(repository: Path) -> tuple[str | None, str | None]:
|
|
143
|
+
"""Pure-Python zero-subprocess resolution of HEAD commit SHA and current branch.
|
|
144
|
+
|
|
145
|
+
Resolves in <0.2ms on standard repositories, falling back to None if non-standard or missing.
|
|
146
|
+
"""
|
|
147
|
+
git_dir = repository / ".git"
|
|
148
|
+
if git_dir.is_file():
|
|
149
|
+
# Git worktree or submodule pointing to another gitdir
|
|
150
|
+
try:
|
|
151
|
+
content = git_dir.read_text(encoding="utf-8", errors="replace").strip()
|
|
152
|
+
if content.startswith("gitdir:"):
|
|
153
|
+
target = Path(content.split(":", 1)[1].strip())
|
|
154
|
+
git_dir = target if target.is_absolute() else (repository / target).resolve()
|
|
155
|
+
except OSError:
|
|
156
|
+
return None, None
|
|
157
|
+
if not git_dir.exists() or not git_dir.is_dir():
|
|
158
|
+
return None, None
|
|
159
|
+
|
|
160
|
+
head_file = git_dir / "HEAD"
|
|
161
|
+
if not head_file.exists():
|
|
162
|
+
return None, None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
head_text = head_file.read_text(encoding="utf-8", errors="replace").strip()
|
|
166
|
+
if head_text.startswith("ref:"):
|
|
167
|
+
ref_path = head_text.split(":", 1)[1].strip()
|
|
168
|
+
branch = ref_path.replace("refs/heads/", "")
|
|
169
|
+
# Check loose ref
|
|
170
|
+
ref_file = git_dir / ref_path
|
|
171
|
+
if ref_file.exists():
|
|
172
|
+
return ref_file.read_text(encoding="utf-8", errors="replace").strip(), branch
|
|
173
|
+
# Check packed-refs
|
|
174
|
+
packed_file = git_dir / "packed-refs"
|
|
175
|
+
if packed_file.exists():
|
|
176
|
+
for line in packed_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
177
|
+
if line.startswith("#") or line.startswith("^"):
|
|
178
|
+
continue
|
|
179
|
+
parts = line.strip().split()
|
|
180
|
+
if len(parts) == 2 and parts[1] == ref_path:
|
|
181
|
+
return parts[0], branch
|
|
182
|
+
return None, branch
|
|
183
|
+
elif len(head_text) == 40 and all(c in "0123456789abcdefABCDEF" for c in head_text):
|
|
184
|
+
return head_text, f"detached:{head_text[:8]}"
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return None, None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_WORKING_TREE_CACHE: dict[str, tuple[float, WorkingTreeStatus]] = {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def clear_working_tree_cache() -> None:
|
|
195
|
+
"""Clear cached working tree statuses."""
|
|
196
|
+
_WORKING_TREE_CACHE.clear()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_working_tree_status(repository: Path, max_age_ms: float = 0.0) -> WorkingTreeStatus:
|
|
200
|
+
"""Inspect working tree status using git status porcelain v1 with optional sub-millisecond caching."""
|
|
201
|
+
repo_key = str(repository.resolve())
|
|
202
|
+
now = time.monotonic()
|
|
203
|
+
if max_age_ms > 0.0 and repo_key in _WORKING_TREE_CACHE:
|
|
204
|
+
cached_time, cached_val = _WORKING_TREE_CACHE[repo_key]
|
|
205
|
+
if (now - cached_time) * 1000.0 < max_age_ms:
|
|
206
|
+
return cached_val
|
|
207
|
+
|
|
208
|
+
raw = _run_git_cmd(repository, "status", "--porcelain=v1", "-uall")
|
|
209
|
+
if raw is None:
|
|
210
|
+
return WorkingTreeStatus(is_dirty=False)
|
|
211
|
+
|
|
212
|
+
modified: list[str] = []
|
|
213
|
+
staged: list[str] = []
|
|
214
|
+
untracked: list[str] = []
|
|
215
|
+
deleted: list[str] = []
|
|
216
|
+
renamed: list[dict[str, str]] = []
|
|
217
|
+
|
|
218
|
+
for line in raw.splitlines():
|
|
219
|
+
if len(line) < 4:
|
|
220
|
+
continue
|
|
221
|
+
index_status = line[0]
|
|
222
|
+
work_status = line[1]
|
|
223
|
+
path_part = line[3:].strip()
|
|
224
|
+
|
|
225
|
+
# Handle renamed format: "R old -> new"
|
|
226
|
+
if " -> " in path_part:
|
|
227
|
+
old_p, new_p = path_part.split(" -> ", 1)
|
|
228
|
+
old_p = old_p.strip().strip('"')
|
|
229
|
+
new_p = new_p.strip().strip('"')
|
|
230
|
+
renamed.append({"old_path": old_p, "new_path": new_p})
|
|
231
|
+
staged.append(new_p)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
clean_path = path_part.strip('"')
|
|
235
|
+
|
|
236
|
+
# Staged changes (index)
|
|
237
|
+
if index_status in ("M", "A", "R"):
|
|
238
|
+
if clean_path not in staged:
|
|
239
|
+
staged.append(clean_path)
|
|
240
|
+
elif index_status == "D":
|
|
241
|
+
if clean_path not in deleted:
|
|
242
|
+
deleted.append(clean_path)
|
|
243
|
+
|
|
244
|
+
# Working tree changes
|
|
245
|
+
if work_status == "M":
|
|
246
|
+
if clean_path not in modified:
|
|
247
|
+
modified.append(clean_path)
|
|
248
|
+
elif work_status == "D":
|
|
249
|
+
if clean_path not in deleted:
|
|
250
|
+
deleted.append(clean_path)
|
|
251
|
+
elif work_status == "?":
|
|
252
|
+
# Only include untracked if relevant source file
|
|
253
|
+
ext = Path(clean_path).suffix.lower()
|
|
254
|
+
if ext in RELEVANT_EXTENSIONS:
|
|
255
|
+
untracked.append(clean_path)
|
|
256
|
+
|
|
257
|
+
is_dirty = bool(modified or staged or untracked or deleted or renamed)
|
|
258
|
+
res = WorkingTreeStatus(
|
|
259
|
+
is_dirty=is_dirty,
|
|
260
|
+
modified_files=sorted(modified),
|
|
261
|
+
staged_files=sorted(staged),
|
|
262
|
+
untracked_files=sorted(untracked),
|
|
263
|
+
deleted_files=sorted(deleted),
|
|
264
|
+
renamed_files=renamed,
|
|
265
|
+
)
|
|
266
|
+
_WORKING_TREE_CACHE[repo_key] = (now, res)
|
|
267
|
+
return res
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_DIFF_COUNT_CACHE: dict[tuple[str, str, str], int] = {}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _get_cached_diff_count(repository: Path, base: str, head: str) -> int:
|
|
274
|
+
"""Cache diff counts between immutable commit SHAs to avoid redundant subprocess invocations."""
|
|
275
|
+
cache_key = (str(repository), base, head)
|
|
276
|
+
if cache_key in _DIFF_COUNT_CACHE:
|
|
277
|
+
return _DIFF_COUNT_CACHE[cache_key]
|
|
278
|
+
diff_out = _run_git_cmd(repository, "diff", "--name-only", base, head)
|
|
279
|
+
count = len(diff_out.splitlines()) if diff_out else 0
|
|
280
|
+
if len(_DIFF_COUNT_CACHE) > 500:
|
|
281
|
+
_DIFF_COUNT_CACHE.clear()
|
|
282
|
+
_DIFF_COUNT_CACHE[cache_key] = count
|
|
283
|
+
return count
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_git_state(
|
|
287
|
+
repository: Path,
|
|
288
|
+
con: sqlite3.Connection | None = None,
|
|
289
|
+
cache_ttl_ms: float = 0.0,
|
|
290
|
+
) -> GitStateReport:
|
|
291
|
+
"""Compute comprehensive Git state and freshness assessment."""
|
|
292
|
+
if is_reindexing_active(repository):
|
|
293
|
+
return GitStateReport(
|
|
294
|
+
is_git=True,
|
|
295
|
+
branch=None,
|
|
296
|
+
current_head=None,
|
|
297
|
+
indexed_head=get_indexed_commit(con),
|
|
298
|
+
freshness=GitFreshnessState.REINDEXING,
|
|
299
|
+
working_tree=WorkingTreeStatus(is_dirty=False),
|
|
300
|
+
reindex_required=False,
|
|
301
|
+
detail="Repository reindexing lock is currently active.",
|
|
302
|
+
last_indexed_at=get_last_indexed_timestamp(con),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# 1. Fast zero-subprocess check for Git directory, HEAD, and branch
|
|
306
|
+
current_head, branch = _fast_read_git_head_and_branch(repository)
|
|
307
|
+
is_git_confirmed = bool(current_head or branch or (repository / ".git").exists())
|
|
308
|
+
|
|
309
|
+
if not is_git_confirmed:
|
|
310
|
+
# Fallback check for non-standard GIT_DIR env or worktree
|
|
311
|
+
git_dir = _run_git_cmd(repository, "rev-parse", "--git-dir")
|
|
312
|
+
if git_dir is None:
|
|
313
|
+
return GitStateReport(
|
|
314
|
+
is_git=False,
|
|
315
|
+
branch=None,
|
|
316
|
+
current_head=None,
|
|
317
|
+
indexed_head=get_indexed_commit(con),
|
|
318
|
+
freshness=GitFreshnessState.ERROR,
|
|
319
|
+
working_tree=WorkingTreeStatus(is_dirty=False),
|
|
320
|
+
reindex_required=False,
|
|
321
|
+
detail="Repository is not a Git repository.",
|
|
322
|
+
last_indexed_at=get_last_indexed_timestamp(con),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# 2. If HEAD or branch was not resolved by fast-path, query Git CLI
|
|
326
|
+
if not current_head:
|
|
327
|
+
head_raw = _run_git_cmd(repository, "rev-parse", "HEAD")
|
|
328
|
+
current_head = head_raw.strip() if head_raw else None
|
|
329
|
+
|
|
330
|
+
if not branch:
|
|
331
|
+
branch_raw = _run_git_cmd(repository, "rev-parse", "--abbrev-ref", "HEAD")
|
|
332
|
+
branch = branch_raw.strip() if branch_raw else None
|
|
333
|
+
if branch == "HEAD":
|
|
334
|
+
branch = f"detached:{current_head[:8]}" if current_head else "detached"
|
|
335
|
+
|
|
336
|
+
indexed_head = get_indexed_commit(con)
|
|
337
|
+
last_indexed_at = get_last_indexed_timestamp(con)
|
|
338
|
+
wt_status = get_working_tree_status(repository, max_age_ms=cache_ttl_ms)
|
|
339
|
+
|
|
340
|
+
# 3. Determine freshness state
|
|
341
|
+
if indexed_head is None:
|
|
342
|
+
# Never indexed
|
|
343
|
+
freshness = GitFreshnessState.STALE
|
|
344
|
+
detail = "Repository has not been indexed yet."
|
|
345
|
+
reindex_required = True
|
|
346
|
+
affected_count = len(wt_status.modified_files) + len(wt_status.staged_files)
|
|
347
|
+
elif current_head != indexed_head:
|
|
348
|
+
# Commit advanced or branch switched
|
|
349
|
+
freshness = GitFreshnessState.STALE
|
|
350
|
+
detail = f"Indexed commit ({indexed_head[:8]}) does not match current HEAD ({current_head[:8] if current_head else 'none'})."
|
|
351
|
+
reindex_required = True
|
|
352
|
+
# Calculate diff files between indexed_head and current_head with caching
|
|
353
|
+
diff_count = _get_cached_diff_count(repository, indexed_head, current_head or "HEAD")
|
|
354
|
+
affected_count = diff_count + len(wt_status.modified_files) + len(wt_status.staged_files)
|
|
355
|
+
elif wt_status.is_dirty:
|
|
356
|
+
freshness = GitFreshnessState.DIRTY
|
|
357
|
+
detail = "Working tree has uncommitted modifications, staged changes, or relevant untracked files."
|
|
358
|
+
reindex_required = True
|
|
359
|
+
affected_count = len(wt_status.modified_files) + len(wt_status.staged_files) + len(wt_status.untracked_files) + len(wt_status.deleted_files)
|
|
360
|
+
else:
|
|
361
|
+
freshness = GitFreshnessState.CLEAN
|
|
362
|
+
detail = f"Index is fresh at HEAD ({current_head[:8] if current_head else 'clean'}) with clean working tree."
|
|
363
|
+
reindex_required = False
|
|
364
|
+
affected_count = 0
|
|
365
|
+
|
|
366
|
+
return GitStateReport(
|
|
367
|
+
is_git=True,
|
|
368
|
+
branch=branch,
|
|
369
|
+
current_head=current_head,
|
|
370
|
+
indexed_head=indexed_head,
|
|
371
|
+
freshness=freshness,
|
|
372
|
+
working_tree=wt_status,
|
|
373
|
+
reindex_required=reindex_required,
|
|
374
|
+
detail=detail,
|
|
375
|
+
last_indexed_at=last_indexed_at,
|
|
376
|
+
affected_files_count=affected_count,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class IncrementalSyncResult:
|
|
382
|
+
status: str
|
|
383
|
+
reindexed_files: list[str]
|
|
384
|
+
deleted_files: list[str]
|
|
385
|
+
new_commit: str | None
|
|
386
|
+
duration_ms: float
|
|
387
|
+
detail: str
|
|
388
|
+
|
|
389
|
+
def as_dict(self) -> dict[str, Any]:
|
|
390
|
+
return {
|
|
391
|
+
"status": self.status,
|
|
392
|
+
"reindexed_files": self.reindexed_files,
|
|
393
|
+
"deleted_files": self.deleted_files,
|
|
394
|
+
"new_commit": self.new_commit,
|
|
395
|
+
"duration_ms": self.duration_ms,
|
|
396
|
+
"detail": self.detail,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def incremental_git_sync(
|
|
401
|
+
repository: Path,
|
|
402
|
+
con: sqlite3.Connection,
|
|
403
|
+
) -> IncrementalSyncResult:
|
|
404
|
+
"""Incrementally synchronize the affected graph state without a full repository rebuild.
|
|
405
|
+
|
|
406
|
+
Identifies changed/added/deleted/renamed files and updates the index database
|
|
407
|
+
selectively, updating indexed_commit when working tree is clean.
|
|
408
|
+
"""
|
|
409
|
+
import time
|
|
410
|
+
|
|
411
|
+
from codegraph.config import Settings
|
|
412
|
+
from codegraph.indexing import Indexer
|
|
413
|
+
|
|
414
|
+
start_time = time.perf_counter()
|
|
415
|
+
state = get_git_state(repository, con=con)
|
|
416
|
+
|
|
417
|
+
if not state.is_git:
|
|
418
|
+
return IncrementalSyncResult(
|
|
419
|
+
status="error",
|
|
420
|
+
reindexed_files=[],
|
|
421
|
+
deleted_files=[],
|
|
422
|
+
new_commit=None,
|
|
423
|
+
duration_ms=0.0,
|
|
424
|
+
detail="Cannot incrementally sync non-git repository.",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
if state.freshness == GitFreshnessState.CLEAN:
|
|
428
|
+
return IncrementalSyncResult(
|
|
429
|
+
status="clean",
|
|
430
|
+
reindexed_files=[],
|
|
431
|
+
deleted_files=[],
|
|
432
|
+
new_commit=state.current_head,
|
|
433
|
+
duration_ms=(time.perf_counter() - start_time) * 1000.0,
|
|
434
|
+
detail="Repository index is already fresh.",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
affected_modified: set[str] = set()
|
|
438
|
+
affected_deleted: set[str] = set()
|
|
439
|
+
|
|
440
|
+
# 1. Collect files changed between indexed_head and current_head
|
|
441
|
+
if state.indexed_head and state.current_head and state.indexed_head != state.current_head:
|
|
442
|
+
diff_raw = _run_git_cmd(repository, "diff", "--name-status", state.indexed_head, state.current_head)
|
|
443
|
+
if diff_raw:
|
|
444
|
+
for line in diff_raw.splitlines():
|
|
445
|
+
parts = line.strip().split(maxsplit=1)
|
|
446
|
+
if len(parts) == 2:
|
|
447
|
+
action, path_str = parts[0], parts[1]
|
|
448
|
+
if action.startswith("D"):
|
|
449
|
+
affected_deleted.add(path_str)
|
|
450
|
+
else:
|
|
451
|
+
affected_modified.add(path_str)
|
|
452
|
+
|
|
453
|
+
# 2. Add working tree changes
|
|
454
|
+
wt = state.working_tree
|
|
455
|
+
for p in wt.modified_files:
|
|
456
|
+
affected_modified.add(p)
|
|
457
|
+
for p in wt.staged_files:
|
|
458
|
+
affected_modified.add(p)
|
|
459
|
+
for p in wt.untracked_files:
|
|
460
|
+
affected_modified.add(p)
|
|
461
|
+
for p in wt.deleted_files:
|
|
462
|
+
affected_deleted.add(p)
|
|
463
|
+
for r in wt.renamed_files:
|
|
464
|
+
affected_deleted.add(r["old_path"])
|
|
465
|
+
affected_modified.add(r["new_path"])
|
|
466
|
+
|
|
467
|
+
# Don't try to index files that were deleted
|
|
468
|
+
affected_modified -= affected_deleted
|
|
469
|
+
|
|
470
|
+
if not affected_modified and not affected_deleted:
|
|
471
|
+
if not wt.is_dirty and state.current_head:
|
|
472
|
+
from codegraph.freshness import save_commit
|
|
473
|
+
save_commit(con, state.current_head, int(time.time()))
|
|
474
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
475
|
+
return IncrementalSyncResult(
|
|
476
|
+
status="clean",
|
|
477
|
+
reindexed_files=[],
|
|
478
|
+
deleted_files=[],
|
|
479
|
+
new_commit=state.current_head,
|
|
480
|
+
duration_ms=elapsed_ms,
|
|
481
|
+
detail="Repository index is already fresh.",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Query known hashes to skip unchanged files
|
|
485
|
+
existing_hashes: dict[str, str] = {}
|
|
486
|
+
try:
|
|
487
|
+
cur = con.execute("SELECT path, hash FROM files")
|
|
488
|
+
for row in cur.fetchall():
|
|
489
|
+
existing_hashes[str(row[0])] = str(row[1])
|
|
490
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
from codegraph.indexing.scanner import LANGUAGES
|
|
494
|
+
|
|
495
|
+
indexer = Indexer(repository, Settings(repository=repository))
|
|
496
|
+
reindexed: list[str] = []
|
|
497
|
+
removed: list[str] = []
|
|
498
|
+
|
|
499
|
+
# 3. Clean up deleted files from graph
|
|
500
|
+
for d_path in affected_deleted:
|
|
501
|
+
indexer._delete_file(con, d_path)
|
|
502
|
+
removed.append(d_path)
|
|
503
|
+
|
|
504
|
+
# 4. Incrementally index modified / added files
|
|
505
|
+
import hashlib
|
|
506
|
+
for m_path in affected_modified:
|
|
507
|
+
abs_p = repository / m_path
|
|
508
|
+
if abs_p.exists() and abs_p.is_file():
|
|
509
|
+
try:
|
|
510
|
+
# safe_path check
|
|
511
|
+
safe_path(repository, m_path)
|
|
512
|
+
ext = abs_p.suffix.lower()
|
|
513
|
+
lang = LANGUAGES.get(ext)
|
|
514
|
+
if not lang:
|
|
515
|
+
continue
|
|
516
|
+
content = abs_p.read_text(encoding="utf-8", errors="replace")
|
|
517
|
+
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
518
|
+
if existing_hashes.get(m_path) == digest:
|
|
519
|
+
# Content is unchanged from current index; skip reindexing
|
|
520
|
+
continue
|
|
521
|
+
indexer._replace_file(con, m_path, lang, content, digest)
|
|
522
|
+
reindexed.append(m_path)
|
|
523
|
+
except (SecurityError, Exception):
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
if reindexed or removed:
|
|
527
|
+
con.commit()
|
|
528
|
+
|
|
529
|
+
# 5. If working tree is clean, update indexed_commit
|
|
530
|
+
if not wt.is_dirty and state.current_head:
|
|
531
|
+
from codegraph.freshness import save_commit
|
|
532
|
+
save_commit(con, state.current_head, int(time.time()))
|
|
533
|
+
|
|
534
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
535
|
+
|
|
536
|
+
return IncrementalSyncResult(
|
|
537
|
+
status="synced",
|
|
538
|
+
reindexed_files=sorted(reindexed),
|
|
539
|
+
deleted_files=sorted(removed),
|
|
540
|
+
new_commit=state.current_head,
|
|
541
|
+
duration_ms=elapsed_ms,
|
|
542
|
+
detail=f"Incrementally updated {len(reindexed)} files and removed {len(removed)} deleted files in {elapsed_ms:.1f}ms.",
|
|
543
|
+
)
|
codegraph/mcp/server.py
CHANGED
|
@@ -956,6 +956,65 @@ def create_server(
|
|
|
956
956
|
with indexer.session() as con:
|
|
957
957
|
return runtime_reconcile_static_runtime(con, indexer.repository, symbol=symbol, route=route, table=table)
|
|
958
958
|
|
|
959
|
+
# -----------------------------------------------------------------------
|
|
960
|
+
# Git Intelligence Tools (5 Unified Capabilities)
|
|
961
|
+
# -----------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
def get_git_state() -> dict[str, Any]:
|
|
964
|
+
"""Track current branch, HEAD, indexed HEAD, working-tree modifications, staged files, and explicit freshness states (CLEAN, DIRTY, STALE, REINDEXING, ERROR). Use when asking if the index is up to date or what uncommitted changes exist. Does not perform a full repository scan when an incremental update is safe."""
|
|
965
|
+
from codegraph.git_state import get_git_state as impl_get_git_state
|
|
966
|
+
with indexer.session() as con:
|
|
967
|
+
return impl_get_git_state(indexer.repository, con=con).as_dict()
|
|
968
|
+
|
|
969
|
+
def compare_git(base: str = "HEAD~1", head: str = "HEAD", branch_comparison: bool = False) -> dict[str, Any]:
|
|
970
|
+
"""Compare two Git revisions, commits, or branches structurally. Detects added/deleted/modified/renamed files, AST symbol changes, relationship diffs, routes, and affected tests. Use when comparing branches or commits structurally. Does not execute repository code."""
|
|
971
|
+
if branch_comparison:
|
|
972
|
+
from codegraph.branch_comparison import compare_branch_architecture
|
|
973
|
+
with indexer.session() as con:
|
|
974
|
+
return compare_branch_architecture(indexer.repository, base_branch=base, head_branch=head, con=con).as_dict()
|
|
975
|
+
else:
|
|
976
|
+
from codegraph.structural_diff import compare_revisions
|
|
977
|
+
with indexer.session() as con:
|
|
978
|
+
return compare_revisions(indexer.repository, base=base, head=head, con=con).as_dict()
|
|
979
|
+
|
|
980
|
+
def get_change_impact(base: str = "HEAD~1", head: str = "HEAD", max_depth: int = 2, max_results: int = 50) -> dict[str, Any]:
|
|
981
|
+
"""Compute deep downstream change impact across callers, callees, framework routes, covering tests, mutating database queries, and monorepo packages. Use when analyzing blast radius and ripple effects of git commits. Does not assume unobserved runtime paths are impossible."""
|
|
982
|
+
from codegraph.change_impact import get_deep_change_impact
|
|
983
|
+
with indexer.session() as con:
|
|
984
|
+
return get_deep_change_impact(indexer.repository, con, base=base, head=head, max_depth=max_depth, max_results=max_results).as_dict()
|
|
985
|
+
|
|
986
|
+
def check_context_freshness(task: str = "", context_packet: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
987
|
+
"""Validate whether a previously compiled context packet or task context is still VALID, PARTIALLY_STALE, or STALE. Use when checking if cached context is valid after edits. Does not invalidate context when only unrelated files change."""
|
|
988
|
+
from codegraph.context_freshness import (
|
|
989
|
+
check_context_freshness as impl_check_context_freshness,
|
|
990
|
+
)
|
|
991
|
+
with indexer.session() as con:
|
|
992
|
+
target_packet = context_packet
|
|
993
|
+
if target_packet is None and task:
|
|
994
|
+
from codegraph.context import get_context as ctx_compile
|
|
995
|
+
target_packet = ctx_compile(con, indexer.repository, task, mode="FAST").model_dump()
|
|
996
|
+
if target_packet is None:
|
|
997
|
+
return {
|
|
998
|
+
"status": "UNKNOWN",
|
|
999
|
+
"reason": "Neither context_packet nor task prompt was provided.",
|
|
1000
|
+
"recommended_action": "RECOMPILE_CONTEXT",
|
|
1001
|
+
}
|
|
1002
|
+
return impl_check_context_freshness(target_packet, indexer.repository, con=con).as_dict()
|
|
1003
|
+
|
|
1004
|
+
def trace_symbol_history(symbol: str, path: str = "", max_commits: int = 15) -> dict[str, Any]:
|
|
1005
|
+
"""Trace deterministic symbol evolution across Git history: introduced, modified, moved across files, renamed, or deleted. Use when discovering when a symbol was added, moved, or renamed. Does not guess ambiguous renames without evidence."""
|
|
1006
|
+
from codegraph.symbol_history import trace_symbol_history as impl_trace_symbol_history
|
|
1007
|
+
with indexer.session() as con:
|
|
1008
|
+
return impl_trace_symbol_history(indexer.repository, symbol=symbol, path=path or None, con=con, max_commits=max_commits).as_dict()
|
|
1009
|
+
|
|
1010
|
+
def detect_semantic_conflicts(base_branch: str = "main", head_branch: str = "HEAD") -> dict[str, Any]:
|
|
1011
|
+
"""Detect semantic and contract conflicts between Git branches (calling deleted symbols, broken call signatures, or missing route handlers). Use when verifying whether branches can be safely merged without semantic breakage. Does not report false conflicts when branches are compatible."""
|
|
1012
|
+
from codegraph.semantic_conflicts import (
|
|
1013
|
+
detect_semantic_conflicts as impl_detect_semantic_conflicts,
|
|
1014
|
+
)
|
|
1015
|
+
with indexer.session() as con:
|
|
1016
|
+
return impl_detect_semantic_conflicts(indexer.repository, base_branch=base_branch, head_branch=head_branch, con=con).as_dict()
|
|
1017
|
+
|
|
959
1018
|
core_tools: dict[str, Any] = {
|
|
960
1019
|
"resolve_symbol": resolve_symbol,
|
|
961
1020
|
"search_symbols": search_symbols,
|
|
@@ -972,9 +1031,19 @@ def create_server(
|
|
|
972
1031
|
"get_git_impact": get_git_impact,
|
|
973
1032
|
}
|
|
974
1033
|
|
|
1034
|
+
git_intelligence_tools: dict[str, Any] = {
|
|
1035
|
+
"get_git_state": get_git_state,
|
|
1036
|
+
"compare_git": compare_git,
|
|
1037
|
+
"get_change_impact": get_change_impact,
|
|
1038
|
+
"check_context_freshness": check_context_freshness,
|
|
1039
|
+
"trace_symbol_history": trace_symbol_history,
|
|
1040
|
+
"detect_semantic_conflicts": detect_semantic_conflicts,
|
|
1041
|
+
}
|
|
1042
|
+
|
|
975
1043
|
# Map of all available tools
|
|
976
1044
|
all_tools: dict[str, Any] = {
|
|
977
1045
|
**core_tools,
|
|
1046
|
+
**git_intelligence_tools,
|
|
978
1047
|
"search_code": search_code,
|
|
979
1048
|
"read_file": read_file,
|
|
980
1049
|
"find_symbol": find_symbol,
|