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/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,