Skip to content

Commit 9f8f889

Browse files
committed
feat(cli): index/enrich --force, enrich --diff, codeiq diff subcommand
1 parent da323e3 commit 9f8f889

3 files changed

Lines changed: 149 additions & 8 deletions

File tree

internal/cli/diff.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"path/filepath"
7+
8+
"github.com/randomcodespace/codeiq/internal/analyzer"
9+
"github.com/randomcodespace/codeiq/internal/cache"
10+
"github.com/randomcodespace/codeiq/internal/detector"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func init() {
15+
registerSubcommand(func() *cobra.Command {
16+
var cachePath string
17+
cmd := &cobra.Command{
18+
Use: "diff [path]",
19+
Short: "Show the cache vs disk delta without touching the graph.",
20+
Long: `Walk the project at [path] and classify each file against the
21+
SQLite analysis cache:
22+
23+
- Added -- on disk, not in cache
24+
- Modified -- path in cache but content hash differs from disk
25+
- Deleted -- in cache, missing from disk
26+
- Unchanged -- path + content hash match cache exactly
27+
28+
Useful for previewing what an incremental ` + "`codeiq index`" + ` /
29+
` + "`codeiq enrich`" + ` run would do. The cache is not modified.`,
30+
Example: ` codeiq diff .
31+
codeiq diff /path/to/repo
32+
codeiq diff /path/to/repo --cache-path /tmp/scratch.sqlite`,
33+
Args: cobra.MaximumNArgs(1),
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
root, err := resolvePath(args)
36+
if err != nil {
37+
return err
38+
}
39+
cp := cachePath
40+
if cp == "" {
41+
cp = filepath.Join(root, ".codeiq", "cache", "codeiq.sqlite")
42+
}
43+
c, err := cache.Open(cp)
44+
if err != nil {
45+
return fmt.Errorf("open cache %s: %w", cp, err)
46+
}
47+
defer c.Close()
48+
a := analyzer.NewAnalyzer(analyzer.Options{
49+
Cache: c,
50+
Registry: detector.Default,
51+
})
52+
d, err := a.Diff(root)
53+
if err != nil {
54+
return err
55+
}
56+
out := map[string]any{
57+
"added": stringsOrEmpty(d.Added),
58+
"modified": stringsOrEmpty(d.Modified),
59+
"deleted": stringsOrEmpty(d.Deleted),
60+
"unchanged": stringsOrEmpty(d.Unchanged),
61+
"counts": map[string]int{
62+
"added": len(d.Added),
63+
"modified": len(d.Modified),
64+
"deleted": len(d.Deleted),
65+
"unchanged": len(d.Unchanged),
66+
},
67+
}
68+
enc := json.NewEncoder(cmd.OutOrStdout())
69+
enc.SetIndent("", " ")
70+
return enc.Encode(out)
71+
},
72+
}
73+
cmd.Flags().StringVar(&cachePath, "cache-path", "",
74+
"Path to the cache file (default: <path>/.codeiq/cache/codeiq.sqlite).")
75+
return cmd
76+
})
77+
}
78+
79+
// stringsOrEmpty replaces a nil slice with an empty one so JSON output is
80+
// `[]` instead of `null` for empty buckets.
81+
func stringsOrEmpty(s []string) []string {
82+
if s == nil {
83+
return []string{}
84+
}
85+
return s
86+
}

internal/cli/enrich.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -9,16 +10,19 @@ import (
910

1011
"github.com/randomcodespace/codeiq/internal/analyzer"
1112
"github.com/randomcodespace/codeiq/internal/cache"
13+
"github.com/randomcodespace/codeiq/internal/detector"
1214
"github.com/spf13/cobra"
1315
)
1416

