repograph-codeintel 0.1.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.
repograph/cli.py ADDED
@@ -0,0 +1,476 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from . import __version__
13
+ from .config import INDEX_DIR, load_settings, init_project
14
+ from .impact import analyze_impact
15
+ from .indexer import index_repository
16
+ from .query import (
17
+ architecture_summary,
18
+ dependency_graph,
19
+ find_cycles,
20
+ find_references,
21
+ quick_status,
22
+ search as search_repo,
23
+ )
24
+ from .store import Store
25
+
26
+ app = typer.Typer(
27
+ name="repograph",
28
+ help="Evidence-backed codebase intelligence for AI coding agents.",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+
33
+
34
+ def _root(path: Optional[Path]) -> Path:
35
+ return (path or Path.cwd()).expanduser().resolve()
36
+
37
+
38
+ def _open_store(root: Path) -> tuple[Store, object]:
39
+ settings = load_settings(root)
40
+ if not settings.db_path.exists():
41
+ typer.echo(f"no index found at {settings.db_path}", err=True)
42
+ typer.echo("run: repograph index .", err=True)
43
+ raise typer.Exit(1)
44
+ store = Store(settings)
45
+ store.init_schema()
46
+ return store, settings
47
+
48
+
49
+ def _emit(data: dict | list, as_json: bool) -> None:
50
+ if as_json:
51
+ typer.echo(json.dumps(data, indent=2, ensure_ascii=False))
52
+
53
+
54
+ @app.command()
55
+ def init(path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd).")) -> None:
56
+ """Create the local .repograph directory and default configuration."""
57
+ root = _root(path)
58
+ settings = init_project(root)
59
+ typer.echo(f"initialized {settings.state_dir}")
60
+ typer.echo(f"next: repograph index {root}")
61
+
62
+
63
+ @app.command()
64
+ def index(
65
+ path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd)."),
66
+ force: bool = typer.Option(False, "--force", help="Reparse every file."),
67
+ reset: bool = typer.Option(False, "--reset", help="Drop the existing index first."),
68
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
69
+ ) -> None:
70
+ """Discover, parse and index a repository (incremental)."""
71
+ root = _root(path)
72
+ if not root.is_dir():
73
+ typer.echo(f"not a directory: {root}", err=True)
74
+ raise typer.Exit(1)
75
+ settings = init_project(root)
76
+ stats = index_repository(root, force=force, reset=reset, settings=settings)
77
+ data = {
78
+ "root": str(root),
79
+ "files_scanned": stats.files_scanned,
80
+ "added": stats.added,
81
+ "updated": stats.updated,
82
+ "unchanged": stats.unchanged,
83
+ "deleted": stats.deleted,
84
+ "parse_errors": stats.parse_errors,
85
+ "duration_s": stats.duration_s,
86
+ "db_bytes": stats.db_bytes,
87
+ "languages": stats.languages,
88
+ "truncated": stats.truncated,
89
+ "skipped_large": stats.skipped_large,
90
+ "skipped_symlink": stats.skipped_symlink,
91
+ }
92
+ if as_json:
93
+ _emit(data, True)
94
+ return
95
+ typer.echo(f"indexed {stats.files_scanned} files in {stats.duration_s:.2f}s "
96
+ f"({stats.added} added, {stats.updated} updated, {stats.deleted} deleted, "
97
+ f"{stats.parse_errors} parse errors)")
98
+ typer.echo(f"database: {settings.db_path} ({stats.db_bytes / 1024:.0f} KiB)")
99
+ if stats.truncated:
100
+ typer.echo("warning: file limit reached; increase max_files in .repograph/config.json", err=True)
101
+
102
+
103
+ @app.command()
104
+ def status(
105
+ path: Optional[Path] = typer.Argument(None),
106
+ as_json: bool = typer.Option(False, "--json"),
107
+ ) -> None:
108
+ """Show index statistics and whether a re-index is needed."""
109
+ root = _root(path)
110
+ store, settings = _open_store(root)
111
+ with store:
112
+ data = quick_status(store, settings)
113
+ if as_json:
114
+ _emit(data, True)
115
+ return
116
+ typer.echo(f"root: {data.get('root')}")
117
+ typer.echo(f"indexed: {data.get('indexed_at')}")
118
+ typer.echo(f"files: {data['files']} symbols: {data['symbols']} "
119
+ f"internal edges: {data['internal_imports']}")
120
+ typer.echo(f"tests: {data['tests']} parse errors: {data['parse_errors']}")
121
+ typer.echo(f"languages: {', '.join(f'{k}={v}' for k, v in data['languages'].items()) or '-'}")
122
+ typer.echo(f"database: {data['db_bytes'] / 1024:.0f} KiB")
123
+ stale = data["stale"]
124
+ state = "stale" if data["needs_reindex"] else "up to date"
125
+ typer.echo(f"worktree: {state} (+{stale['added']} ~{stale['changed']} -{stale['deleted']})")
126
+
127
+
128
+ @app.command()
129
+ def search(
130
+ query: str = typer.Argument(..., help="Symbol or text to search for."),
131
+ path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
132
+ kind: Optional[str] = typer.Option(None, "--kind", help="Filter symbols by kind."),
133
+ limit: int = typer.Option(20, "--limit", min=1, max=200),
134
+ related: bool = typer.Option(False, "--related", help="Include directly related files."),
135
+ as_json: bool = typer.Option(False, "--json"),
136
+ ) -> None:
137
+ """Search indexed symbols and source text."""
138
+ root = _root(path)
139
+ store, _ = _open_store(root)
140
+ with store:
141
+ if kind:
142
+ from .query import search_symbols
143
+
144
+ data = {"query": query, "symbols": search_symbols(store, query, kind=kind, limit=limit)}
145
+ else:
146
+ data = search_repo(store, root, query, limit=limit, related=related)
147
+ if as_json:
148
+ _emit(data, True)
149
+ return
150
+ typer.echo(f"symbols ({len(data['symbols'])}):")
151
+ for s in data["symbols"]:
152
+ typer.echo(f" {s['kind']:<9} {s['qualified_name']} {s['path']}:{s['lines'][0]}")
153
+ text = data.get("text", [])
154
+ typer.echo(f"text matches ({len(text)}):")
155
+ for t in text:
156
+ typer.echo(f" [{t.get('kind', 'source')}] {t['path']}:{t['line']}: {t['text']}")
157
+ for r in data.get("related_files", []):
158
+ typer.echo(f" related [{r['relation']}] {r['path']} -> {r['target']}")
159
+
160
+
161
+
162
+ @app.command()
163
+ def impact(
164
+ paths: Optional[list[str]] = typer.Argument(None, help="Changed files (repo-relative)."),
165
+ path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
166
+ symbol: Optional[str] = typer.Option(None, "--symbol", help="Analyze a symbol instead of files."),
167
+ diff: bool = typer.Option(False, "--diff", help="Use the current git worktree diff."),
168
+ depth: int = typer.Option(2, "--depth", min=1, max=5),
169
+ tests: bool = typer.Option(True, "--tests/--no-tests"),
170
+ as_json: bool = typer.Option(False, "--json"),
171
+ ) -> None:
172
+ """Estimate what a change can affect, with evidence and confidence."""
173
+ root = _root(path)
174
+ store, _ = _open_store(root)
175
+ with store:
176
+ data = analyze_impact(store, root, paths=list(paths or []), symbol=symbol,
177
+ diff=diff, depth=depth, include_tests=tests)
178
+ if as_json:
179
+ _emit(data, True)
180
+ if "error" in data:
181
+ raise typer.Exit(2)
182
+ return
183
+ if "error" in data:
184
+ typer.echo(f"error: {data['error']}", err=True)
185
+ for c in data.get("candidates", []):
186
+ typer.echo(f" candidate: {c['qualified_name']} ({c['path']}:{c['kind']})", err=True)
187
+ raise typer.Exit(2)
188
+ target = data["target"]
189
+ typer.echo(f"changed files ({len(data['changed_files'])}): {', '.join(data['changed_files']) or '-'}")
190
+ typer.echo(f"changed symbols: {data['summary']['changed_symbols']}")
191
+ for s in target["symbols"][:10]:
192
+ typer.echo(f" {s['kind']:<9} {s['qualified_name']} {s['path']}:{s['lines'][0]}")
193
+ typer.echo(f"\ndirect dependents ({len(data['direct_dependents'])}):")
194
+ for d in data["direct_dependents"][:20]:
195
+ reason = d["reasons"][0]
196
+ typer.echo(f" [{d['confidence']}] {d['path']} ({reason['relation']} at line {reason.get('line')})")
197
+ typer.echo(f"transitive dependents: {len(data['transitive_dependents'])}")
198
+ for t in data["transitive_dependents"][:10]:
199
+ typer.echo(f" [{t['confidence']}] depth {t['depth']} {t['path']}")
200
+ typer.echo(f"\nrelated tests ({len(data['related_tests'])}):")
201
+ for t in data["related_tests"][:15]:
202
+ rules = ", ".join(r["rule"] for r in t["rules"])
203
+ typer.echo(f" [{t['confidence']}] {t['path']} ({rules})")
204
+ if data["affected_routes"]:
205
+ typer.echo("\naffected routes:")
206
+ for r in data["affected_routes"][:15]:
207
+ typer.echo(f" {r['method']} {r['path']} {r['file']}:{r['line']}")
208
+ if data["related_models"]:
209
+ typer.echo("\nrelated models:")
210
+ for m in data["related_models"][:15]:
211
+ typer.echo(f" {m['qualified_name']} {m['path']}:{m['line']}")
212
+ if data["architecture"]["documentation_mentions"]:
213
+ typer.echo("\ndocumentation mentions:")
214
+ for d in data["architecture"]["documentation_mentions"][:5]:
215
+ typer.echo(f" {d['path']}:{d['line']}")
216
+ typer.echo("\nblind spots:")
217
+ for b in data["blind_spots"]:
218
+ typer.echo(f" - {b['kind']} ({b['count']}): {b['message']}")
219
+ typer.echo("\nnote: absence of static evidence is not evidence of safety.")
220
+
221
+
222
+ @app.command()
223
+ def tests(
224
+ paths: Optional[list[str]] = typer.Argument(None, help="Changed files (repo-relative)."),
225
+ path: Optional[Path] = typer.Option(None, "--root", help="Repository root."),
226
+ diff: bool = typer.Option(False, "--diff", help="Use the git diff instead of paths."),
227
+ base: str = typer.Option("HEAD", "--base", help="Git ref to diff against."),
228
+ strict: bool = typer.Option(
229
+ False, "--strict", help="Exit 3 when a test cannot be ruled out or none was selected."
230
+ ),
231
+ command_only: bool = typer.Option(
232
+ False, "--command", help="Print only the pytest command to run."
233
+ ),
234
+ as_json: bool = typer.Option(False, "--json"),
235
+ ) -> None:
236
+ """Test files a change reaches, and the ones that cannot be ruled out (heuristic)."""
237
+ from .test_gate import select
238
+
239
+ if not paths and not diff:
240
+ typer.echo("error: provide paths or --diff", err=True)
241
+ raise typer.Exit(2)
242
+ root = _root(path)
243
+ store, _ = _open_store(root)
244
+ with store:
245
+ data = select(store, root, paths=list(paths or []), diff=diff, base=base, strict=strict)
246
+
247
+ if "error" in data:
248
+ typer.echo(f"error: {data['error']}", err=True)
249
+ raise typer.Exit(2)
250
+ if as_json:
251
+ _emit(data, True)
252
+ raise typer.Exit(data["exit_code"])
253
+ if command_only:
254
+ typer.echo(data["command"])
255
+ raise typer.Exit(data["exit_code"])
256
+
257
+ sel = data["selection"]
258
+ typer.echo(f"changed files ({len(data['changed_files'])}):")
259
+ for p in data["changed_files"][:20]:
260
+ typer.echo(f" {p}")
261
+ for p in data["unindexed_changed_files"]:
262
+ typer.echo(f" {p} (not in the index: run `repograph index .`)")
263
+
264
+ fraction = f"{sel['fraction']:.0%} of {sel['total_tests']}" if sel["fraction"] is not None else "no tests"
265
+ typer.echo(f"\nrun these ({sel['run']}, {fraction}):")
266
+ for t in data["run"]:
267
+ typer.echo(f" [{t['confidence']}] {t['path']} ({', '.join(t['rules'])})")
268
+ if not data["run"]:
269
+ typer.echo(" - nothing: no test reaches this change. That is not evidence of safety.")
270
+
271
+ typer.echo(f"\ncannot rule out ({sel['undetermined']}):")
272
+ for u in data["undetermined"][:20]:
273
+ typer.echo(f" {u['path']} ({u['reason']})")
274
+ if data["reachable_not_selected"]:
275
+ typer.echo(f"\nreachable but not selected ({len(data['reachable_not_selected'])}):")
276
+ for p in data["reachable_not_selected"][:20]:
277
+ typer.echo(f" {p}")
278
+ for c in data["caveats"]:
279
+ typer.echo(f" note: {c}")
280
+
281
+ typer.echo(f"\nnext: {data['command']}")
282
+ raise typer.Exit(data["exit_code"])
283
+
284
+
285
+ @app.command()
286
+ def graph(
287
+ target: Optional[str] = typer.Argument(None, help="Focus file (default: whole repo)."),
288
+ path: Optional[Path] = typer.Option(None, "--root"),
289
+ direction: str = typer.Option("both", "--direction", help="in, out, or both."),
290
+ depth: int = typer.Option(2, "--depth", min=1, max=5),
291
+ fmt: str = typer.Option("json", "--format", help="json or dot."),
292
+ cycles: bool = typer.Option(False, "--cycles", help="Report dependency cycles."),
293
+ as_json: bool = typer.Option(False, "--json"),
294
+ ) -> None:
295
+ """Inspect the file dependency graph or its cycles."""
296
+ root = _root(path)
297
+ store, _ = _open_store(root)
298
+ with store:
299
+ if cycles:
300
+ data = {"cycles": find_cycles(store)}
301
+ else:
302
+ data = dependency_graph(store, path=target, direction=direction, depth=depth)
303
+ if as_json or (fmt == "json" and not typer.get_text_stream("stdout").isatty()):
304
+ _emit(data, True)
305
+ return
306
+ if "cycles" in data:
307
+ if not data["cycles"]:
308
+ typer.echo("no dependency cycles found")
309
+ for i, cyc in enumerate(data["cycles"], 1):
310
+ typer.echo(f"cycle {i}: {' -> '.join(cyc)}")
311
+ return
312
+ if fmt == "dot" and not as_json:
313
+ typer.echo("digraph repograph {")
314
+ typer.echo(' rankdir="LR";')
315
+ for n in data["nodes"]:
316
+ typer.echo(f' "{n}";')
317
+ for e in data["edges"]:
318
+ typer.echo(f' "{e["from"]}" -> "{e["to"]}" [label="L{e["line"]}"];')
319
+ typer.echo("}")
320
+ return
321
+ _emit(data, True)
322
+
323
+
324
+ @app.command()
325
+ def refs(
326
+ name: str = typer.Argument(..., help="Symbol name or qualified name."),
327
+ path: Optional[Path] = typer.Option(None, "--root"),
328
+ limit: int = typer.Option(100, "--limit", min=1, max=500),
329
+ as_json: bool = typer.Option(False, "--json"),
330
+ ) -> None:
331
+ """Find definitions and resolved references for a symbol."""
332
+ root = _root(path)
333
+ store, _ = _open_store(root)
334
+ with store:
335
+ data = find_references(store, name, limit=limit)
336
+ if as_json:
337
+ _emit(data, True)
338
+ return
339
+ for d in data["definitions"]:
340
+ typer.echo(f"def {d['kind']:<9} {d['qualified_name']} {d['path']}:{d['lines'][0]}")
341
+ for r in data["references"]:
342
+ typer.echo(f"ref [{r['confidence']}] {r['path']}:{r['line']} -> {r['target']}")
343
+ if not data["definitions"]:
344
+ typer.echo("symbol not found in index", err=True)
345
+
346
+
347
+ @app.command()
348
+ def arch(
349
+ path: Optional[Path] = typer.Option(None, "--root"),
350
+ as_json: bool = typer.Option(False, "--json"),
351
+ ) -> None:
352
+ """Summarize repository architecture from the index."""
353
+ root = _root(path)
354
+ store, _ = _open_store(root)
355
+ with store:
356
+ data = architecture_summary(store, root)
357
+ if as_json:
358
+ _emit(data, True)
359
+ return
360
+ typer.echo(f"files: {data['files']} symbols: {data['symbols']} internal edges: {data['internal_edges']}")
361
+ typer.echo(f"unresolved imports: {data['unresolved_imports']} dynamic imports: {data['dynamic_imports']}"
362
+ " (a resolved-edge count is not a census)")
363
+ typer.echo(f"parse errors: {len(data['parse_errors'])}")
364
+ typer.echo("languages: " + ", ".join(f"{k}={v}" for k, v in data["languages"].items()))
365
+ typer.echo("layers: " + ", ".join(f"{k}={v}" for k, v in data["layers"].items()))
366
+ typer.echo("top directories:")
367
+ for name, d in list(data["top_directories"].items())[:10]:
368
+ typer.echo(f" {name:<20} files={d['files']:<5} in={d['inbound']:<5} out={d['outbound']}")
369
+ typer.echo("dependency hubs:")
370
+ for hub in data["dependency_hubs"][:5]:
371
+ typer.echo(f" in={hub['inbound']:<4} {hub['path']}")
372
+ typer.echo(f"entry points: {', '.join(data['entry_points'][:8]) or '-'}")
373
+ typer.echo(f"routes: {len(data['http_routes'])} models: {len(data['database_models'])}")
374
+
375
+
376
+ @app.command()
377
+ def setup(
378
+ path: Optional[Path] = typer.Argument(None, help="Repository root (default: cwd)."),
379
+ client: Optional[list[str]] = typer.Option(
380
+ None, "--client", "-c", help="claude, cursor, codex. Repeatable. Default: detected."
381
+ ),
382
+ yes: bool = typer.Option(False, "--yes", "-y", help="Apply without asking."),
383
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show changes and write nothing."),
384
+ no_skill: bool = typer.Option(
385
+ False, "--no-skill", help="Register the server but skip agent instructions."
386
+ ),
387
+ ) -> None:
388
+ """Register the MCP server with your agent and install its usage instructions."""
389
+ from pathlib import Path as _Path
390
+
391
+ from .agent_setup import apply, detect_clients, plan
392
+
393
+ root = _root(path)
394
+ clients = client or detect_clients(root, _Path.home())
395
+ try:
396
+ changes = plan(root, clients, include_skill=not no_skill)
397
+ except ValueError as exc:
398
+ typer.echo(f"error: {exc}", err=True)
399
+ raise typer.Exit(1) from exc
400
+
401
+ typer.echo(f"clients: {', '.join(clients)}")
402
+ if "codex" not in clients and (_Path.home() / ".codex").is_dir():
403
+ typer.echo("note: Codex detected; add --client codex to register it in ~/.codex/config.toml")
404
+ for change in changes:
405
+ typer.echo(f" {change.kind:<9} {change.path} ({change.note})")
406
+
407
+ pending = [c for c in changes if c.kind != "unchanged"]
408
+ if not pending:
409
+ typer.echo("already configured; nothing to write.")
410
+ else:
411
+ for change in pending:
412
+ typer.echo("")
413
+ typer.echo(change.diff().rstrip("\n"))
414
+
415
+ if dry_run:
416
+ typer.echo("\ndry run: nothing written.")
417
+ raise typer.Exit(0)
418
+
419
+ if pending:
420
+ if not yes:
421
+ typer.confirm("Apply these changes?", abort=True)
422
+ try:
423
+ written = apply(changes)
424
+ except (OSError, ValueError) as exc:
425
+ typer.echo(f"error: {exc}", err=True)
426
+ raise typer.Exit(1) from exc
427
+ typer.echo("")
428
+ for item in written:
429
+ typer.echo(f"wrote {item}")
430
+
431
+ settings = load_settings(root)
432
+ if not settings.db_path.exists():
433
+ typer.echo(f"\nnext: repograph index {root}")
434
+ typer.echo("next: restart your agent, then ask \"what breaks if I change <symbol>?\"")
435
+
436
+
437
+ @app.command()
438
+ def serve(
439
+ path: Optional[Path] = typer.Option(None, "--root", help="Repository root to expose."),
440
+ http: bool = typer.Option(False, "--http", help="Use Streamable HTTP instead of stdio."),
441
+ host: str = typer.Option("127.0.0.1", "--host"),
442
+ port: int = typer.Option(8765, "--port"),
443
+ ) -> None:
444
+ """Run the MCP server (stdio by default)."""
445
+ from .mcp_server import build_server
446
+
447
+ root = _root(path)
448
+ try:
449
+ server = build_server(root)
450
+ except FileNotFoundError as exc:
451
+ typer.echo(str(exc), err=True)
452
+ raise typer.Exit(1) from exc
453
+ if http:
454
+ server.settings.host = host
455
+ server.settings.port = port
456
+ if host not in ("127.0.0.1", "localhost", "::1"):
457
+ typer.echo("warning: the MCP server has no authentication; do not expose it to untrusted networks",
458
+ err=True)
459
+ typer.echo(f"MCP streamable-http on http://{host}:{port}/mcp", err=True)
460
+ server.run("streamable-http")
461
+ else:
462
+ server.run("stdio")
463
+
464
+
465
+ @app.command()
466
+ def version() -> None:
467
+ """Print the version."""
468
+ typer.echo(__version__)
469
+
470
+
471
+ def main() -> None:
472
+ app()
473
+
474
+
475
+ if __name__ == "__main__":
476
+ main()
repograph/config.py ADDED
@@ -0,0 +1,145 @@
1
+ """Project configuration.
2
+
3
+ Config is intentionally a small JSON file (no extra dependency, no code
4
+ execution). Anything not set here falls back to conservative defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ INDEX_DIR = ".repograph"
14
+ DB_NAME = "index.db"
15
+ CONFIG_NAME = "config.json"
16
+
17
+ DEFAULT_EXCLUDES = [
18
+ ".git",
19
+ ".hg",
20
+ ".svn",
21
+ ".repograph",
22
+ ".venv",
23
+ "venv",
24
+ "node_modules",
25
+ "__pycache__",
26
+ ".mypy_cache",
27
+ ".pytest_cache",
28
+ ".ruff_cache",
29
+ ".tox",
30
+ ".nox",
31
+ "dist",
32
+ "build",
33
+ "target",
34
+ "vendor",
35
+ ".next",
36
+ ".nuxt",
37
+ ".turbo",
38
+ ".cache",
39
+ "coverage",
40
+ "htmlcov",
41
+ "site-packages",
42
+ "*.min.js",
43
+ "*.min.css",
44
+ "*.map",
45
+ "*.lock",
46
+ "package-lock.json",
47
+ "pnpm-lock.yaml",
48
+ "yarn.lock",
49
+ "poetry.lock",
50
+ "uv.lock",
51
+ ".env",
52
+ ".env.*",
53
+ "*.pem",
54
+ "*.key",
55
+ ]
56
+
57
+ # Files that are likely to contain credentials are never indexed or searched.
58
+ SECRET_GLOBS = [".env", ".env.*", "*.pem", "*.key", "*.p12", "id_rsa", "id_ed25519", ".npmrc", ".pypirc"]
59
+
60
+
61
+ @dataclass
62
+ class Settings:
63
+ root: Path
64
+ exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDES))
65
+ max_file_bytes: int = 1_500_000
66
+ max_files: int = 200_000
67
+ follow_symlinks: bool = False
68
+ index_dir: Path | None = None
69
+
70
+ @property
71
+ def db_path(self) -> Path:
72
+ return (self.index_dir or (self.root / INDEX_DIR)) / DB_NAME
73
+
74
+ @property
75
+ def state_dir(self) -> Path:
76
+ return self.index_dir or (self.root / INDEX_DIR)
77
+
78
+ def is_excluded(self, rel: str) -> bool:
79
+ from fnmatch import fnmatchcase
80
+
81
+ parts = rel.split("/")
82
+ for pat in self.exclude + SECRET_GLOBS:
83
+ if fnmatchcase(rel, pat) or any(fnmatchcase(p, pat) for p in parts):
84
+ return True
85
+ return False
86
+
87
+
88
+ def load_settings(root: str | Path) -> Settings:
89
+ root = Path(root).expanduser().resolve()
90
+ s = Settings(root=root)
91
+ cfg = root / INDEX_DIR / CONFIG_NAME
92
+ if cfg.is_file():
93
+ try:
94
+ data = json.loads(cfg.read_text(encoding="utf-8"))
95
+ except (OSError, ValueError):
96
+ data = {}
97
+ if isinstance(data.get("exclude"), list):
98
+ # User excludes extend, never replace, the safety defaults.
99
+ s.exclude = list(dict.fromkeys(DEFAULT_EXCLUDES + [str(x) for x in data["exclude"]]))
100
+ for key in ("max_file_bytes", "max_files"):
101
+ if isinstance(data.get(key), int) and data[key] > 0:
102
+ setattr(s, key, data[key])
103
+ s.follow_symlinks = bool(data.get("follow_symlinks", False))
104
+ return s
105
+
106
+
107
+ def init_project(root: str | Path) -> Settings:
108
+ root = Path(root).expanduser().resolve()
109
+ s = load_settings(root)
110
+ s.state_dir.mkdir(parents=True, exist_ok=True)
111
+ cfg = s.state_dir / CONFIG_NAME
112
+ if not cfg.exists():
113
+ cfg.write_text(
114
+ json.dumps(
115
+ {
116
+ "exclude": [],
117
+ "max_file_bytes": s.max_file_bytes,
118
+ "max_files": s.max_files,
119
+ "follow_symlinks": False,
120
+ },
121
+ indent=2,
122
+ )
123
+ + "\n",
124
+ encoding="utf-8",
125
+ )
126
+ return s
127
+
128
+
129
+ def safe_path(root: Path, user_path: str | Path, *, must_exist: bool = True) -> Path:
130
+ """Resolve a user-supplied path, refusing anything outside the repository.
131
+
132
+ Guards against ``..`` traversal, absolute paths elsewhere, and symlinks
133
+ that point outside the index root.
134
+ """
135
+ root = root.resolve()
136
+ p = Path(user_path).expanduser()
137
+ candidate = p if p.is_absolute() else root / p
138
+ if must_exist and not candidate.exists():
139
+ raise FileNotFoundError(f"path not found: {user_path}")
140
+ resolved = candidate.resolve()
141
+ try:
142
+ resolved.relative_to(root)
143
+ except ValueError as exc:
144
+ raise PermissionError(f"path escapes repository root: {user_path}") from exc
145
+ return resolved