Skip to content

Commit 75c53e6

Browse files
committed
feat: enhance indexing with ignore file support and dry-run option
1 parent 1bec25a commit 75c53e6

11 files changed

Lines changed: 688 additions & 15 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ th index . --in-memory
132132
th index . --in-memory --output json
133133
```
134134

135+
Preview which files would be indexed without parsing or writing any SQLite state:
136+
137+
```powershell
138+
th index . --dry-run
139+
th index . --dry-run --output json
140+
```
141+
135142
Watch for file changes and reindex incrementally (Ctrl-C to stop):
136143

137144
```powershell
@@ -149,6 +156,18 @@ th index . --sqlite-db ./.trailhead/graph.db --embed-model sentence-transformers
149156

150157
When `sqlite-vector` can be loaded, trailhead also initializes vector search for the `vertex_embeddings.embedding` column. If extension loading is unavailable on your platform build, embeddings are still stored as Float32 BLOBs in SQLite.
151158

159+
Source discovery respects `.gitignore` and `.trailheadignore` in the indexed directory. Both use gitignore-style patterns, and `.trailheadignore` is applied after `.gitignore` so Trailhead-specific rules take precedence. If you change either ignore file, delete `.trailhead/db.sqlite` and run `th index` again to rebuild the index with the new rules.
160+
161+
`th index --dry-run --output json` returns a file-preview payload with this schema:
162+
163+
```json
164+
{
165+
"root": "C:/path/to/project",
166+
"count": 2,
167+
"files": ["src/app.py", "src/lib/util.py"]
168+
}
169+
```
170+
152171
### serve
153172

154173
Run the warm-model API server with a background indexer. The server watches the source tree, keeps the SQLite graph fresh, and reuses the loaded embedding model across index updates. The database defaults to `.trailhead/db.sqlite` under the watched directory:

docs/architecture-blog-post.md

Lines changed: 356 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727

