sql-code-graph 0.3.0__py3-none-any.whl → 1.0.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.
Files changed (44) hide show
  1. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/METADATA +87 -9
  2. sql_code_graph-1.0.0.dist-info/RECORD +63 -0
  3. sqlcg/__init__.py +1 -1
  4. sqlcg/cli/commands/analyze.py +24 -0
  5. sqlcg/cli/commands/db.py +40 -7
  6. sqlcg/cli/commands/gain.py +5 -17
  7. sqlcg/cli/commands/git.py +71 -40
  8. sqlcg/cli/commands/index.py +122 -17
  9. sqlcg/cli/commands/install.py +147 -8
  10. sqlcg/cli/commands/mcp.py +12 -0
  11. sqlcg/cli/commands/reindex.py +170 -0
  12. sqlcg/cli/commands/uninstall.py +94 -39
  13. sqlcg/cli/commands/watch.py +14 -1
  14. sqlcg/cli/main.py +8 -0
  15. sqlcg/core/config.py +185 -2
  16. sqlcg/core/graph_db.py +65 -0
  17. sqlcg/core/kuzu_backend.py +177 -6
  18. sqlcg/core/neo4j_backend.py +38 -0
  19. sqlcg/core/queries.cypher +114 -0
  20. sqlcg/core/queries.py +44 -82
  21. sqlcg/core/schema.cypher +15 -3
  22. sqlcg/core/schema.py +2 -1
  23. sqlcg/indexer/error_classify.py +140 -0
  24. sqlcg/indexer/git_delta.py +121 -0
  25. sqlcg/indexer/indexer.py +952 -125
  26. sqlcg/indexer/pool.py +446 -0
  27. sqlcg/indexer/walker.py +1 -3
  28. sqlcg/indexer/watcher.py +68 -18
  29. sqlcg/lineage/aggregator.py +58 -2
  30. sqlcg/lineage/schema_resolver.py +26 -14
  31. sqlcg/parsers/ansi_parser.py +195 -26
  32. sqlcg/parsers/base.py +609 -59
  33. sqlcg/parsers/bigquery_parser.py +7 -2
  34. sqlcg/parsers/postgres_parser.py +7 -2
  35. sqlcg/parsers/registry.py +7 -2
  36. sqlcg/parsers/snowflake_parser.py +170 -8
  37. sqlcg/parsers/tsql_parser.py +7 -2
  38. sqlcg/server/models.py +297 -4
  39. sqlcg/server/noise_filter.py +167 -0
  40. sqlcg/server/skill.py +256 -0
  41. sqlcg/server/tools.py +934 -178
  42. sql_code_graph-0.3.0.dist-info/RECORD +0 -56
  43. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/WHEEL +0 -0
  44. {sql_code_graph-0.3.0.dist-info → sql_code_graph-1.0.0.dist-info}/entry_points.txt +0 -0
