Skip to content

Commit 29da63d

Browse files
committed
feat: add local daemon git layers
1 parent 4392229 commit 29da63d

16 files changed

Lines changed: 1531 additions & 48 deletions

AGENTS.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
This is built on top of [CocoIndex v1](https://cocoindex.io/docs-v1/llms.txt).
2+
3+
4+
## Build and Test Commands
5+
6+
This project uses [uv](https://docs.astral.sh/uv/) for project management.
7+
8+
```bash
9+
uv run mypy . # Type check Python code
10+
uv run pytest tests/ # Run Python tests
11+
```
12+
13+
## Code Conventions
14+
15+
### Internal vs External Modules
16+
17+
We distinguish between **internal modules** (under packages with `_` prefix, e.g. `_internal.*` or `connectors.*._source`) and **external modules** (which users can directly import).
18+
19+
**External modules** (user-facing, e.g. `cocoindex/ops/sentence_transformers.py`):
20+
21+
* Be strict about not leaking implementation details
22+
* Use `__all__` to explicitly list public exports
23+
* Prefix ALL non-public symbols with `_`, including:
24+
* Standard library imports: `import threading as _threading`, `import typing as _typing`
25+
* Third-party imports: `import numpy as _np`, `from numpy.typing import NDArray as _NDArray`
26+
* Internal package imports: `from cocoindex.resources import schema as _schema`
27+
* Exception: `TYPE_CHECKING` imports for type hints don't need prefixing
28+
29+
**Internal modules** (e.g. `cocoindex/_internal/component_ctx.py`):
30+
31+
* Less strict since users shouldn't import these directly
32+
* Standard library and internal imports don't need underscore prefix
33+
* Only prefix symbols that are truly private to the module itself (e.g. `_context_var` for a module-private ContextVar)
34+
35+
### General principles (also covered by `/review-changes`)
36+
37+
- **Top-level imports.** Defer to in-function only for a real circular dependency or a heavy import that isn't always needed.
38+
- **Specific types over `Any`.** When a value enters as a weaker form (`str`, `Any`), convert to the strong type at the earliest point. Don't propagate the weak form.
39+
- **`NamedTuple`/small dataclass for multi-value returns.** Access fields by name at call sites.
40+
- **Single source of truth.** When the same value or logic appears in multiple places, consolidate it.
41+
- **Delete dead code and dead config.** When a change makes something unreachable, the code, the tests, and the knobs all go.
42+
- **Honest names.** The name describes what the code does today.
43+
44+
### Testing Guidelines
45+
46+
We prefer end-to-end tests on user-facing APIs, over unit tests on smaller internal functions. With this said, there're cases where unit tests are necessary, e.g. for internal logic with various situations and edge cases, in which case it's usually easier to cover various scenarios with unit tests.
47+
48+
When tests fail, fix the underlying issue. Don't skip, ignore, or exclude to get a green result.

src/cocoindex_code/_daemon_paths.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ def daemon_runtime_dir() -> Path:
3131
return user_settings_dir()
3232

3333

34+
def daemon_state_dir() -> Path:
35+
"""Return the durable daemon-owned state directory.
36+
37+
This is separate from both project checkout state and daemon runtime files:
38+
it stores shared layer metadata, materialized layer sources, and layer
39+
databases. ``COCOINDEX_CODE_STATE_DIR`` exists mostly for tests and
40+
advanced users; otherwise we follow XDG data-home on Unix-like systems.
41+
"""
42+
override = os.environ.get("COCOINDEX_CODE_STATE_DIR")
43+
if override:
44+
return Path(override)
45+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
46+
if xdg_data_home:
47+
return Path(xdg_data_home) / "cocoindex-code"
48+
return Path.home() / ".local" / "share" / "cocoindex-code"
49+
50+
3451
def connection_family() -> str:
3552
"""Return the multiprocessing connection family for this platform."""
3653
return "AF_PIPE" if sys.platform == "win32" else "AF_UNIX"

src/cocoindex_code/cli.py

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""CLI entry point for cocoindex-code (ccc command)."""
2+
# mypy: disable-error-code=untyped-decorator
23

34
from __future__ import annotations
45

@@ -44,6 +45,8 @@
4445

4546
daemon_app = _typer.Typer(name="daemon", help="Manage the daemon process.")
4647
app.add_typer(daemon_app, name="daemon")
48+
overlay_app = _typer.Typer(name="overlay", help="Inspect and prune Git index overlays.")
49+
app.add_typer(overlay_app, name="overlay")
4750

4851

4952
@app.callback()
@@ -99,6 +102,28 @@ def require_project_root() -> Path:
99102
return root
100103

101104

105+
def require_project_root_from(cwd: Path | None) -> Path:
106+
"""Find the initialized project root for *cwd* or the process CWD."""
107+
if cwd is None:
108+
return require_project_root()
109+
gs_path = user_settings_path()
110+
if not gs_path.is_file():
111+
_typer.echo(
112+
f"Error: Global settings not found: {format_path_for_display(gs_path)}\n"
113+
"Run `ccc init` to create it with default settings.",
114+
err=True,
115+
)
116+
raise _typer.Exit(code=1)
117+
root = find_project_root(cwd)
118+
if root is None:
119+
_typer.echo(
120+
f"Error: Not in an initialized project directory: {format_path_for_display(cwd)}",
121+
err=True,
122+
)
123+
raise _typer.Exit(code=1)
124+
return root
125+
126+
102127
_F = TypeVar("_F", bound=Callable[..., object])
103128

104129

@@ -181,7 +206,12 @@ def print_search_results(response: SearchResponse) -> None:
181206
_typer.echo(r.content)
182207

183208

184-
def _run_index_with_progress(project_root: str) -> None:
209+
def _run_index_with_progress(
210+
project_root: str,
211+
*,
212+
cwd: str | None = None,
213+
base_ref: str | None = None,
214+
) -> None:
185215
"""Run indexing with streaming progress display. Exits on failure."""
186216
from rich.console import Console as _Console
187217
from rich.live import Live as _Live
@@ -208,7 +238,13 @@ def _on_progress(progress: IndexingProgress) -> None:
208238
live.update(_Spinner("dots", last_progress_line))
209239

210240
try:
211-
resp = _client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
241+
resp = _client.index(
242+
project_root,
243+
cwd=cwd,
244+
base_ref=base_ref,
245+
on_progress=_on_progress,
246+
on_waiting=_on_waiting,
247+
)
212248
except RuntimeError as e:
213249
live.stop()
214250
# Let DaemonStartError propagate to the decorator for consistent handling.
@@ -229,6 +265,8 @@ def _on_progress(progress: IndexingProgress) -> None:
229265
def _search_with_wait_spinner(
230266
project_root: str,
231267
query: str,
268+
cwd: str | None = None,
269+
base_ref: str | None = None,
232270
languages: list[str] | None = None,
233271
paths: list[str] | None = None,
234272
limit: int = 10,
@@ -254,6 +292,8 @@ def _on_waiting() -> None:
254292
resp = _client.search(
255293
project_root=project_root,
256294
query=query,
295+
cwd=cwd,
296+
base_ref=base_ref,
257297
languages=languages,
258298
paths=paths,
259299
limit=limit,
@@ -533,13 +573,18 @@ def init(
533573

534574
@app.command()
535575
@_catch_daemon_start_error
536-
def index() -> None:
576+
def index(
577+
cwd: Path | None = _typer.Option(None, "--cwd", help="Workspace path to index"),
578+
base_ref: str | None = _typer.Option(None, "--base", help="Git base ref"),
579+
) -> None:
537580
"""Create/update index for the codebase."""
538581
from . import client as _client
539582

540-
project_root = str(require_project_root())
583+
project_root_path = require_project_root_from(cwd.resolve() if cwd is not None else None)
584+
project_root = str(project_root_path)
585+
request_cwd = str(cwd.resolve()) if cwd is not None else None
541586
print_project_header(project_root)
542-
_run_index_with_progress(project_root)
587+
_run_index_with_progress(project_root, cwd=request_cwd, base_ref=base_ref)
543588
print_index_stats(_client.project_status(project_root))
544589

545590

@@ -552,13 +597,17 @@ def search(
552597
offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
553598
limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
554599
refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
600+
cwd: Path | None = _typer.Option(None, "--cwd", help="Workspace path to search"),
601+
base_ref: str | None = _typer.Option(None, "--base", help="Git base ref"),
555602
) -> None:
556603
"""Semantic search across the codebase."""
557-
project_root = str(require_project_root())
604+
project_root_path = require_project_root_from(cwd.resolve() if cwd is not None else None)
605+
project_root = str(project_root_path)
606+
request_cwd = str(cwd.resolve()) if cwd is not None else None
558607
query_str = " ".join(query)
559608

560609
if refresh:
561-
_run_index_with_progress(project_root)
610+
_run_index_with_progress(project_root, cwd=request_cwd, base_ref=base_ref)
562611

563612
# Default path filter from CWD
564613
paths: list[str] | None = None
@@ -572,6 +621,8 @@ def search(
572621
resp = _search_with_wait_spinner(
573622
project_root=project_root,
574623
query=query_str,
624+
cwd=request_cwd,
625+
base_ref=base_ref,
575626
languages=lang or None,
576627
paths=paths,
577628
limit=limit,
@@ -825,7 +876,7 @@ def mcp() -> None:
825876
async def _run_mcp() -> None:
826877
from .server import create_mcp_server
827878

828-
mcp_server = create_mcp_server(project_root)
879+
mcp_server = create_mcp_server(project_root, cwd=str(Path.cwd().resolve()))
829880
asyncio.create_task(_bg_index(project_root))
830881
await mcp_server.run_stdio_async()
831882

@@ -845,6 +896,47 @@ async def _bg_index(project_root: str) -> None:
845896
pass
846897

847898

899+
# --- Overlay subcommands ---
900+
901+
902+
@overlay_app.command("status")
903+
@_catch_daemon_start_error
904+
def overlay_status(
905+
cwd: Path | None = _typer.Option(None, "--cwd", help="Workspace path to inspect"),
906+
base_ref: str | None = _typer.Option(None, "--base", help="Git base ref"),
907+
) -> None:
908+
"""Show daemon layer metadata for the current Git repo."""
909+
from . import client as _client
910+
911+
project_root_path = require_project_root_from(cwd.resolve() if cwd is not None else None)
912+
request_cwd = str(cwd.resolve()) if cwd is not None else None
913+
resp = _client.overlay_status(str(project_root_path), cwd=request_cwd, base_ref=base_ref)
914+
if resp.repo_id is not None:
915+
_typer.echo(f"Repo: {resp.repo_id}")
916+
if not resp.layers:
917+
_typer.echo("No layers.")
918+
return
919+
for layer in resp.layers:
920+
_typer.echo(
921+
f"{layer.kind:6} {layer.status:8} {layer.layer_id} "
922+
f"ref={layer.ref_name or '-'} affected={layer.affected_count} "
923+
f"tombstones={layer.tombstoned_count}"
924+
)
925+
926+
927+
@overlay_app.command("prune")
928+
@_catch_daemon_start_error
929+
def overlay_prune() -> None:
930+
"""Prune expired dirty and branch overlays."""
931+
from . import client as _client
932+
933+
resp = _client.overlay_prune()
934+
if not resp.pruned_layer_ids:
935+
_typer.echo("No expired layers pruned.")
936+
return
937+
_typer.echo(f"Pruned {len(resp.pruned_layer_ids)} layer(s).")
938+
939+
848940
# --- Daemon subcommands ---
849941

850942

src/cocoindex_code/client.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
IndexRequest,
4141
IndexResponse,
4242
IndexWaitingNotice,
43+
OverlayPruneRequest,
44+
OverlayPruneResponse,
45+
OverlayStatusRequest,
46+
OverlayStatusResponse,
4347
ProjectStatusRequest,
4448
ProjectStatusResponse,
4549
RemoveProjectRequest,
@@ -242,14 +246,20 @@ def _send(req: Request) -> Response:
242246

243247
def index(
244248
project_root: str,
249+
cwd: str | None = None,
250+
base_ref: str | None = None,
245251
on_progress: Callable[[IndexingProgress], None] | None = None,
246252
on_waiting: Callable[[], None] | None = None,
247253
) -> IndexResponse:
248254
"""Request indexing with streaming progress. Blocks until complete."""
249255
project_root = normalize_input_path(project_root)
256+
if cwd is not None:
257+
cwd = normalize_input_path(cwd)
250258
conn = _connect_and_handshake()
251259
try:
252-
conn.send_bytes(encode_request(IndexRequest(project_root=project_root)))
260+
conn.send_bytes(
261+
encode_request(IndexRequest(project_root=project_root, cwd=cwd, base_ref=base_ref))
262+
)
253263
while True:
254264
try:
255265
data = conn.recv_bytes()
@@ -276,6 +286,8 @@ def index(
276286
def search(
277287
project_root: str,
278288
query: str,
289+
cwd: str | None = None,
290+
base_ref: str | None = None,
279291
languages: list[str] | None = None,
280292
paths: list[str] | None = None,
281293
limit: int = 5,
@@ -289,13 +301,17 @@ def search(
289301
until the final ``SearchResponse``.
290302
"""
291303
project_root = normalize_input_path(project_root)
304+
if cwd is not None:
305+
cwd = normalize_input_path(cwd)
292306
conn = _connect_and_handshake()
293307
try:
294308
conn.send_bytes(
295309
encode_request(
296310
SearchRequest(
297311
project_root=project_root,
298312
query=query,
313+
cwd=cwd,
314+
base_ref=base_ref,
299315
languages=languages,
300316
paths=paths,
301317
limit=limit,
@@ -345,6 +361,21 @@ def daemon_env() -> DaemonEnvResponse:
345361
return _send(DaemonEnvRequest()) # type: ignore[return-value]
346362

347363

364+
def overlay_status(
365+
project_root: str,
366+
cwd: str | None = None,
367+
base_ref: str | None = None,
368+
) -> OverlayStatusResponse:
369+
project_root = normalize_input_path(project_root)
370+
if cwd is not None:
371+
cwd = normalize_input_path(cwd)
372+
return _send(OverlayStatusRequest(project_root=project_root, cwd=cwd, base_ref=base_ref)) # type: ignore[return-value]
373+
374+
375+
def overlay_prune() -> OverlayPruneResponse:
376+
return _send(OverlayPruneRequest()) # type: ignore[return-value]
377+
378+
348379
def doctor(
349380
project_root: str | None = None,
350381
on_result: Callable[[DoctorCheckResult], None] | None = None,

0 commit comments

Comments
 (0)