coderag-ai 0.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.
Files changed (73) hide show
  1. coderag/__init__.py +12 -0
  2. coderag/analyzers/__init__.py +0 -0
  3. coderag/analyzers/base.py +36 -0
  4. coderag/analyzers/flake8_adapter.py +43 -0
  5. coderag/analyzers/pylint_adapter.py +50 -0
  6. coderag/analyzers/workflow.py +107 -0
  7. coderag/api/__init__.py +0 -0
  8. coderag/api/app.py +351 -0
  9. coderag/api/dashboard.py +237 -0
  10. coderag/api/schemas.py +181 -0
  11. coderag/cli/__init__.py +0 -0
  12. coderag/cli/main.py +359 -0
  13. coderag/context/__init__.py +0 -0
  14. coderag/context/builder.py +206 -0
  15. coderag/context/package.py +100 -0
  16. coderag/core/__init__.py +0 -0
  17. coderag/core/config.py +100 -0
  18. coderag/core/logging.py +58 -0
  19. coderag/core/text.py +80 -0
  20. coderag/core/tokens.py +66 -0
  21. coderag/db/__init__.py +0 -0
  22. coderag/db/base.py +58 -0
  23. coderag/db/migrations/env.py +77 -0
  24. coderag/db/migrations/script.py.mako +26 -0
  25. coderag/db/migrations/versions/.gitkeep +0 -0
  26. coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
  27. coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
  28. coderag/db/models.py +275 -0
  29. coderag/embeddings/__init__.py +0 -0
  30. coderag/embeddings/base.py +53 -0
  31. coderag/embeddings/hashing.py +43 -0
  32. coderag/embeddings/pipeline.py +87 -0
  33. coderag/embeddings/registry.py +38 -0
  34. coderag/embeddings/sentence_transformer.py +47 -0
  35. coderag/evaluation/__init__.py +0 -0
  36. coderag/evaluation/datasets.py +36 -0
  37. coderag/evaluation/harness.py +246 -0
  38. coderag/git/__init__.py +0 -0
  39. coderag/git/repo.py +139 -0
  40. coderag/indexing/__init__.py +0 -0
  41. coderag/indexing/ignore.py +101 -0
  42. coderag/indexing/indexer.py +445 -0
  43. coderag/llm/__init__.py +0 -0
  44. coderag/llm/anthropic.py +117 -0
  45. coderag/llm/base.py +60 -0
  46. coderag/llm/null.py +16 -0
  47. coderag/llm/registry.py +17 -0
  48. coderag/localdb.py +100 -0
  49. coderag/mcp_server.py +209 -0
  50. coderag/parsing/__init__.py +0 -0
  51. coderag/parsing/base.py +76 -0
  52. coderag/parsing/python.py +294 -0
  53. coderag/parsing/registry.py +40 -0
  54. coderag/retrieval/__init__.py +0 -0
  55. coderag/retrieval/base.py +60 -0
  56. coderag/retrieval/engine.py +176 -0
  57. coderag/retrieval/fusion.py +41 -0
  58. coderag/retrieval/graph.py +139 -0
  59. coderag/retrieval/lexical.py +57 -0
  60. coderag/retrieval/reranking.py +99 -0
  61. coderag/retrieval/semantic.py +53 -0
  62. coderag/retrieval/symbol.py +91 -0
  63. coderag/security/__init__.py +0 -0
  64. coderag/security/authz.py +35 -0
  65. coderag/security/secrets.py +110 -0
  66. coderag/service.py +160 -0
  67. coderag/telemetry.py +72 -0
  68. coderag_ai-0.3.0.dist-info/METADATA +364 -0
  69. coderag_ai-0.3.0.dist-info/RECORD +73 -0
  70. coderag_ai-0.3.0.dist-info/WHEEL +4 -0
  71. coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
  72. coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,87 @@