@@ -1,9 +1,18 @@
1
1
  """Index command for scanning and indexing SQL files."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
 
5
6
  import typer
6
7
  from rich.console import Console
8
+ from rich.progress import (
9
+ BarColumn,
10
+ MofNCompleteColumn,
11
+ Progress,
12
+ SpinnerColumn,
13
+ TextColumn,
14
+ TimeRemainingColumn,
15
+ )
7
16
 
8
17
  from sqlcg.core.config import get_backend, get_db_path, get_dialect
9
18
  from sqlcg.indexer.indexer import Indexer
@@ -20,26 +29,53 @@ def index_cmd( # noqa: B008
20
29
  None, "--dbt-manifest", help="Path to dbt manifest"
21
30
  ),
22
31
  timeout_per_file: int = typer.Option( # noqa: B008
23
- 30, "--timeout-per-file", help="Timeout per file in seconds"
32
+ 5, "--timeout-per-file", help="Timeout per file in seconds"
24
33
  ),
25
- no_ddl: bool = typer.Option( # noqa: B008
26
- False, "--no-ddl", help="Skip DDL statements (not yet fully implemented)"
34
+ buffer_pool_size: int = typer.Option( # noqa: B008
35
+ 0,
36
+ "--buffer-pool-size",
37
+ help="KuzuDB buffer pool size in MB (0 = default). "
38
+ "Set to 256-512 on memory-constrained machines.",
39
+ ),
40
+ batch_size: int = typer.Option( # noqa: B008
41
+ 50,
42
+ "--batch-size",
43
+ help=(
44
+ "Files per KuzuDB transaction in the upsert pass. "
45
+ "Default 50 balances commit-overhead reduction (vs. legacy per-file commits) "
46
+ "against per-batch memory cost. Lower values are safer for memory-constrained "
47
+ "machines; higher values give marginal speedup at the cost of larger working sets. "
48
+ "Set to 1 to reproduce legacy per-file commit behaviour."
49
+ ),
27
50
  ),
28
- schema_from_info_schema: str | None = typer.Option( # noqa: B008
29
- None, "--schema-from-info-schema", hidden=True, help="(Not yet implemented)"
51
+ no_ddl: bool = typer.Option( # noqa: B008
52
+ False, "--no-ddl", help="Skip table-node upserts for DDL-only files"
30
53
  ),
31
54
  quiet: bool = typer.Option( # noqa: B008
32
55
  False, "--quiet", "-q", help="Suppress summary console output"
33
56
  ),
57
+ debug: bool = typer.Option( # noqa: B008
58
+ False, "--debug", help="Show detailed log output during indexing"
59
+ ),
60
+ profile: bool = typer.Option( # noqa: B008
61
+ False, "--profile/--no-profile", help="Emit per-stage timing after indexing"
62
+ ),
34
63
  ) -> None:
35
- """Index SQL files in a directory."""
36
- if schema_from_info_schema:
37
- console.print("[red]--schema-from-info-schema is not yet implemented (v2)[/red]")
38
- raise typer.Exit(1)
64
+ """Index SQL files in a directory.
39
65
 
