Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ You can also use the CLI directly — useful for manual control, running indexin

```bash
ccc init # initialize project (creates settings)
ccc index --dry # preview added, updated, and deleted files
ccc index # build the index
ccc search "authentication logic" # search!
```
Expand All @@ -246,6 +247,7 @@ The background daemon starts automatically on first use.
| Command | Description |
|---------|-------------|
| `ccc init` | Initialize a project — creates settings files, adds `.cocoindex_code/` to `.gitignore` |
| `ccc index --dry` | Preview added, updated, and deleted files |
| `ccc index` | Build or update the index (auto-inits if needed). Shows streaming progress. |
| `ccc search <query>` | Semantic search across the codebase |
| `ccc grep <pattern> [path]` | Structural code search by example (no index needed) |
Expand Down
43 changes: 38 additions & 5 deletions src/cocoindex_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
import functools
import os
import sys
Expand Down Expand Up @@ -645,14 +646,46 @@ def init(

@app.command()
@_catch_daemon_start_error
def index() -> None:
def index(
dry: bool = _typer.Option(
False,
"--dry",
help="List files to add, update, or delete without indexing.",
),
) -> None:
"""Create/update index for the codebase."""
project_root = require_project_root(auto_init=not dry)
print_project_header(str(project_root))
if dry:
from .index_changes import find_index_changes

changes = asyncio.run(find_index_changes(project_root))
_print_index_changes(changes.added, changes.updated, changes.deleted)
return

from . import client as _client

project_root = str(require_project_root(auto_init=True))
print_project_header(project_root)
_run_index_with_progress(project_root)
print_index_stats(_client.project_status(project_root))
_run_index_with_progress(str(project_root))
print_index_stats(_client.project_status(str(project_root)))


def _print_index_changes(
added: tuple[str, ...],
updated: tuple[str, ...] | None,
deleted: tuple[str, ...],
) -> None:
_typer.echo(f"Files to add ({len(added)}):")
for path in added:
_typer.echo(f" {path}")
if updated is None:
_typer.echo("Files to update: unavailable until the next successful index")
else:
_typer.echo(f"Files to update ({len(updated)}):")
for path in updated:
_typer.echo(f" {path}")
_typer.echo(f"Files to delete ({len(deleted)}):")
for path in deleted:
_typer.echo(f" {path}")


@app.command()
Expand Down
239 changes: 239 additions & 0 deletions src/cocoindex_code/index_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Read-only preview of files changed since the last index."""

from __future__ import annotations

import asyncio
import sqlite3
from collections.abc import Collection, Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePath
from typing import NamedTuple

import cocoindex as coco
from cocoindex.connectorkits.fingerprint import fingerprint_bytes
from cocoindex.connectors import localfs
from cocoindex.connectors import sqlite as coco_sqlite
from cocoindex.inspect import iter_stable_paths_by_name

from .file_walk import build_matcher, iter_included_files
from .settings import cocoindex_db_path, load_project_settings, target_sqlite_db_path
from .shared import APP_NAME, CODEBASE_DIR, SQLITE_DB

FILE_MANIFEST_APP_NAME = f"{APP_NAME}FileManifest"
_FILE_MANIFEST_TABLE = "code_file_manifest"
_CREATE_FILE_MANIFEST_TABLE = f"""
CREATE TABLE IF NOT EXISTS {_FILE_MANIFEST_TABLE} (
path TEXT PRIMARY KEY NOT NULL,
fingerprint BLOB NOT NULL
)
"""


@dataclass(frozen=True)
class IndexChanges:
added: tuple[str, ...]
updated: tuple[str, ...] | None
deleted: tuple[str, ...]


class _FileManifestAction(NamedTuple):
path: str
fingerprint: bytes | None


class _FileManifestHandler(coco.TargetHandler[bytes, bytes]):
def __init__(self) -> None:
self._sink = coco.TargetActionSink[_FileManifestAction, None].from_fn(self._apply)

@staticmethod
def _apply(
context_provider: coco.ContextProvider,
actions: Sequence[_FileManifestAction],
/,
) -> None:
db = context_provider.get(SQLITE_DB)
with db.transaction() as conn:
conn.execute(_CREATE_FILE_MANIFEST_TABLE)
conn.executemany(
f"DELETE FROM {_FILE_MANIFEST_TABLE} WHERE path = ?",
[(action.path,) for action in actions if action.fingerprint is None],
)
conn.executemany(
f"""
INSERT INTO {_FILE_MANIFEST_TABLE} (path, fingerprint)
VALUES (?, ?)
ON CONFLICT (path) DO UPDATE SET fingerprint = excluded.fingerprint
""",
[
(action.path, action.fingerprint)
for action in actions
if action.fingerprint is not None
],
)

def reconcile(
self,
key: coco.StableKey,
desired_state: bytes | coco.NonExistenceType,
prev_possible_records: Collection[bytes],
prev_may_be_missing: bool,
/,
) -> coco.TargetReconcileOutput[_FileManifestAction, bytes] | None:
assert isinstance(key, str)
if coco.is_non_existence(desired_state):
if not prev_possible_records and not prev_may_be_missing:
return None
return coco.TargetReconcileOutput(
action=_FileManifestAction(key, None),
sink=self._sink,
tracking_record=coco.NON_EXISTENCE,
)

if not prev_may_be_missing and all(
previous == desired_state for previous in prev_possible_records
):
return None

return coco.TargetReconcileOutput(
action=_FileManifestAction(key, desired_state),
sink=self._sink,
tracking_record=desired_state,
)


_FILE_MANIFEST_PROVIDER = coco.register_root_target_states_provider(
"cocoindex_code/file_manifest",
_FileManifestHandler(),
)


@coco.fn(memo=True)
async def _track_file(file: localfs.File) -> None:
path = file.file_path.path.as_posix()
coco.declare_target_state(
_FILE_MANIFEST_PROVIDER.target_state(path, await file.content_fingerprint())
)


@coco.fn
async def _build_file_manifest() -> None:
project_root = coco.use_context(CODEBASE_DIR)
settings = load_project_settings(project_root)
matcher = build_matcher(
project_root,
settings.include_patterns,
settings.exclude_patterns,
settings.max_file_size,
)
files = localfs.walk_dir(
CODEBASE_DIR,
recursive=True,
path_matcher=matcher,
)
await coco.mount_each(
coco.component_subpath(coco.Symbol("track_file")),
_track_file,
files.items(),
)


def create_file_manifest_app(env: coco.Environment) -> coco.App[[], None]:
"""Create the app that persists source fingerprints after indexing."""
return coco.App(
coco.AppConfig(name=FILE_MANIFEST_APP_NAME, environment=env),
_build_file_manifest,
)


def prepare_file_manifest(db: coco_sqlite.ManagedConnection) -> None:
"""Create the fingerprint table before the first manifest update."""
with db.transaction() as conn:
conn.execute(_CREATE_FILE_MANIFEST_TABLE)


async def find_index_changes(project_root: Path) -> IndexChanges:
"""Return files the next index would add, update, or delete."""
indexed = await asyncio.to_thread(_indexed_fingerprints, project_root)
if indexed is not None:
matched = await asyncio.to_thread(_matched_fingerprints, project_root)
matched_paths = set(matched)
indexed_paths = set(indexed)
return IndexChanges(
added=tuple(sorted(matched_paths - indexed_paths)),
updated=tuple(
sorted(
path for path in matched_paths & indexed_paths if matched[path] != indexed[path]
)
),
deleted=tuple(sorted(indexed_paths - matched_paths)),
)

matched_paths, indexed_paths = await asyncio.gather(
asyncio.to_thread(_matched_paths, project_root),
_indexed_paths(project_root),
)
return IndexChanges(
added=tuple(sorted(matched_paths - indexed_paths)),
updated=None if indexed_paths else (),
deleted=tuple(sorted(indexed_paths - matched_paths)),
)


def _matched_fingerprints(project_root: Path) -> dict[str, bytes]:
fingerprints: dict[str, bytes] = {}
for absolute_path, relative_path in _iter_matched_files(project_root):
try:
fingerprints[relative_path.as_posix()] = fingerprint_bytes(absolute_path.read_bytes())
except OSError:
continue
return fingerprints


def _matched_paths(project_root: Path) -> set[str]:
return {relative_path.as_posix() for _, relative_path in _iter_matched_files(project_root)}


def _iter_matched_files(project_root: Path) -> Iterator[tuple[Path, PurePath]]:
settings = load_project_settings(project_root)
matcher = build_matcher(
project_root,
settings.include_patterns,
settings.exclude_patterns,
settings.max_file_size,
)
return iter_included_files(project_root, project_root, matcher)


def _indexed_fingerprints(project_root: Path) -> dict[str, bytes] | None:
db_path = target_sqlite_db_path(project_root)
if not db_path.exists():
return None

try:
conn = sqlite3.connect(f"{db_path.resolve().as_uri()}?mode=ro", uri=True)
try:
rows = conn.execute(f"SELECT path, fingerprint FROM {_FILE_MANIFEST_TABLE}").fetchall()
finally:
conn.close()
except sqlite3.OperationalError:
return None
return {path: fingerprint for path, fingerprint in rows}


async def _indexed_paths(project_root: Path) -> set[str]:
db_path = cocoindex_db_path(project_root)
if not db_path.exists():
return set()

env = coco.Environment(coco.Settings.from_env(db_path))
paths: set[str] = set()
async for item in iter_stable_paths_by_name(env, APP_NAME):
parts = coco.StablePath(item.path).parts()
if (
len(parts) == 2
and isinstance(parts[0], coco.Symbol)
and parts[0].name == "process_file"
and isinstance(parts[1], str)
):
paths.add(parts[1])
return paths
12 changes: 10 additions & 2 deletions src/cocoindex_code/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from cocoindex.connectors import sqlite as coco_sqlite

from .chunking import CHUNKER_REGISTRY, ChunkerFn
from .index_changes import create_file_manifest_app, prepare_file_manifest
from .indexer import indexer_main
from .protocol import (
IndexingProgress,
Expand All @@ -34,6 +35,7 @@
target_sqlite_db_path as _target_sqlite_db_path,
)
from .shared import (
APP_NAME,
CODEBASE_DIR,
EMBEDDER,
INDEXING_EMBED_PARAMS,
Expand All @@ -49,6 +51,7 @@
class Project:
_env: coco.Environment
_app: coco.App[[], None]
_file_manifest_app: coco.App[[], None]
_project_root: Path
_index_lock: asyncio.Lock
_clear_mps_cache_after_index: bool
Expand Down Expand Up @@ -115,6 +118,7 @@ async def _run_index_inner(
if on_progress is not None:
on_progress(progress)
await asyncio.sleep(0.1)
await self._file_manifest_app.update()
finally:
try:
if self._clear_mps_cache_after_index:
Expand Down Expand Up @@ -315,9 +319,12 @@ async def create(

settings = coco.Settings.from_env(cocoindex_db)

target_db = coco_sqlite.connect(str(target_sqlite_db), load_vec=True)
prepare_file_manifest(target_db)

context = coco.ContextProvider()
context.provide(CODEBASE_DIR, project_root)
context.provide(SQLITE_DB, coco_sqlite.connect(str(target_sqlite_db), load_vec=True))
context.provide(SQLITE_DB, target_db)
context.provide(EMBEDDER, embedder)
context.provide(INDEXING_EMBED_PARAMS, dict(indexing_params))
context.provide(QUERY_EMBED_PARAMS, dict(query_params))
Expand All @@ -326,7 +333,7 @@ async def create(
env = coco.Environment(settings, context_provider=context)
app = coco.App(
coco.AppConfig(
name="CocoIndexCode",
name=APP_NAME,
environment=env,
),
indexer_main,
Expand All @@ -335,6 +342,7 @@ async def create(
result = Project.__new__(Project)
result._env = env
result._app = app
result._file_manifest_app = create_file_manifest_app(env)
result._project_root = project_root
result._index_lock = asyncio.Lock()
result._clear_mps_cache_after_index = clear_mps_cache_after_index
Expand Down
1 change: 1 addition & 0 deletions src/cocoindex_code/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

logger = logging.getLogger(__name__)

APP_NAME = "CocoIndexCode"
SBERT_PREFIX = "sbert/"
DEFAULT_LITELLM_MIN_INTERVAL_MS = 5

Expand Down
Loading
Loading