Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/worktrees/feat/nomic-embed-code-7b
Submodule nomic-embed-code-7b added at 5cb827
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ Two skills are also available: `/lumen:doctor` (health check) and
re-index in seconds after the first run
- **11 language families** — Go, Python, TypeScript, JavaScript, Rust, Ruby,
Java, PHP, C/C++, C#
- **Git worktree support** — worktrees share index data automatically; a new
worktree seeds from a sibling's index and only re-indexes changed files,
turning minutes of embedding into seconds
- **Zero cloud** — embeddings stay on your machine; no data leaves your network
- **Ollama and LM Studio** — works with either local embedding backend

Expand Down Expand Up @@ -243,6 +246,12 @@ needed.
You can safely delete the entire `lumen` directory to clear all indexes, or use
`lumen purge` to do it automatically.

**Git worktrees** are detected automatically. When you create a new worktree
(`git worktree add` or `claude --worktree`), Lumen finds a sibling worktree's
existing index and copies it as a seed. The Merkle tree diff then re-indexes
only the files that actually differ — typically a handful of files instead of
the entire codebase. No configuration needed; it just works.

## CLI Reference

Download the binary from the
Expand Down
3 changes: 3 additions & 0 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ func generateSessionContext(mcpName, cwd string) string {

dbPath := config.DBPathForProject(cwd, cfg.Model)
if _, err := os.Stat(dbPath); err != nil {
if donorPath := config.FindDonorIndex(cwd, cfg.Model); donorPath != "" {
return directive + " Sibling worktree index found — fast incremental re-index on first search."
}
return directive + " No index yet — auto-created on first call."
}

Expand Down
9 changes: 9 additions & 0 deletions cmd/stdio.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ func (ic *indexerCache) getOrCreate(projectPath string, preferredRoot string) (*
return nil, "", fmt.Errorf("create db directory: %w", err)
}

// Seed from sibling worktree if this is a new index.
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
if donorPath := config.FindDonorIndex(effectiveRoot, ic.model); donorPath != "" {
if _, seedErr := index.SeedFromDonor(donorPath, dbPath); seedErr != nil {
_, _ = fmt.Fprintf(os.Stderr, "lumen: seed from worktree failed: %v\n", seedErr)
}
}
}

