Skip to content

Commit 51df1e1

Browse files
committed
feat: add local daemon git layers
1 parent 4392229 commit 51df1e1

16 files changed

Lines changed: 1530 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: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444

4545
daemon_app = _typer.Typer(name="daemon", help="Manage the daemon process.")
4646
app.add_typer(daemon_app, name="daemon")
47+
overlay_app = _typer.Typer(name="overlay", help="Inspect and prune Git index overlays.")
48+
app.add_typer(overlay_app, name="overlay")
4749

4850

4951
@app.callback()
@@ -99,6 +101,28 @@ def require_project_root() -> Path:
99101
return root
100102

101103

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

104128

@@ -181,7 +205,12 @@ def print_search_results(response: SearchResponse) -> None:
181205
_typer.echo(r.content)
182206

183207

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

210239
try:
211-
resp = _client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
240+
resp = _client.index(
241+
project_root,
242+
cwd=cwd,
243+
base_ref=base_ref,
244+
on_progress=_on_progress,
245+
on_waiting=_on_waiting,
246+
)
212247
except RuntimeError as e:
213248
live.stop()
214249
# Let DaemonStartError propagate to the decorator for consistent handling.
@@ -229,6 +264,8 @@ def _on_progress(progress: IndexingProgress) -> None:
229264
def _search_with_wait_spinner(
230265
project_root: str,
231266
query: str,
267+
cwd: str | None = None,
268+
base_ref: str | None = None,
232269
languages: list[str] | None = None,
233270
paths: list[str] | None = None,
234271
limit: int = 10,
@@ -254,6 +291,8 @@ def _on_waiting() -> None:
254291
resp = _client.search(
255292
project_root=project_root,
256293
query=query,
294+
cwd=cwd,
295+
base_ref=base_ref,
257296
languages=languages,
258297
paths=paths,
259298
limit=limit,
@@ -533,13 +572,18 @@ def init(
533572

534573
@app.command()
535574
@_catch_daemon_start_error
536-
def index() -> None:
575+
def index(
576+
cwd: Path | None = _typer.Option(None, "--cwd", help="Workspace path to index"),
577+
base_ref: str | None = _typer.Option(None, "--base", help="Git base ref"),
578+
) -> None:
537579
"""Create/update index for the codebase."""
538580
from . import client as _client
539581

540-
project_root = str(require_project_root())
582+
project_root_path = require_project_root_from(cwd.resolve() if cwd is not None else None)
583+
project_root = str(project_root_path)
584+
request_cwd = str(cwd.resolve()) if cwd is not None else None
541585
print_project_header(project_root)
542-
_run_index_with_progress(project_root)
586+
_run_index_with_progress(project_root, cwd=request_cwd, base_ref=base_ref)
543587
print_index_stats(_client.project_status(project_root))
544588

545589

@@ -552,13 +596,17 @@ def search(
552596
offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
553597
limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
554598
refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
599+
cwd: Path | None = _typer.Option(None, "--cwd", help="Workspace path to search"),
600+
base_ref: str | None = _typer.Option(None, "--base", help="Git base ref"),
555601
) -> None:
556602
"""Semantic search across the codebase."""
557-
project_root = str(require_project_root())
603+
project_root_path = require_project_root_from(cwd.resolve() if cwd is not None else None)
604+
project_root = str(project_root_path)
605+
request_cwd = str(cwd.resolve()) if cwd is not None else None
558606
query_str = " ".join(query)
559607

560608
if refresh:
561-
_run_index_with_progress(project_root)
609+
_run_index_with_progress(project_root, cwd=request_cwd, base_ref=base_ref)
562610

563611
# Default path filter from CWD
564612
paths: list[str] | None = None
@@ -572,6 +620,8 @@ def search(
572620
resp = _search_with_wait_spinner(
573621
project_root=project_root,
574622
query=query_str,
623+
cwd=request_cwd,
624+
base_ref=base_ref,
575625
languages=lang or None,
576626
paths=paths,
577627
limit=limit,
@@ -825,7 +875,7 @@ def mcp() -> None:
825875
async def _run_mcp() -> None:
826876
from .server import create_mcp_server
827877

828-
mcp_server = create_mcp_server(project_root)
878+
mcp_server = create_mcp_server(project_root, cwd=str(Path.cwd().resolve()))
829879
asyncio.create_task(_bg_index(project_root))
830880
await mcp_server.run_stdio_async()
831881

@@ -845,6 +895,47 @@ async def _bg_index(project_root: str) -> None:
845895
pass
846896

847897

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

850941

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)