Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,10 @@ and binary version. Different models or Lumen versions automatically get
separate indexes. No files are added to your repo, no `.gitignore` modifications
needed.

You can safely delete the entire `lumen` directory to clear all indexes, or use
`lumen purge` to do it automatically.
You can safely delete the entire `lumen` directory to clear all indexes. Or use
`lumen purge` (current project only), `lumen purge --all` (every index), or
`lumen purge --missing` (drop indexes whose source folder was deleted; add
`--dry-run` to preview).

**Git worktrees** are detected automatically. When you create a new worktree
(`git worktree add` or `claude --worktree`), Lumen finds a sibling worktree's
Expand Down
158 changes: 135 additions & 23 deletions cmd/purge.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,49 @@ import (
"github.com/spf13/cobra"
)

const (
flagAll = "all"
flagMissing = "missing"
flagDryRun = "dry-run"
)

func init() {
registerPurgeFlags(purgeCmd)
rootCmd.AddCommand(purgeCmd)
}

// registerPurgeFlags declares the purge command's flags. Shared by init and
// the test helper so tests exercise the real flag set.
func registerPurgeFlags(cmd *cobra.Command) {
cmd.Flags().Bool(flagAll, false, "Remove every index under the data directory")
cmd.Flags().Bool(flagMissing, false, "Remove indexes whose project folder no longer exists")
cmd.Flags().Bool(flagDryRun, false, "With --missing, list what would be removed without deleting")
}

// lumenDataDir returns the directory holding all lumen index databases.
func lumenDataDir() string {
return filepath.Join(config.XDGDataDir(), "lumen")
}