1517
func init() {
1618
registerSubcommand(func() *cobra.Command {
1719
var (
18-
graphDir string
19-
memProfile string
20-
maxBufferPool int64
21-
copyThreads int
20+
graphDir string
21+
memProfile string
22+
maxBufferPool int64
23+
copyThreads int
24+
force bool
25+
diffOnly bool
2226
)
2327
cmd := &cobra.Command{
2428
Use: "enrich [path]",
@@ -56,7 +60,34 @@ become available and the stdio MCP server can serve clients.`,
5660
return fmt.Errorf("open cache %s: %w", cachePath, err)
5761
}
5862
defer c.Close()
59-
opts := analyzer.EnrichOptions{GraphDir: graphDir}
63+
64+
// --diff: print Diff against the cache as JSON and exit.
65+
// Does not touch the graph. Useful for previewing what an
66+
// incremental enrich would do.
67+
if diffOnly {
68+
a := analyzer.NewAnalyzer(analyzer.Options{Cache: c, Registry: detector.Default})
69+
d, dErr := a.Diff(root)
70+
if dErr != nil {
71+
return dErr
72+
}
73+
out := map[string]any{
74+
"added": d.Added,
75+
"modified": d.Modified,
76+
"deleted": d.Deleted,
77+
"unchanged": d.Unchanged,
78+
"counts": map[string]int{
79+
"added": len(d.Added),
80+
"modified": len(d.Modified),
81+
"deleted": len(d.Deleted),
82+
"unchanged": len(d.Unchanged),
83+
},
84+
}
85+
enc := json.NewEncoder(cmd.OutOrStdout())
86+
enc.SetIndent("", " ")
87+
return enc.Encode(out)
88+
}
89+
90+
opts := analyzer.EnrichOptions{GraphDir: graphDir, Force: force}
6091
if maxBufferPool > 0 {
6192
opts.StoreBufferPoolBytes = uint64(maxBufferPool)
6293
}
@@ -79,9 +110,14 @@ become available and the stdio MCP server can serve clients.`,
79110
}
80111
fmt.Fprintf(cmd.ErrOrStderr(), "heap profile written to %s\n", memProfile)
81112
}
82-
fmt.Fprintf(cmd.OutOrStdout(),
83-
"enrich complete: %d nodes, %d edges, %d services\n",
84-
summary.Nodes, summary.Edges, summary.Services)
113+
if summary.ShortCircuited {
114+
fmt.Fprintln(cmd.OutOrStdout(),
115+
"enrich short-circuited: graph already matches cache manifest")
116+
} else {
117+
fmt.Fprintf(cmd.OutOrStdout(),
118+
"enrich complete: %d nodes, %d edges, %d services\n",
119+
summary.Nodes, summary.Edges, summary.Services)
120+
}
85121
return nil
86122
},
87123
}
@@ -93,6 +129,10 @@ become available and the stdio MCP server can serve clients.`,
93129
"Cap Kuzu BufferPoolSize in bytes (default: 2 GiB; 0 means default).")
94130
cmd.Flags().IntVar(&copyThreads, "copy-threads", 0,
95131
"Cap Kuzu COPY FROM parallelism (default: min(4, GOMAXPROCS); 0 means default).")
132+
cmd.Flags().BoolVar(&force, "force", false,
133+
"Bypass the incremental short-circuit; rebuild the graph from scratch.")
134+
cmd.Flags().BoolVar(&diffOnly, "diff", false,
135+
"Print the cache vs disk delta as JSON and exit without touching the graph.")
96136
return cmd
97137
})
98138
}

internal/cli/index.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func init() {
2222
var (
2323
batchSize int
2424
workers int
25+
force bool
2526
)
2627
cmd := &cobra.Command{
2728
Use: "index [path]",
@@ -68,6 +69,7 @@ Java and Python.`,
6869
Registry: detector.Default,
6970
BatchSize: batchSize,
7071
Workers: workers,
72+
Force: force,
7173
})
7274
stats, err := a.Run(abs)
7375
if err != nil {
@@ -81,13 +83,26 @@ Java and Python.`,
8183
"Deduped: %d nodes, %d edges Dropped: %d phantom edges\n",
8284
stats.DedupedNodes, stats.DedupedEdges, stats.DroppedEdges)
8385
}
86+
// Incremental counters are only meaningful when the cache was
87+
// consulted (i.e. not --force). Print them when any of them is
88+
// non-zero so unchanged re-runs see "Unchanged: N (100%)".
89+
if !force && (stats.Added+stats.Modified+stats.Deleted+stats.Unchanged) > 0 {
90+
line := fmt.Sprintf("Added: %d Modified: %d Deleted: %d Unchanged: %d Cache hits: %d",
91+
stats.Added, stats.Modified, stats.Deleted, stats.Unchanged, stats.CacheHits)
92+
if stats.Files > 0 {
93+
line += fmt.Sprintf(" (%.1f%%)", 100.0*float64(stats.CacheHits)/float64(stats.Files))
94+
}
95+
fmt.Fprintln(cmd.OutOrStdout(), line)
96+
}
8497
return nil
8598
},
8699
}
87100
cmd.Flags().IntVar(&batchSize, "batch-size", 500,
88101
"Number of files processed per batch (default: 500).")
89102
cmd.Flags().IntVarP(&workers, "workers", "w", 0,
90103
"Worker goroutine count (default: 2 * GOMAXPROCS).")
104+
cmd.Flags().BoolVar(&force, "force", false,
105+
"Bypass the incremental cache; re-parse every file even when the content hash hasn't changed.")
91106
return cmd
92107
})
93108
}

0 commit comments

Comments
 (0)