2828
dependencies = [
2929
"sentence-transformers",
30+
"pathspec>=0.12",
3031
"tree-sitter>=0.21",
3132
"tree-sitter-python>=0.21",
3233
"sqliteai-vector",

src/tests/test_indexing.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for the indexing service (property graph model + tree-sitter parser)."""
22
from __future__ import annotations
33

4+
import json
45
import sqlite3
56

67
import pytest
@@ -390,6 +391,33 @@ def test_ignores_non_python_files(self, tmp_path):
390391
graph = index_directory(tmp_path)
391392
assert len(graph.vertices("module")) == 1
392393

394+
def test_respects_ignore_files_with_trailheadignore_precedence(self, tmp_path):
395+
from trailhead.services.indexing.walker import index_directory
396+
397+
(tmp_path / ".gitignore").write_text("*.py\n")
398+
(tmp_path / ".trailheadignore").write_text("!keep.py\n")
399+
(tmp_path / "keep.py").write_text("def keep():\n pass\n")
400+
(tmp_path / "drop.py").write_text("def drop():\n pass\n")
401+
402+
graph = index_directory(tmp_path)
403+
404+
module_names = {v.properties["name"] for v in graph.vertices("module")}
405+
assert module_names == {"keep"}
406+
407+
def test_ignores_directories_listed_in_ignore_files(self, tmp_path):
408+
from trailhead.services.indexing.walker import index_directory
409+
410+
generated = tmp_path / "generated"
411+
generated.mkdir()
412+
(tmp_path / ".gitignore").write_text("generated/\n")
413+
(tmp_path / "keep.py").write_text("def keep():\n pass\n")
414+
(generated / "skip.py").write_text("def skip():\n pass\n")
415+
416+
graph = index_directory(tmp_path)
417+
418+
module_names = {v.properties["name"] for v in graph.vertices("module")}
419+
assert module_names == {"keep"}
420+
393421
def test_accepts_existing_graph(self, tmp_path):
394422
from trailhead.services.indexing.graph import PropertyGraph
395423
from trailhead.services.indexing.walker import index_directory
@@ -517,6 +545,23 @@ def test_persist_append_mode_keeps_existing_rows(self, tmp_path):
517545
assert v_count == 4
518546
assert e_count == 2
519547

548+
def test_persist_indexed_files_respects_ignore_files(self, tmp_path):
549+
from trailhead.services.indexing.sqlite_store import get_indexed_files
550+
from trailhead.services.indexing.sqlite_store import persist_indexed_files
551+
552+
root = tmp_path / "src"
553+
root.mkdir()
554+
(root / ".gitignore").write_text("skip.py\n")
555+
(root / "keep.py").write_text("def keep():\n pass\n")
556+
(root / "skip.py").write_text("def skip():\n pass\n")
557+
db = tmp_path / "graph.db"
558+
559+
count = persist_indexed_files(root, db)
560+
indexed_files = get_indexed_files(db)
561+
562+
assert count == 1
563+
assert set(indexed_files) == {str((root / "keep.py").resolve())}
564+
520565
def test_persist_vertex_embeddings_writes_rows(self, tmp_path, monkeypatch):
521566
from trailhead.services.indexing.graph import PropertyGraph
522567
from trailhead.services.indexing.sqlite_store import persist_graph
@@ -629,3 +674,63 @@ def test_index_command_embed_model_persists_vectors(self, tmp_path, monkeypatch)
629674
]
630675
)
631676
assert rc == 0
677+
678+
679+
class TestIndexCommandDryRun:
680+
def test_dry_run_lists_candidates_without_creating_db(self, tmp_path, capsys):
681+
from trailhead.cli import app as cli
682+
683+
src = tmp_path / "src"
684+
src.mkdir()
685+
(src / ".gitignore").write_text("*.py\n")
686+
(src / ".trailheadignore").write_text("!keep.py\n")
687+
(src / "keep.py").write_text("def keep():\n pass\n")
688+
(src / "drop.py").write_text("def drop():\n pass\n")
689+
690+
rc = cli.main(["index", str(src), "--dry-run"])
691+
output = capsys.readouterr().out
692+
693+
assert rc == 0
694+
assert "Would index 1 file(s)" in output
695+
assert "keep.py" in output
696+
assert "drop.py" not in output
697+
assert not (src / ".trailhead").exists()
698+
699+
def test_dry_run_json_uses_preview_schema(self, tmp_path, capsys):
700+
from trailhead.cli import app as cli
701+
702+
src = tmp_path / "src"
703+
src.mkdir()
704+
(src / "keep.py").write_text("def keep():\n pass\n")
705+
706+
rc = cli.main(["index", str(src), "--dry-run", "--output", "json"])
707+
payload = json.loads(capsys.readouterr().out)
708+
709+
assert rc == 0
710+
assert payload == {
711+
"root": str(src.resolve()),
712+
"count": 1,
713+
"files": ["keep.py"],
714+
}
715+
716+
@pytest.mark.parametrize(
717+
"argv",
718+
[
719+
["index", "src", "--dry-run", "--watch"],
720+
["index", "src", "--dry-run", "--in-memory"],
721+
["index", "src", "--dry-run", "--sqlite-db", "graph.db"],
722+
["index", "src", "--dry-run", "--embed-model", "sentence-transformers/all-MiniLM-L6-v2"],
723+
],
724+
)
725+
def test_dry_run_rejects_incompatible_flags(self, tmp_path, argv):
726+
from trailhead.cli import app as cli
727+
728+
src = tmp_path / "src"
729+
src.mkdir()
730+
(src / "keep.py").write_text("def keep():\n pass\n")
731+
732+
normalized_argv = [str(src) if arg == "src" else arg for arg in argv]
733+
734+
rc = cli.main(normalized_argv)
735+
736+
assert rc == 1

src/tests/test_live_indexer.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,34 @@ def fake_reindex_paths(paths):
116116
assert len(reindexed) == 1
117117
assert any(p.name == "foo.py" for p in reindexed[0])
118118

119+
def test_watcher_ignores_ignore_file_updates(self, tmp_path, monkeypatch):
120+
from trailhead.services.indexing.live_indexer import LiveIndexer
121+
122+
root = tmp_path / "src"
123+
root.mkdir()
124+
db = tmp_path / "graph.db"
125+
126+
reindexed: list[set] = []
127+
128+
def fake_reindex_paths(paths):
129+
reindexed.append(paths)
130+
131+
fake_changes = [[(1, str(root / ".trailheadignore")), (1, str(root / ".gitignore"))]]
132+
133+
monkeypatch.setattr(
134+
"trailhead.services.indexing.live_indexer.watch",
135+
lambda *a, **kw: iter(fake_changes),
136+
)
137+
138+
service = LiveIndexer(root=root, db_path=db)
139+
monkeypatch.setattr(service, "reindex_paths", fake_reindex_paths)
140+
service.start()
141+
if service._watch_thread is not None:
142+
service._watch_thread.join(timeout=5)
143+
service.stop()
144+
145+
assert reindexed == []
146+
119147

120148
class TestLiveIndexer:
121149
def test_synchronize_reindexes_changed_new_and_deleted_files(self, tmp_path, monkeypatch):
@@ -158,3 +186,28 @@ def fake_reindex_file(db_path, path, **kwargs):
158186
new_file.resolve(),
159187
deleted_file.resolve(),
160188
}
189+
190+
def test_reindex_paths_skips_ignored_files_using_current_rules(self, tmp_path, monkeypatch):
191+
from trailhead.services.indexing.live_indexer import LiveIndexer
192+
193+
root = tmp_path / "src"
194+
root.mkdir()
195+
keep = root / "keep.py"
196+
ignored = root / "ignored.py"
197+
keep.write_text("def keep():\n pass\n")
198+
ignored.write_text("def ignored():\n pass\n")
199+
(root / ".gitignore").write_text("*.py\n")
200+
(root / ".trailheadignore").write_text("!keep.py\n")
201+
202+
seen_paths: list[Path] = []
203+
204+
def fake_reindex_file(db_path, path, **kwargs):
205+
seen_paths.append(path.resolve())
206+
return (0, 0)
207+
208+
monkeypatch.setattr("trailhead.services.indexing.live_indexer.reindex_file", fake_reindex_file)
209+
210+
service = LiveIndexer(root=root, db_path=tmp_path / "graph.db")
211+
service.reindex_paths({keep, ignored})
212+
213+
assert seen_paths == [keep.resolve()]

src/trailhead/cli/commands/index.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from trailhead.services.config import ALLOWED_MODELS
1111
from trailhead.services.config import get_cache_dir
1212
from trailhead.services.config import is_model_allowed
13+
from trailhead.services.indexing import discover_source_files
1314
from trailhead.services.indexing import LiveIndexer
1415
from trailhead.services.indexing.graph import PropertyGraph
1516
from trailhead.services.indexing.sqlite_store import get_index_model
@@ -56,6 +57,15 @@ def configure_parser(subparsers: argparse._SubParsersAction) -> None:
5657
"Incompatible with --watch."
5758
),
5859
)
60+
parser.add_argument(
61+
"--dry-run",
62+
action="store_true",
63+
help=(
64+
"List the source files that would be indexed and exit without parsing "
65+
"or writing SQLite state. Incompatible with --watch, --in-memory, "
66+
"--sqlite-db, and --embed-model."
67+
),
68+
)
5969
parser.add_argument(
6070
"--watch",
6171
action="store_true",
@@ -69,7 +79,7 @@ def configure_parser(subparsers: argparse._SubParsersAction) -> None:
6979
"--output",
7080
choices=["summary", "json"],
7181
default="summary",
72-
help="Output format when --in-memory is set (default: summary).",
82+
help="Output format when --in-memory or --dry-run is set (default: summary).",
7383
)
7484
parser.add_argument(
7585
"--embed-model",
@@ -113,10 +123,26 @@ def run(args: argparse.Namespace) -> int:
113123
logger.error("--in-memory and --watch are mutually exclusive.")
114124
return 1
115125

126+
if args.dry_run and args.watch:
127+
logger.error("--dry-run and --watch are mutually exclusive.")
128+
return 1
129+
130+
if args.dry_run and args.in_memory:
131+
logger.error("--dry-run and --in-memory are mutually exclusive.")
132+
return 1
133+
134+
if args.dry_run and args.sqlite_db:
135+
logger.error("--dry-run does not accept --sqlite-db.")
136+
return 1
137+
116138
if args.embed_model and args.in_memory:
117139
logger.error("--embed-model requires SQLite persistence; remove --in-memory.")
118140
return 1
119141

142+
if args.embed_model and args.dry_run:
143+
logger.error("--embed-model requires SQLite persistence; remove --dry-run.")
144+
return 1
145+
120146
if args.embed_model and not args.sqlite_db:
121147
logger.error("--embed-model requires --sqlite-db.")
122148
return 1
@@ -130,6 +156,18 @@ def run(args: argparse.Namespace) -> int:
130156
)
131157
return 1
132158

159+
# ------------------------------------------------------------------
160+
# Dry-run path: enumerate files only, print preview, then exit.
161+
# ------------------------------------------------------------------
162+
if args.dry_run:
163+
logger.info("Previewing index candidates under %s", root)
164+
files = discover_source_files(root)
165+
if args.output == "json":
166+
_print_dry_run_json(root, files)
167+
else:
168+
_print_dry_run_summary(root, files)
169+
return 0
170+
133171
# ------------------------------------------------------------------
134172
# In-memory path: one-shot build, print summary/JSON, then exit.
135173
# ------------------------------------------------------------------
@@ -241,3 +279,21 @@ def _print_summary(graph: PropertyGraph) -> None:
241279
if imports:
242280
names = sorted(e.in_v.properties["name"] for e in imports)
243281
print(f" imports: {', '.join(names)}")
282+
283+
284+
def _print_dry_run_json(root: Path, files: list[Path]) -> None:
285+
data = {
286+
"root": str(root),
287+
"count": len(files),
288+
"files": [path.relative_to(root).as_posix() for path in files],
289+
}
290+
print(json.dumps(data, indent=2))
291+
292+
293+
def _print_dry_run_summary(root: Path, files: list[Path]) -> None:
294+
print(f"Would index {len(files)} file(s) under {root}.")
295+
if not files:
296+
return
297+
print()
298+
for path in files:
299+
print(path.relative_to(root).as_posix())

src/trailhead/services/indexing/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Code indexing service — property graph construction from source trees."""
22

3+
from trailhead.services.indexing.discovery import discover_source_files
4+
from trailhead.services.indexing.discovery import load_ignore_spec
5+
from trailhead.services.indexing.discovery import should_index_path
36
from trailhead.services.indexing.graph import PropertyGraph
47
from trailhead.services.indexing.graph_query import search_vertices
58
from trailhead.services.indexing.graph_query import traverse_graph
@@ -18,11 +21,14 @@
1821
__all__ = [
1922
"PropertyGraph",
2023
"LiveIndexer",
24+
"discover_source_files",
2125
"index_directory",
26+
"load_ignore_spec",
2227
"persist_graph",
2328
"persist_indexed_files",
2429
"persist_vertex_embeddings",
2530
"reindex_file",
31+
"should_index_path",
2632
"vector_to_blob",
2733
"get_indexed_files",
2834
"load_graph",

0 commit comments

Comments
 (0)