var purgeCmd = &cobra.Command{
Use: "purge [path...]",
Short: "Remove lumen index data",
Long: `Deletes lumen index databases under ~/.local/share/lumen/.

With no arguments, removes every index (irreversible — all indexes will be
rebuilt on the next search).
With no arguments, removes only the index for the current working directory's
project (the path is normalized to its git root first).

With one or more paths, removes only the index directories associated with
those projects. Each path is normalized to its git root first, then matched
against the project_path recorded inside each index database, so switching
embedding models or using custom models never leaves orphan indexes.
With one or more paths, removes the index directories associated with those
projects. Each path is normalized to its git root, then matched against the
project_path recorded inside each index database, so switching embedding models
or using custom models never leaves orphan indexes.

Indexes created by older binaries that did not record project_path cannot be
matched by path; run "lumen purge" with no arguments to wipe those.
--all Remove every index (irreversible — all indexes will be rebuilt on
the next search). Also clears legacy indexes created by older
binaries that did not record project_path.
--missing Remove every index whose recorded project folder no longer exists
on disk. Only deletes when the folder is confirmed missing.
--dry-run With --missing, list what would be removed without deleting.

Note: a concurrently running indexer for a purged project may log a write
error and exit; re-run "lumen index" afterwards to rebuild.`,
Expand All @@ -54,14 +78,53 @@ error and exit; re-run "lumen index" afterwards to rebuild.`,
}

func runPurge(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return purgeAll(cmd.ErrOrStderr())
all, _ := cmd.Flags().GetBool(flagAll)
missing, _ := cmd.Flags().GetBool(flagMissing)
dryRun, _ := cmd.Flags().GetBool(flagDryRun)

if err := validatePurgeFlags(all, missing, dryRun, len(args)); err != nil {
return err
}

stderr := cmd.ErrOrStderr()
stdout := cmd.OutOrStdout()

switch {
case all:
return purgeAll(stderr)
case missing:
return purgeMissing(stderr, stdout, dryRun)
default:
if len(args) == 0 {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("determine working directory: %w", err)
}
args = []string{cwd}
}
return purgeProjects(stderr, stdout, args)
}
}

// validatePurgeFlags enforces mutual exclusivity of the purge modes.
func validatePurgeFlags(all, missing, dryRun bool, nArgs int) error {
if all && missing {
return fmt.Errorf("--all and --missing cannot be combined")
}
if all && nArgs > 0 {
return fmt.Errorf("--all cannot be combined with explicit paths")
}
if missing && nArgs > 0 {
return fmt.Errorf("--missing cannot be combined with explicit paths")
}
return purgeProjects(cmd.ErrOrStderr(), cmd.OutOrStdout(), args)
if dryRun && !missing {
return fmt.Errorf("--dry-run is only valid with --missing")
}
return nil
}

func purgeAll(stderr io.Writer) error {
dataDir := filepath.Join(config.XDGDataDir(), "lumen")
dataDir := lumenDataDir()

info, err := os.Stat(dataDir)
if err != nil {
Expand All @@ -75,6 +138,19 @@ func purgeAll(stderr io.Writer) error {
return fmt.Errorf("%s is not a directory", dataDir)
}

// Log each index directory before wiping, matching the per-index logging
// used by the other purge modes. Legacy dirs without project_path metadata
// are logged by path alone.
indexMap, legacy, _ := scanIndexes(dataDir)
for projectPath, hashDirs := range indexMap {
Comment thread
Ismael marked this conversation as resolved.
for _, hashDir := range hashDirs {
_, _ = fmt.Fprintf(stderr, "Removed %s (%s)\n", hashDir, projectPath)
}
}
for _, hashDir := range legacy {
_, _ = fmt.Fprintf(stderr, "Removed %s\n", hashDir)
}

if err := os.RemoveAll(dataDir); err != nil {
return fmt.Errorf("remove index data: %w", err)
}
Expand All @@ -83,8 +159,7 @@ func purgeAll(stderr io.Writer) error {
}

func purgeProjects(stderr, stdout io.Writer, args []string) error {
dataDir := filepath.Join(config.XDGDataDir(), "lumen")
indexMap, err := scanIndexes(dataDir)
indexMap, _, err := scanIndexes(lumenDataDir())
if err != nil {
return err
}
Expand All @@ -102,18 +177,54 @@ func purgeProjects(stderr, stdout io.Writer, args []string) error {
return nil
}

func purgeMissing(stderr, stdout io.Writer, dryRun bool) error {
indexMap, _, err := scanIndexes(lumenDataDir())
if err != nil {
return err
}

verb := "Removed"
if dryRun {
verb = "Would remove"
}

removed := 0
for projectPath, hashDirs := range indexMap {
if _, statErr := os.Stat(projectPath); statErr == nil {
continue // folder still exists — keep the index
} else if !os.IsNotExist(statErr) {
// Conservative: any error other than "not exist" must never delete.
_, _ = fmt.Fprintf(stderr, "Skipping %s: cannot stat (%v)\n", projectPath, statErr)
continue
}
for _, hashDir := range hashDirs {
if !dryRun {
if err := os.RemoveAll(hashDir); err != nil {
return fmt.Errorf("remove %s: %w", hashDir, err)
}
}
_, _ = fmt.Fprintf(stderr, "%s %s (%s)\n", verb, hashDir, projectPath)
removed++
}
}

_, _ = fmt.Fprintf(stdout, "%s %d index director%s whose folder no longer exists.\n", verb, removed, pluralY(removed))
return nil
}

// scanIndexes walks dataDir (one level deep) and returns a map of stored
// project_path → list of hash directories for that project. Hash directories
// that can't be read or lack project_path metadata are silently skipped so a
// single broken index never blocks purging of others.
func scanIndexes(dataDir string) (map[string][]string, error) {
result := make(map[string][]string)
// project_path → list of hash directories for that project, plus the hash
// directories that can't be read or lack project_path metadata (legacy
// indexes). Path-based purge modes ignore the legacy slice so a single broken
// index never blocks purging of others; --all uses it to log those dirs.
func scanIndexes(dataDir string) (indexMap map[string][]string, legacy []string, err error) {
indexMap = make(map[string][]string)
entries, err := os.ReadDir(dataDir)
if err != nil {
if os.IsNotExist(err) {
return result, nil
return indexMap, nil, nil
}
return nil, fmt.Errorf("read data dir: %w", err)
return nil, nil, fmt.Errorf("read data dir: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
Expand All @@ -123,11 +234,12 @@ func scanIndexes(dataDir string) (map[string][]string, error) {
dbPath := filepath.Join(hashDir, "index.db")
stored, err := store.ReadMetaAt(dbPath, "project_path")
if err != nil || stored == "" {
legacy = append(legacy, hashDir)
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
result[stored] = append(result[stored], hashDir)
indexMap[stored] = append(indexMap[stored], hashDir)
}
return result, nil
return indexMap, legacy, nil
}

// purgeOneTarget resolves arg to a project root and removes every hash
Expand Down
Loading
Loading