1
+ """Embedding pipeline: embed a repository's symbols, cached by source hash.
2
+
3
+ Unchanged code is never re-embedded: an embedding is (re)computed only when no
4
+ row exists for (symbol, model, version) or the symbol's ``source_hash`` changed.
5
+ Embedding inputs carry structural context (repo/file/symbol/type/signature/
6
+ docstring/code), not just the raw body (spec §9).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy import delete, select
12
+ from sqlalchemy.orm import Session
13
+
14
+ from coderag.core.logging import get_logger
15
+ from coderag.db.models import Repository, Symbol, SymbolEmbedding
16
+ from coderag.embeddings.base import EmbeddingProvider, build_embedding_input
17
+
18
+ log = get_logger("embeddings")
19
+
20
+
21
+ class EmbeddingPipeline:
22
+ def __init__(self, session: Session, provider: EmbeddingProvider) -> None:
23
+ self.session = session
24
+ self.provider = provider
25
+
26
+ def embed_repository(self, repo: Repository) -> int:
27
+ model = self.provider.model_name
28
+ version = self.provider.model_version
29
+
30
+ existing: dict[int, str] = {
31
+ row[0]: row[1]
32
+ for row in self.session.execute(
33
+ select(SymbolEmbedding.symbol_id, SymbolEmbedding.source_hash).where(
34
+ SymbolEmbedding.repository_id == repo.id,
35
+ SymbolEmbedding.embedding_model == model,
36
+ SymbolEmbedding.embedding_version == version,
37
+ )
38
+ ).all()
39
+ }
40
+
41
+ symbols = list(
42
+ self.session.scalars(
43
+ select(Symbol).where(Symbol.repository_id == repo.id)
44
+ )
45
+ )
46
+ stale: list[Symbol] = [
47
+ s for s in symbols if existing.get(s.id) != s.source_hash
48
+ ]
49
+ if not stale:
50
+ return 0
51
+
52
+ # Drop any prior (stale) rows for these symbols before re-inserting.
53
+ stale_ids = [s.id for s in stale]
54
+ self.session.execute(
55
+ delete(SymbolEmbedding).where(
56
+ SymbolEmbedding.symbol_id.in_(stale_ids),
57
+ SymbolEmbedding.embedding_model == model,
58
+ SymbolEmbedding.embedding_version == version,
59
+ )
60
+ )
61
+
62
+ inputs = [
63
+ build_embedding_input(
64
+ repo.name, s.file_path, s.qualified_name, s.symbol_type,
65
+ s.signature, s.docstring, s.source_code,
66
+ )
67
+ for s in stale
68
+ ]
69
+ vectors = self.provider.embed_documents(inputs)
70
+ for s, vec in zip(stale, vectors, strict=True):
71
+ self.session.add(
72
+ SymbolEmbedding(
73
+ repository_id=repo.id,
74
+ symbol_id=s.id,
75
+ source_hash=s.source_hash,
76
+ embedding_model=model,
77
+ embedding_version=version,
78
+ embedding_dimension=self.provider.dimension,
79
+ embedding=vec,
80
+ )
81
+ )
82
+ self.session.flush()
83
+ log.info(
84
+ "embed_repository.done", repository=repo.name, model=model,
85
+ embedded=len(stale), skipped=len(symbols) - len(stale),
86
+ )
87
+ return len(stale)
@@ -0,0 +1,38 @@
1
+ """Embedding provider selection + a tiny cache keyed by configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from coderag.core.config import Settings, get_settings
6
+ from coderag.embeddings.base import EmbeddingProvider
7
+
8
+ _cache: dict[tuple, EmbeddingProvider] = {}
9
+
10
+
11
+ def get_embedding_provider(settings: Settings | None = None) -> EmbeddingProvider:
12
+ settings = settings or get_settings()
13
+ key = (
14
+ settings.embedding_provider,
15
+ settings.embedding_model,
16
+ settings.embedding_device,
17
+ settings.embedding_dimension,
18
+ )
19
+ if key in _cache:
20
+ return _cache[key]
21
+
22
+ if settings.embedding_provider == "sentence_transformer":
23
+ from coderag.embeddings.sentence_transformer import (
24
+ SentenceTransformerEmbeddingProvider,
25
+ )
26
+
27
+ provider: EmbeddingProvider = SentenceTransformerEmbeddingProvider(
28
+ model_name=settings.embedding_model,
29
+ device=settings.embedding_device,
30
+ batch_size=settings.embedding_batch_size,
31
+ )
32
+ else:
33
+ from coderag.embeddings.hashing import HashingEmbeddingProvider
34
+
35
+ provider = HashingEmbeddingProvider(dimension=settings.embedding_dimension)
36
+
37
+ _cache[key] = provider
38
+ return provider
@@ -0,0 +1,47 @@
1
+ """Local SentenceTransformers embedding provider (optional `embeddings` extra).
2
+
3
+ Runs entirely on-box — no source code leaves your infrastructure. The model,
4
+ device, and batch size come from configuration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from coderag.embeddings.base import EmbeddingProvider
10
+
11
+
12
+ class SentenceTransformerEmbeddingProvider(EmbeddingProvider):
13
+ def __init__(
14
+ self,
15
+ model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
16
+ device: str = "cpu",
17
+ batch_size: int = 32,
18
+ ) -> None:
19
+ try:
20
+ from sentence_transformers import SentenceTransformer # noqa: PLC0415
21
+ except ImportError as exc: # pragma: no cover - optional dependency
22
+ raise RuntimeError(
23
+ "sentence-transformers is not installed. Install the 'embeddings' "
24
+ "extra: pip install 'coderag[embeddings]'"
25
+ ) from exc
26
+ self._model = SentenceTransformer(model_name, device=device)
27
+ self.model_name = model_name
28
+ self.model_version = "st1"
29
+ self.batch_size = batch_size
30
+ self.dimension = int(self._model.get_sentence_embedding_dimension())
31
+
32
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
33
+ vectors = self._model.encode(
34
+ texts,
35
+ batch_size=self.batch_size,
36
+ normalize_embeddings=True,
37
+ convert_to_numpy=True,
38
+ show_progress_bar=False,
39
+ )
40
+ return [v.tolist() for v in vectors]
41
+
42
+ def embed_query(self, text: str) -> list[float]:
43
+ vector = self._model.encode(
44
+ [text], normalize_embeddings=True, convert_to_numpy=True,
45
+ show_progress_bar=False,
46
+ )[0]
47
+ return vector.tolist()
File without changes
@@ -0,0 +1,36 @@
1
+ """Evaluation dataset format + loader.
2
+
3
+ A dataset is a JSON list of cases; each case has a ``question`` and the
4
+ ``expected_symbols`` that should be retrieved. Expected symbols may be full
5
+ qualified names or suffixes (see matching in ``harness``). Point the eval at your
6
+ own dataset to measure retrieval on your codebase.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ DEMO_DATASET = str(
16
+ Path(__file__).resolve().parents[3] / "examples" / "eval" / "eval_dataset.json"
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class EvalCase:
22
+ question: str
23
+ expected_symbols: list[str]
24
+
25
+
26
+ def load_dataset(path: str) -> list[EvalCase]:
27
+ data = json.loads(Path(path).read_text())
28
+ cases = []
29
+ for item in data:
30
+ cases.append(
31
+ EvalCase(
32
+ question=item["question"],
33
+ expected_symbols=list(item["expected_symbols"]),
34
+ )
35
+ )
36
+ return cases
@@ -0,0 +1,246 @@
1
+ """Evaluation harness.
2
+
3
+ Retrieval quality (Recall@K, MRR) + token accounting, and a measured comparison
4
+ of a naive baseline ("send whole files") against the budgeted Code-RAG context.
5
+ We report measured numbers only — no unverified savings claims (spec §18/§19).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import statistics
11
+ import time
12
+ from dataclasses import dataclass, field
13
+
14
+ from sqlalchemy import select
15
+ from sqlalchemy.orm import Session
16
+
17
+ from coderag.context.builder import ContextBuilder
18
+ from coderag.core.config import Settings, get_settings
19
+ from coderag.core.tokens import get_token_counter
20
+ from coderag.db.models import EvaluationRun, Repository, SourceFile
21
+ from coderag.evaluation.datasets import EvalCase
22
+ from coderag.git.repo import GitRepo
23
+ from coderag.retrieval.engine import build_engine
24
+
25
+
26
+ def _matches(qualified: str, expected: str) -> bool:
27
+ q, e = qualified.lower(), expected.lower()
28
+ return q == e or q.endswith("." + e) or e.endswith("." + q)
29
+
30
+
31
+ def _first_hit_rank(candidate_quals: list[str], expected: list[str]) -> int | None:
32
+ for rank, qual in enumerate(candidate_quals, start=1):
33
+ if any(_matches(qual, e) for e in expected):
34
+ return rank
35
+ return None
36
+
37
+
38
+ @dataclass
39
+ class RetrievalMetrics:
40
+ n_cases: int = 0
41
+ recall_at: dict[int, float] = field(default_factory=dict)
42
+ mrr: float = 0.0
43
+ avg_candidate_tokens: float = 0.0
44
+ avg_context_tokens: float = 0.0
45
+ avg_retrieval_latency_ms: float = 0.0
46
+ per_case: list[dict] = field(default_factory=list)
47
+
48
+
49
+ def evaluate_retrieval(
50
+ session: Session,
51
+ repo: Repository,
52
+ cases: list[EvalCase],
53
+ settings: Settings | None = None,
54
+ ks: tuple[int, ...] = (1, 3, 5, 10),
55
+ *,
56
+ semantic: bool = True,
57
+ graph: bool = True,
58
+ rerank: bool = False,
59
+ ) -> RetrievalMetrics:
60
+ settings = settings or get_settings()
61
+ engine = build_engine(
62
+ settings,
63
+ embedding_provider=_provider(settings) if semantic else None,
64
+ with_graph=graph,
65
+ with_reranker=rerank,
66
+ )
67
+ builder = ContextBuilder(session, settings=settings)
68
+
69
+ hits: dict[int, list[int]] = {k: [] for k in ks}
70
+ rrs: list[float] = []
71
+ cand_tokens: list[int] = []
72
+ ctx_tokens: list[int] = []
73
+ latencies: list[float] = []
74
+ per_case: list[dict] = []
75
+
76
+ for case in cases:
77
+ outcome = engine.search(session, repo.id, case.question, top_n=None)
78
+ quals = [c.qualified_name for c in outcome.candidates]
79
+ rank = _first_hit_rank(quals, case.expected_symbols)
80
+ for k in ks:
81
+ hits[k].append(1 if (rank is not None and rank <= k) else 0)
82
+ rrs.append(1.0 / rank if rank else 0.0)
83
+ latencies.append(outcome.latency_ms)
84
+ cand_tokens.append(sum(c.token_count for c in outcome.candidates))
85
+ pkg = builder.build(case.question, outcome.candidates, repo)
86
+ ctx_tokens.append(pkg.accounting.context_tokens)
87
+ per_case.append({
88
+ "question": case.question, "first_hit_rank": rank,
89
+ "candidate_tokens": cand_tokens[-1], "context_tokens": ctx_tokens[-1],
90
+ })
91
+
92
+ n = len(cases)
93
+ return RetrievalMetrics(
94
+ n_cases=n,
95
+ recall_at={k: (sum(hits[k]) / n if n else 0.0) for k in ks},
96
+ mrr=(sum(rrs) / n if n else 0.0),
97
+ avg_candidate_tokens=(statistics.mean(cand_tokens) if cand_tokens else 0.0),
98
+ avg_context_tokens=(statistics.mean(ctx_tokens) if ctx_tokens else 0.0),
99
+ avg_retrieval_latency_ms=(statistics.mean(latencies) if latencies else 0.0),
100
+ per_case=per_case,
101
+ )
102
+
103
+
104
+ @dataclass
105
+ class BaselineComparison:
106
+ n_cases: int
107
+ baseline_kind: str
108
+ avg_baseline_tokens: float
109
+ avg_topfiles_tokens: float
110
+ avg_rag_context_tokens: float
111
+ avg_rag_prompt_tokens: float
112
+ token_reduction_percent: float
113
+
114
+
115
+ def compare_baseline(
116
+ session: Session,
117
+ repo: Repository,
118
+ cases: list[EvalCase],
119
+ settings: Settings | None = None,
120
+ topfiles: int = 3,
121
+ ) -> BaselineComparison:
122
+ """Compare Code-RAG context against naive baselines.
123
+
124
+ Headline baseline is **broad repository context** (send every source file) —
125
+ the thing you do without retrieval. We also report the "top-N whole files"
126
+ baseline. Savings scale with repository size: on a tiny demo the whole-repo
127
+ number is modest, but the *ratio* (RAG context vs the code you'd otherwise
128
+ paste) is what generalises.
129
+ """
130
+ settings = settings or get_settings()
131
+ engine = build_engine(settings, embedding_provider=_provider(settings), with_graph=True)
132
+ builder = ContextBuilder(session, settings=settings)
133
+ counter = get_token_counter(settings)
134
+ git = GitRepo(repo.local_path)
135
+
136
+ file_cache: dict[str, int] = {}
137
+
138
+ def file_tokens(path: str) -> int:
139
+ if path not in file_cache:
140
+ file_cache[path] = counter.count(git.read_text(path) or "")
141
+ return file_cache[path]
142
+
143
+ all_files = list(
144
+ session.scalars(
145
+ select(SourceFile.path).where(SourceFile.repository_id == repo.id)
146
+ )
147
+ )
148
+ whole_repo_tokens = sum(file_tokens(f) for f in all_files)
149
+
150
+ topfiles_tok: list[int] = []
151
+ rag_ctx: list[int] = []
152
+ rag_prompt: list[int] = []
153
+ for case in cases:
154
+ outcome = engine.search(session, repo.id, case.question, top_n=None)
155
+ files: list[str] = []
156
+ for c in outcome.candidates:
157
+ if c.file_path not in files:
158
+ files.append(c.file_path)
159
+ if len(files) >= topfiles:
160
+ break
161
+ topfiles_tok.append(sum(file_tokens(f) for f in files))
162
+ pkg = builder.build(case.question, outcome.candidates, repo)
163
+ rag_ctx.append(pkg.accounting.context_tokens)
164
+ rag_prompt.append(pkg.accounting.final_prompt_tokens)
165
+
166
+ avg_ctx = statistics.mean(rag_ctx) if rag_ctx else 0.0
167
+ reduction = (
168
+ round(100.0 * (1.0 - avg_ctx / whole_repo_tokens), 1) if whole_repo_tokens else 0.0
169
+ )
170
+ return BaselineComparison(
171
+ n_cases=len(cases), baseline_kind="whole_repository",
172
+ avg_baseline_tokens=float(whole_repo_tokens),
173
+ avg_topfiles_tokens=(statistics.mean(topfiles_tok) if topfiles_tok else 0.0),
174
+ avg_rag_context_tokens=avg_ctx,
175
+ avg_rag_prompt_tokens=(statistics.mean(rag_prompt) if rag_prompt else 0.0),
176
+ token_reduction_percent=reduction,
177
+ )
178
+
179
+
180
+ @dataclass
181
+ class LatencyBenchmark:
182
+ n: int
183
+ p50_ms: float
184
+ p95_ms: float
185
+ mean_ms: float
186
+
187
+
188
+ def benchmark_latency(
189
+ session: Session, repo: Repository, cases: list[EvalCase],
190
+ settings: Settings | None = None, repeats: int = 3,
191
+ ) -> LatencyBenchmark:
192
+ settings = settings or get_settings()
193
+ engine = build_engine(settings, embedding_provider=_provider(settings), with_graph=True)
194
+ samples: list[float] = []
195
+ for _ in range(repeats):
196
+ for case in cases:
197
+ t = time.perf_counter()
198
+ engine.search(session, repo.id, case.question, top_n=10)
199
+ samples.append((time.perf_counter() - t) * 1000)
200
+ samples.sort()
201
+
202
+ def pct(p: float) -> float:
203
+ if not samples:
204
+ return 0.0
205
+ idx = min(len(samples) - 1, int(p * len(samples)))
206
+ return samples[idx]
207
+
208
+ return LatencyBenchmark(
209
+ n=len(samples), p50_ms=pct(0.50), p95_ms=pct(0.95),
210
+ mean_ms=(statistics.mean(samples) if samples else 0.0),
211
+ )
212
+
213
+
214
+ def persist_eval_run(
215
+ session: Session, name: str, dataset: str, metrics: RetrievalMetrics,
216
+ settings: Settings | None = None,
217
+ ) -> EvaluationRun:
218
+ settings = settings or get_settings()
219
+ run = EvaluationRun(
220
+ name=name, dataset=dataset,
221
+ recall_at_1=metrics.recall_at.get(1, 0.0),
222
+ recall_at_3=metrics.recall_at.get(3, 0.0),
223
+ recall_at_5=metrics.recall_at.get(5, 0.0),
224
+ recall_at_10=metrics.recall_at.get(10, 0.0),
225
+ mrr=metrics.mrr,
226
+ avg_retrieved_tokens=metrics.avg_candidate_tokens,
227
+ avg_context_tokens=metrics.avg_context_tokens,
228
+ avg_retrieval_latency_ms=metrics.avg_retrieval_latency_ms,
229
+ config={
230
+ "rrf_k": settings.rrf_k,
231
+ "weights": {
232
+ "symbol": settings.weight_symbol, "lexical": settings.weight_lexical,
233
+ "semantic": settings.weight_semantic, "graph": settings.weight_graph,
234
+ },
235
+ "max_context_tokens": settings.max_context_tokens,
236
+ },
237
+ )
238
+ session.add(run)
239
+ session.flush()
240
+ return run
241
+
242
+
243
+ def _provider(settings: Settings):
244
+ from coderag.embeddings.registry import get_embedding_provider
245
+
246
+ return get_embedding_provider(settings)
File without changes
coderag/git/repo.py ADDED
@@ -0,0 +1,139 @@
1
+ """Git-backed repository access.
2
+
3
+ For git repositories we enumerate with ``git ls-files`` so ``.gitignore`` and the
4
+ tracked/untracked distinction are honoured for free. For plain directories we
5
+ fall back to a filesystem walk. Diffs (base..head) power incremental indexing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass
16
+ class DiffResult:
17
+ added: list[str] = field(default_factory=list)
18
+ modified: list[str] = field(default_factory=list)
19
+ deleted: list[str] = field(default_factory=list)
20
+
21
+ @property
22
+ def changed(self) -> list[str]:
23
+ return self.added + self.modified
24
+
25
+
26
+ class GitRepo:
27
+ def __init__(self, path: str | Path) -> None:
28
+ self.path = Path(path).resolve()
29
+
30
+ # -- git helpers ------------------------------------------------------
31
+ def _git(self, *args: str) -> str:
32
+ result = subprocess.run(
33
+ ["git", "-C", str(self.path), *args],
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ )
38
+ return result.stdout
39
+
40
+ def is_git(self) -> bool:
41
+ try:
42
+ out = self._git("rev-parse", "--is-inside-work-tree").strip()
43
+ return out == "true"
44
+ except (subprocess.CalledProcessError, FileNotFoundError):
45
+ return False
46
+
47
+ def current_commit(self) -> str | None:
48
+ try:
49
+ return self._git("rev-parse", "HEAD").strip()
50
+ except subprocess.CalledProcessError:
51
+ return None # e.g. repo with no commits yet
52
+
53
+ def default_branch(self) -> str:
54
+ try:
55
+ return self._git("rev-parse", "--abbrev-ref", "HEAD").strip() or "main"
56
+ except subprocess.CalledProcessError:
57
+ return "main"
58
+
59
+ # -- enumeration ------------------------------------------------------
60
+ def list_files(self) -> list[str]:
61
+ """Repository-relative paths of candidate files (POSIX separators).
62
+
63
+ For git repos we include tracked files *and* untracked files that are
64
+ not gitignored (``--exclude-standard``), so a working tree with
65
+ uncommitted files still indexes correctly while honouring .gitignore.
66
+ """
67
+ if self.is_git():
68
+ tracked = self._git("ls-files", "-z").split("\0")
69
+ untracked = self._git(
70
+ "ls-files", "-z", "--others", "--exclude-standard"
71
+ ).split("\0")
72
+ seen: dict[str, None] = {}
73
+ for p in (*tracked, *untracked):
74
+ if p:
75
+ seen[p] = None
76
+ return list(seen)
77
+ return self._walk()
78
+
79
+ def _walk(self) -> list[str]:
80
+ from coderag.indexing.ignore import DEFAULT_IGNORE_DIRS
81
+
82
+ files: list[str] = []
83
+ for p in sorted(self.path.rglob("*")):
84
+ if p.is_dir():
85
+ continue
86
+ rel = p.relative_to(self.path).as_posix()
87
+ if set(rel.split("/")[:-1]) & DEFAULT_IGNORE_DIRS:
88
+ continue
89
+ files.append(rel)
90
+ return files
91
+
92
+ # -- diff (incremental) ----------------------------------------------
93
+ def diff(self, base: str, head: str = "HEAD") -> DiffResult:
94
+ out = self._git("diff", "--name-status", "-z", base, head)
95
+ return _parse_name_status(out)
96
+
97
+ # -- reading ----------------------------------------------------------
98
+ def read_text(self, rel_path: str) -> str | None:
99
+ fp = self.path / rel_path
100
+ try:
101
+ data = fp.read_bytes()
102
+ except (OSError, FileNotFoundError):
103
+ return None
104
+ from coderag.indexing.ignore import looks_binary
105
+
106
+ if looks_binary(data):
107
+ return None
108
+ return data.decode("utf-8", errors="replace")
109
+
110
+
111
+ def _parse_name_status(z: str) -> DiffResult:
112
+ """Parse ``git diff --name-status -z`` output.
113
+
114
+ Records are NUL-separated. Renames/copies (R/C) emit the status token, then
115
+ the old path, then the new path as separate records.
116
+ """
117
+ res = DiffResult()
118
+ tokens = [t for t in z.split("\0") if t != ""]
119
+ i = 0
120
+ while i < len(tokens):
121
+ status = tokens[i]
122
+ code = status[0]
123
+ if code in ("R", "C"):
124
+ old, new = tokens[i + 1], tokens[i + 2]
125
+ res.deleted.append(old)
126
+ res.added.append(new)
127
+ i += 3
128
+ else:
129
+ path = tokens[i + 1]
130
+ if code == "A":
131
+ res.added.append(path)
132
+ elif code == "M":
133
+ res.modified.append(path)
134
+ elif code == "D":
135
+ res.deleted.append(path)
136
+ else: # T (type change), etc. -> treat as modified
137
+ res.modified.append(path)
138
+ i += 2
139
+ return res
File without changes