40
- # TODO: wire no_ddl through to the indexer once it supports the parameter
41
- if no_ddl:
42
- console.print("[yellow]Note: --no-ddl is not yet fully implemented[/yellow]")
66
+ Schema aliases (staging schema canonical schema) can be configured in
67
+ .sqlcg.toml under sqlcg.schema_aliases, e.g. da_tmp = "da".
68
+ """
69
+
70
+ import logging
71
+
72
+ level = logging.DEBUG if debug else logging.CRITICAL
73
+ logging.getLogger("sqlcg").setLevel(level)
74
+ logging.getLogger("sqlglot").setLevel(level)
75
+
76
+ # Set buffer pool size via env var if specified
77
+ if buffer_pool_size > 0:
78
+ os.environ["SQLCG_BUFFER_POOL_MB"] = str(buffer_pool_size)
43
79
 
44
80
  # Resolve dialect: 'auto' reads from .sqlcg.toml, otherwise use provided value
45
81
  if dialect == "auto":
@@ -51,8 +87,16 @@ def index_cmd( # noqa: B008
51
87
  with get_backend() as backend:
52
88
  backend.init_schema()
53
89
 
54
- # Create Repo node for this repository
55
- from sqlcg.core.schema import NodeLabel
90
+ # Check schema version must match current build
91
+ from sqlcg.core.schema import SCHEMA_VERSION, NodeLabel
92
+
93
+ stored = backend.get_schema_version()
94
+ if stored != SCHEMA_VERSION:
95
+ console.print(
96
+ f"[red]Database schema is v{stored}; this build requires v{SCHEMA_VERSION}. "
97
+ "Run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-index.[/red]"
98
+ )
99
+ raise typer.Exit(1)
56
100
 
57
101
  abs_path = str(path.resolve())
58
102
  backend.upsert_node(
@@ -66,7 +110,32 @@ def index_cmd( # noqa: B008
66
110
 
67
111
  # Index the repository
68
112
  indexer = Indexer()
69
- summary = indexer.index_repo(path, dialect, backend, dbt_manifest, timeout_per_file)
113
+
114
+ with Progress(
115
+ SpinnerColumn(),
116
+ TextColumn("[progress.description]{task.description}"),
117
+ BarColumn(),
118
+ MofNCompleteColumn(),
119
+ TimeRemainingColumn(),
120
+ console=console,
121
+ redirect_stderr=True,
122
+ ) as progress:
123
+ task = progress.add_task("Parsing", total=None)
124
+
125
+ def _progress_callback(n: int, total_n: int) -> None:
126
+ progress.update(task, completed=n, total=total_n)
127
+
128
+ summary = indexer.index_repo(
129
+ path,
130
+ dialect,
131
+ backend,
132
+ dbt_manifest,
133
+ timeout_per_file,
134
+ progress_callback=_progress_callback,
135
+ no_ddl=no_ddl,
136
+ batch_size=batch_size,
137
+ profile=profile,
138
+ )
70
139
 
71
140
  # Connect files to repo
72
141
  from sqlcg.core.schema import RelType
@@ -85,13 +154,49 @@ def index_cmd( # noqa: B008
85
154
 
86
155
  # Print summary unless --quiet is specified
87
156
  if not quiet:
157
+ quality = summary.get("quality", {})
158
+ err = summary.get("error_summary", {})
159
+ n_full = quality.get("full", 0)
160
+ n_partial = quality.get("table_only", 0)
161
+ n_ddl = quality.get("scripting_fallback", 0)
162
+ n_failed = quality.get("failed", 0)
163
+ n_timeout = err.get("timeout", 0)
164
+
165
+ quality_parts = []
166
+ if n_full:
167
+ quality_parts.append(f"[green]{n_full} with column lineage[/green]")
168
+ if n_partial:
169
+ quality_parts.append(f"[yellow]{n_partial} table-only[/yellow]")
170
+ if n_ddl:
171
+ quality_parts.append(f"{n_ddl} DDL-only")
172
+ if n_timeout:
173
+ quality_parts.append(f"[red]{n_timeout} timed out[/red]")
174
+ if n_failed:
175
+ quality_parts.append(f"[red]{n_failed} failed[/red]")
176
+
88
177
  console.print(
89
178
  f"[green]Indexed[/green] {summary['files_parsed']} files — "
90
- f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges, "
91
- f"{summary['parse_errors']} errors"
179
+ f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges"
92
180
  )
181
+ if quality_parts:
182
+ console.print(" " + " · ".join(quality_parts))
93
183
  if summary.get("lineage_edges_created", 0) == 0:
94
184
  console.print(
95
185
  "[yellow]Warning: 0 lineage edges extracted — column lineage "
96
186
  "unavailable.[/yellow]"
97
187
  )
188
+
189
+ if prof := summary.get("profile"):
190
+ console.print("\n[bold]Profile[/bold]")
191
+ console.print(
192
+ f" pass1 parse: {prof['pass1_parse_s']:.2f}s\n"
193
+ f" pass2 resolve: {prof['pass2_resolve_s']:.2f}s\n"
194
+ f" upsert: {prof['upsert_s']:.2f}s\n"
195
+ f" star expand: {prof['star_expand_s']:.2f}s\n"
196
+ f" total: {prof['total_s']:.2f}s\n"
197
+ f" ms/file: {prof['ms_per_file']:.1f}ms ({prof['files']} files)"
198
+ )
199
+ if prof["slowest_files"]:
200
+ console.print(" [bold]Slowest files[/bold]")
201
+ for file_path, ms in prof["slowest_files"]:
202
+ console.print(f" {ms:8.1f}ms {file_path}")
@@ -3,6 +3,7 @@
3
3
  import json
4
4
  import os
5
5
  import shutil
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import typer
@@ -16,12 +17,34 @@ _SERVER_KEY = "sql-code-graph"
16
17
 
17
18
  def install_cmd(
18
19
  dry_run: bool = typer.Option(False, "--dry-run", help="Print config without writing"),
20
+ scope: str | None = typer.Option( # noqa: B008
21
+ None,
22
+ "--scope",
23
+ help="Install skill location: 'project' (under --repo) or 'global' (~/.claude/skills/).",
24
+ ),
25
+ repo: Path | None = typer.Option( # noqa: B008
26
+ None,
27
+ "--repo",
28
+ help="Repository root for --scope project (default: current directory).",
29
+ ),
19
30
  ) -> None:
20
- """Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json)."""
21
- if shutil.which("uvx"):
22
- entry: dict = {"command": "uvx", "args": ["sql-code-graph", "mcp", "start"]}
31
+ """Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json).
32
+
33
+ Also provisions a Claude skill file (SKILL.md) at the chosen location.
34
+ Pass --scope project or --scope global to specify where the skill is written.
35
+ On a TTY without --scope, an interactive prompt asks for the location.
36
+ On a non-TTY (CI, scripts) without --scope, the command exits with an error.
37
+ """
38
+ # Resolve --scope: explicit flag, TTY prompt, or non-TTY error
39
+ resolved_scope = _resolve_scope(scope)
40
+
41
+ if shutil.which("sqlcg"):
42
+ entry: dict = {"command": "sqlcg", "args": ["mcp", "start"]}
43
+ elif shutil.which("uvx"):
44
+ entry = {"command": "uvx", "args": ["sql-code-graph", "mcp", "start"]}
23
45
  else:
24
- entry = {"command": "sqlcg", "args": ["mcp", "start"]}
46
+ console.print("[red]Error:[/red] Neither 'sqlcg' nor 'uvx' found on PATH.")
47
+ raise typer.Exit(1)
25
48
 
26
49
  settings_path = _SETTINGS_PATH
27
50
  if settings_path.exists():
@@ -38,18 +61,31 @@ def install_cmd(
38
61
 
39
62
  mcp_servers: dict = settings.setdefault("mcpServers", {})
40
63
 
41
- if mcp_servers.get(_SERVER_KEY) == entry:
64
+ existing_entry = mcp_servers.get(_SERVER_KEY)
65
+ if existing_entry == entry:
42
66
  cmd_str = f"{entry['command']} {' '.join(entry['args'])}"
67
+ console.print(f"[green]Already configured:[/green] {_SERVER_KEY} → {cmd_str}")
68
+ # Still provision the skill even when MCP entry already exists
69
+ _provision_skill(resolved_scope, repo, dry_run)
70
+ return
71
+
72
+ # Print upgrade notice if switching from uvx to sqlcg
73
+ if (
74
+ existing_entry
75
+ and existing_entry.get("command") == "uvx"
76
+ and entry.get("command") == "sqlcg"
77
+ ):
43
78
  console.print(
44
- f"[green]Already configured:[/green] {_SERVER_KEY} {cmd_str}"
79
+ "[blue]Updating[/blue] MCP entry from [dim]uvx[/dim] to local "
80
+ "[green]sqlcg[/green] binary (faster startup). Writing…"
45
81
  )
46
- return
47
82
 
48
83
  mcp_servers[_SERVER_KEY] = entry
49
84
 
50
85
  if dry_run is True:
51
86
  console.print("[dim]--dry-run: would write:[/dim]")
52
87
  console.print_json(json.dumps(settings, indent=2))
88
+ _provision_skill(resolved_scope, repo, dry_run)
53
89
  return
54
90
 
55
91
  try:
@@ -65,10 +101,113 @@ def install_cmd(
65
101
  console.print(f"[dim]Written to {settings_path}[/dim]")
66
102
 
67
103
  # Note about cold cache if uvx was chosen
68
- if entry['command'] == 'uvx':
104
+ if entry.get("command") == "uvx":
69
105
  console.print(
70
106
  "[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
71
107
  "Subsequent restarts use cache (~1s)."
72
108
  )
73
109
 
74
110
  console.print("\nRestart Claude Code to pick up the new MCP server.")
111
+
112
+ # Provision the skill file
113
+ _provision_skill(resolved_scope, repo, dry_run)
114
+
115
+
116
+ def _resolve_scope(scope: str | None) -> str:
117
+ """Resolve the install scope from the --scope flag, TTY prompt, or error.
118
+
119
+ - If scope is provided and valid, return it.
120
+ - If scope is None and stdin is a TTY, prompt interactively.
121
+ - If scope is None and stdin is not a TTY, exit with a clear error.
122
+
123
+ Args:
124
+ scope: Value of the --scope CLI option (None if omitted).
125
+
126
+ Returns:
127
+ "project" or "global".
128
+
129
+ Raises:
130
+ typer.Exit: if scope is invalid or cannot be determined.
131
+ """
132
+ valid = {"project", "global"}
133
+
134
+ if scope is not None:
135
+ if scope not in valid:
136
+ console.print(
137
+ f"[red]Error:[/red] --scope must be 'project' or 'global', got: {scope!r}"
138
+ )
139
+ raise typer.Exit(1)
140
+ return scope
141
+
142
+ # scope is None — check if stdin is a TTY
143
+ if sys.stdin.isatty():
144
+ choice = typer.prompt(
145
+ "Install skill location",
146
+ default="project",
147
+ prompt_suffix=" [project/global]: ",
148
+ show_default=False,
149
+ )
150
+ if choice not in valid:
151
+ console.print(
152
+ f"[red]Error:[/red] Invalid choice {choice!r}. Must be 'project' or 'global'."
153
+ )
154
+ raise typer.Exit(1)
155
+ return choice
156
+
157
+ # Non-TTY without --scope — error, do not guess
158
+ console.print(
159
+ "[red]Error:[/red] --scope is required in non-interactive mode. "
160
+ "Pass --scope project or --scope global."
161
+ )
162
+ raise typer.Exit(1)
163
+
164
+
165
+ def _provision_skill(scope: str, repo: Path | None, dry_run: bool) -> None:
166
+ """Write (or describe) the sqlcg SKILL.md file at the chosen location.
167
+
168
+ Uses the .tmp + os.replace atomic write idiom. Idempotent: overwrites
169
+ the sqlcg-owned SKILL.md on each install. If a SKILL.md exists at the
170
+ target path without the sqlcg frontmatter marker ('name: sqlcg'), warns
171
+ and skips to avoid clobbering a foreign file.
172
+
173
+ Args:
174
+ scope: "project" or "global".
175
+ repo: Repo root for project scope (defaults to Path.cwd()).
176
+ dry_run: If True, print a note but write nothing.
177
+ """
178
+ from sqlcg import __version__
179
+ from sqlcg.server.skill import render_skill
180
+
181
+ # Resolve scope root
182
+ if scope == "global":
183
+ scope_root = Path.home()
184
+ else:
185
+ scope_root = repo if repo is not None else Path.cwd()
186
+
187
+ skill_dir = scope_root / ".claude" / "skills" / "sqlcg"
188
+ skill_path = skill_dir / "SKILL.md"
189
+
190
+ if dry_run:
191
+ console.print(f"[dim]--dry-run: would write skill to {skill_path}[/dim]")
192
+ return
193
+
194
+ # Foreign-file guard: if SKILL.md exists but does not carry sqlcg frontmatter, warn+skip
195
+ if skill_path.exists():
196
+ existing = skill_path.read_text()
197
+ if "name: sqlcg" not in existing:
198
+ console.print(
199
+ f"[yellow]Warning:[/yellow] {skill_path} exists but was not created by sqlcg "
200
+ "(missing 'name: sqlcg' in frontmatter). Skipping to avoid overwriting."
201
+ )
202
+ return
203
+
204
+ skill_content = render_skill(__version__)
205
+
206
+ try:
207
+ skill_dir.mkdir(parents=True, exist_ok=True)
208
+ tmp = skill_path.with_suffix(".tmp")
209
+ tmp.write_text(skill_content)
210
+ os.replace(tmp, skill_path)
211
+ console.print(f"[green]Skill written:[/green] {skill_path}")
212
+ except (OSError, TypeError, AttributeError) as exc:
213
+ console.print(f"[yellow]Warning:[/yellow] Failed to write skill file: {exc}")
sqlcg/cli/commands/mcp.py CHANGED
@@ -53,3 +53,15 @@ def mcp_start() -> None:
53
53
  from sqlcg.server.server import main as server_main
54
54
 
55
55
  server_main()
56
+
57
+
58
+ @app.command("best-practices")
59
+ def mcp_best_practices() -> None:
60
+ """Print MCP tool best-practices (the fact/heuristic boundary).
61
+
62
+ Same guidance as the bundled Claude skill — useful for humans or agents
63
+ that have not installed the skill.
64
+ """
65
+ from sqlcg.server.skill import render_body
66
+
67
+ typer.echo(render_body())
@@ -0,0 +1,170 @@
1
+ """Reindex command — incremental resync from a git delta.
2
+
3
+ Two modes:
4
+ sqlcg reindex --from <sha> --to <sha> <root>
5
+ Explicit SHAs: call resync_changed(root, from, to, ...).
6
+ sqlcg reindex <root>
7
+ Standalone "catch up": read last-indexed SHA from graph metadata,
8
+ diff against current HEAD. If no stored SHA, do a full index_repo.
9
+
10
+ Gates on schema version like the watch command (same reset+reinit message).
11
+ """
12
+
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ console = Console()
20
+
21
+
22
+ def reindex_cmd( # noqa: B008
23
+ path: Path = typer.Argument(..., help="Repository root directory to resync"), # noqa: B008
24
+ from_sha: str | None = typer.Option( # noqa: B008
25
+ None, "--from", help="Base git SHA (previously-indexed state)"
26
+ ),
27
+ to_sha: str | None = typer.Option( # noqa: B008
28
+ None, "--to", help="Target git SHA (defaults to HEAD when --from is given)"
29
+ ),
30
+ dialect: str | None = typer.Option( # noqa: B008
31
+ None, "--dialect", "-d", help="SQL dialect (or 'auto' to read from .sqlcg.toml)"
32
+ ),
33
+ quiet: bool = typer.Option( # noqa: B008
34
+ False, "--quiet", "-q", help="Suppress summary output"
35
+ ),
36
+ batch_size: int = typer.Option( # noqa: B008
37
+ 50,
38
+ "--batch-size",
39
+ help="Files per KuzuDB transaction (same default as index command)",
40
+ ),
41
+ timeout_per_file: int = typer.Option( # noqa: B008
42
+ 5,
43
+ "--timeout-per-file",
44
+ help="Per-file parse timeout in seconds",
45
+ ),
46
+ ) -> None:
47
+ """Incrementally resync the graph after a git branch change or pull.
48
+
49
+ When --from and --to are given (e.g. from the post-checkout hook), only the
50
+ files that changed between those two SHAs are re-parsed, plus the cross-file
51
+ pass-2 closure (files that SELECT FROM tables defined in changed files).
52
+
53
+ Without --from/--to, reads the last-indexed SHA from the database and diffs it
54
+ against the current HEAD. If no stored SHA is found, falls back to a full index.
55
+
56
+ Exits with an error if the database schema version does not match the current
57
+ build — run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-init.
58
+ """
59
+ from sqlcg.core.config import get_backend, get_db_path, get_dialect
60
+ from sqlcg.core.schema import SCHEMA_VERSION
61
+ from sqlcg.indexer.indexer import Indexer
62
+
63
+ # Resolve dialect
64
+ if dialect == "auto":
65
+ dialect = get_dialect(path)
66
+
67
+ db_path = get_db_path()
68
+ db_path.parent.mkdir(parents=True, exist_ok=True)
69
+
70
+ with get_backend() as backend:
71
+ backend.init_schema()
72
+
73
+ # Schema-version gate (mirrors watch.py)
74
+ stored_version = backend.get_schema_version()
75
+ if stored_version != SCHEMA_VERSION:
76
+ console.print(
77
+ f"[red]Database schema is v{stored_version}; "
78
+ f"this build requires v{SCHEMA_VERSION}. "
79
+ "Run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' "
80
+ "to re-initialize.[/red]"
81
+ )
82
+ raise typer.Exit(1)
83
+
84
+ indexer = Indexer()
85
+
86
+ # ---- Determine mode -------------------------------------------------------
87
+ if from_sha is not None:
88
+ # Explicit-SHA mode
89
+ effective_to = to_sha or _get_head(path)
90
+ if not quiet:
91
+ console.print(
92
+ f"Resyncing [cyan]{path}[/cyan] [dim]{from_sha[:8]}..{effective_to[:8]}[/dim]"
93
+ )
94
+ summary = indexer.resync_changed(
95
+ path,
96
+ from_sha,
97
+ effective_to,
98
+ backend,
99
+ dialect,
100
+ batch_size=batch_size,
101
+ timeout_per_file=timeout_per_file,
102
+ )
103
+ else:
104
+ # Standalone mode: use stored SHA -> HEAD
105
+ old = backend.get_indexed_sha()
106
+ if old is None:
107
+ if not quiet:
108
+ console.print(
109
+ "[yellow]No stored index SHA found — performing full index.[/yellow]"
110
+ )
111
+ indexer.index_repo(
112
+ path,
113
+ dialect,
114
+ backend,
115
+ batch_size=batch_size,
116
+ timeout_per_file=timeout_per_file,
117
+ )
118
+ if not quiet:
119
+ console.print("[green]Full index complete.[/green]")
120
+ return
121
+ new = _get_head(path)
122
+ if not quiet:
123
+ console.print(f"Resyncing [cyan]{path}[/cyan] [dim]{old[:8]}..{new[:8]}[/dim]")
124
+ summary = indexer.resync_changed(
125
+ path,
126
+ old,
127
+ new,
128
+ backend,
129
+ dialect,
130
+ batch_size=batch_size,
131
+ timeout_per_file=timeout_per_file,
132
+ )
133
+
134
+ # ---- Print summary --------------------------------------------------------
135
+ if not quiet:
136
+ if summary.get("fell_back_to_full"):
137
+ console.print(
138
+ "[yellow]Closure exceeded depth cap — fell back to full index.[/yellow]"
139
+ )
140
+ else:
141
+ console.print(
142
+ f"[green]Resynced[/green] "
143
+ f"+{summary['added']} added, "
144
+ f"~{summary['modified']} modified, "
145
+ f"-{summary['deleted']} deleted, "
146
+ f"{summary['closure_resolved']} closure files re-resolved"
147
+ )
148
+
149
+
150
+ def _get_head(root: Path) -> str:
151
+ """Return the current HEAD SHA for the git repo at root.
152
+
153
+ Raises typer.Exit(1) if git is unavailable or root is not a git repo.
154
+ """
155
+ try:
156
+ result = subprocess.run(
157
+ ["git", "rev-parse", "HEAD"],
158
+ cwd=str(root),
159
+ capture_output=True,
160
+ text=True,
161
+ )
162
+ if result.returncode != 0:
163
+ console.print(
164
+ f"[red]Could not determine HEAD SHA in {root}: {result.stderr.strip()}[/red]"
165
+ )
166
+ raise typer.Exit(1)
167
+ return result.stdout.strip()
168
+ except FileNotFoundError:
169
+ console.print("[red]git is not available — cannot determine HEAD SHA[/red]")
170
+ raise typer.Exit(1) from None