idx, err := index.NewIndexer(dbPath, ic.embedder, ic.cfg.MaxChunkTokens)
if err != nil {
return nil, "", fmt.Errorf("create indexer: %w", err)
Expand Down
80 changes: 80 additions & 0 deletions internal/config/seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import (
"os"
"path/filepath"
"slices"

"github.com/ory/lumen/internal/git"
)

// FindDonorIndex searches sibling git worktrees for an existing lumen index
// DB that uses the same model and IndexVersion. Returns the DB path of the
// most recently modified candidate, or "" if no suitable donor exists.
func FindDonorIndex(projectPath, model string) string {
return FindDonorIndexBase(XDGDataDir(), projectPath, model)
}

// FindDonorIndexBase is like FindDonorIndex but accepts an explicit data
// directory, making it safe for testing without side effects.
func FindDonorIndexBase(dataDir, projectPath, model string) string {
worktrees, err := git.ListWorktrees(projectPath)
if err != nil || len(worktrees) < 2 {
return ""
}

// Resolve symlinks so comparisons work on macOS (/var → /private/var).
resolvedSelf := projectPath
if r, err := filepath.EvalSymlinks(projectPath); err == nil {
resolvedSelf = r
}

type candidate struct {
path string
modTime int64
}
var candidates []candidate

for _, wt := range worktrees {
if wt == resolvedSelf {
continue
}
dbPath := DBPathForProjectBase(dataDir, wt, model)
info, err := os.Stat(dbPath)
if err != nil {
continue
}
candidates = append(candidates, candidate{path: dbPath, modTime: info.ModTime().UnixNano()})
}

if len(candidates) == 0 {
return ""
}

// Pick the most recently modified index.
slices.SortFunc(candidates, func(a, b candidate) int {
if a.modTime > b.modTime {
return -1
}
if a.modTime < b.modTime {
return 1
}
return 0
})

return candidates[0].path
}
117 changes: 117 additions & 0 deletions internal/config/seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestFindDonorIndex_NoSiblings(t *testing.T) {
dir := t.TempDir()
result := FindDonorIndexBase(dir, "/some/path", "model")
if result != "" {
t.Fatalf("expected empty string, got %q", result)
}
}

func TestFindDonorIndex_WithSibling(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}

// Create a git repo with a worktree.
main := t.TempDir()
gitRun(t, main, "git", "init")
gitRun(t, main, "git", "commit", "--allow-empty", "-m", "init")

wt := filepath.Join(t.TempDir(), "wt")
gitRun(t, main, "git", "worktree", "add", wt)

// Resolve symlinks so DB path matches what ListWorktrees returns.
mainResolved, err := filepath.EvalSymlinks(main)
if err != nil {
t.Fatal(err)
}

// Create a fake donor index for the main worktree using resolved path.
dataDir := t.TempDir()
donorDB := DBPathForProjectBase(dataDir, mainResolved, "test-model")
if err := os.MkdirAll(filepath.Dir(donorDB), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(donorDB, []byte("fake-db"), 0o644); err != nil {
t.Fatal(err)
}

// From the worktree, we should find the main's index.
result := FindDonorIndexBase(dataDir, wt, "test-model")
if result != donorDB {
t.Fatalf("expected %q, got %q", donorDB, result)
}
}

func TestFindDonorIndex_WrongModel(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}

main := t.TempDir()
gitRun(t, main, "git", "init")
gitRun(t, main, "git", "commit", "--allow-empty", "-m", "init")

wt := filepath.Join(t.TempDir(), "wt")
gitRun(t, main, "git", "worktree", "add", wt)

// Resolve symlinks for DB path consistency.
mainResolved, err := filepath.EvalSymlinks(main)
if err != nil {
t.Fatal(err)
}

// Create index for different model.
dataDir := t.TempDir()
donorDB := DBPathForProjectBase(dataDir, mainResolved, "model-a")
if err := os.MkdirAll(filepath.Dir(donorDB), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(donorDB, []byte("fake-db"), 0o644); err != nil {
t.Fatal(err)
}

// Looking for model-b should not find model-a's index.
result := FindDonorIndexBase(dataDir, wt, "model-b")
if result != "" {
t.Fatalf("expected empty string for wrong model, got %q", result)
}
}

func gitRun(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=test",
"GIT_COMMITTER_EMAIL=test@test.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %v failed: %v\n%s", name, args, err, out)
}
}
92 changes: 92 additions & 0 deletions internal/git/worktree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2026 Aeneas Rekkas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package git provides utilities for detecting git worktrees and finding
// sibling worktree paths.
package git

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

// IsWorktree reports whether projectPath is a git worktree (as opposed to
// the main working tree). A worktree has a .git file pointing at the shared
// .git directory, whereas the main repo has a .git directory.
func IsWorktree(projectPath string) bool {
info, err := os.Lstat(filepath.Join(projectPath, ".git"))
if err != nil {
return false
}
return !info.IsDir()
}

// CommonDir returns the shared .git directory for the repository containing
// projectPath, by running "git rev-parse --git-common-dir". Returns an error
// if git is not available or projectPath is not inside a git repository.
func CommonDir(projectPath string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
cmd.Dir = projectPath
out, err := cmd.Output()
if err != nil {
return "", err
}

dir := strings.TrimSpace(string(out))
if !filepath.IsAbs(dir) {
dir = filepath.Join(projectPath, dir)
}
dir = filepath.Clean(dir)
// Resolve symlinks so paths are comparable across macOS
// /var → /private/var symlink boundaries.
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
return dir, nil
}

// ListWorktrees returns the absolute paths of all worktrees (including the
// main working tree) for the repository containing projectPath. Returns nil
// if git is not available or projectPath is not inside a git repository.
func ListWorktrees(projectPath string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "git", "worktree", "list", "--porcelain")
cmd.Dir = projectPath
out, err := cmd.Output()
if err != nil {
return nil, err
}

var paths []string
for _, line := range strings.Split(string(out), "\n") {
if path, ok := strings.CutPrefix(line, "worktree "); ok {
// Resolve symlinks so paths are comparable across macOS
// /var → /private/var symlink boundaries.
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
paths = append(paths, path)
}
}
return paths, nil
}
Loading
Loading