From 98c533cdc3f95eb78bc8cbaac26e162b5a050c6a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:41:42 +0200 Subject: [PATCH] fix(watch): Track configured file counts separately Co-Authored-By: GPT-5.6 Sol --- cmd/context.go | 8 +- cmd/context_test.go | 28 +++++ cmd/hooks.go | 31 ++++- cmd/hooks_more_test.go | 31 +++++ scanner/walker.go | 12 +- watch/daemon.go | 76 ++++++++++-- watch/events.go | 76 ++++++++++-- watch/more_test.go | 255 +++++++++++++++++++++++++++++++++++++++++ watch/types.go | 50 +++++--- 9 files changed, 523 insertions(+), 44 deletions(-) diff --git a/cmd/context.go b/cmd/context.go index bfa87b3..20bb7cf 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -193,6 +193,7 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext { ctx := ProjectContext{ Root: root, } + configuredFileCountKnown := false // Get branch if branch, ok := gitCurrentBranch(root); ok { @@ -201,7 +202,10 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext { // Count files and detect languages from daemon state if state := watch.ReadState(root); state != nil { - ctx.FileCount = state.FileCount + if count, ok := state.ConfiguredCount(); ok { + ctx.FileCount = count + configuredFileCountKnown = true + } ctx.HubCount = len(state.Hubs) if len(state.Hubs) > 5 { ctx.TopHubs = state.Hubs[:5] @@ -246,7 +250,7 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext { sort.Strings(ctx.Languages) // Fallback file count from quick scan if daemon wasn't available - if ctx.FileCount == 0 && len(ctx.Languages) > 0 { + if !configuredFileCountKnown && ctx.FileCount == 0 && len(ctx.Languages) > 0 { ctx.FileCount = countSourceFiles(root) } diff --git a/cmd/context_test.go b/cmd/context_test.go index aeb734c..f356724 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -1,10 +1,14 @@ package cmd import ( + "encoding/json" "os" "path/filepath" "reflect" "testing" + "time" + + "codemap/watch" ) func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) { @@ -71,6 +75,30 @@ func TestCountSourceFilesReturnsZeroWhenConfiguredScanFails(t *testing.T) { } } +func TestBuildContextEnvelopeFallsBackToConfiguredScanForLegacyState(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, ".codemap", "config.json"), `{"only":["go"]}`) + mustWriteFile(t, filepath.Join(root, "main.go"), "package main\n") + mustWriteFile(t, filepath.Join(root, "notes.txt"), "not source\n") + + legacyState, err := json.Marshal(watch.State{ + UpdatedAt: time.Now(), + FileCount: 999, + }) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(root, ".codemap", "state.json"), string(legacyState)) + + cachedFileCount = -1 + t.Cleanup(func() { cachedFileCount = -1 }) + envelope := buildContextEnvelope(root, "", true) + + if envelope.Project.FileCount != 1 { + t.Fatalf("file count = %d, want configured source count 1", envelope.Project.FileCount) + } +} + func mustWriteFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/cmd/hooks.go b/cmd/hooks.go index 7260309..fd2adaa 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -333,10 +333,7 @@ func hookSessionStart(root string) error { if state == nil && watch.IsRunning(root) { state = waitForDaemonState(root, 2*time.Second) } - if state != nil { - fileCount = state.FileCount - fileCountKnown = true - } + fileCount, fileCountKnown = configuredStateFileCount(root, state) projCfg := config.Load(root) structureBudget := projCfg.SessionStartOutputBytes() maxHubs := projCfg.HubDisplayLimit() @@ -395,7 +392,7 @@ func hookSessionStart(root string) error { importers := len(info.Importers[hub]) fmt.Printf(" ⚠️ HUB FILE: %s (imported by %d files)\n", hub, importers) } - } else if fileCountKnown && fileCount > limits.LargeRepoFileCount { + } else if shouldSkipHubAnalysis(fileCount, fileCountKnown) { fmt.Printf("ℹ️ Hub analysis skipped for large repo (%d files)\n", fileCount) } @@ -446,7 +443,7 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool, projCfg con // Unknown file count typically means daemon state is not ready. // Use cheap git-based output in that case to avoid startup blowups. - if !fileCountKnown || fileCount > limits.LargeRepoFileCount { + if shouldUseLightweightDiff(fileCount, fileCountKnown) { showLightweightDiffVsMain(root) return } @@ -482,6 +479,28 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool, projCfg con fmt.Print(output) } +func configuredStateFileCount(root string, state *watch.State) (int, bool) { + if count, ok := state.ConfiguredCount(); ok { + return count, true + } + if state == nil { + return 0, false + } + files, err := scanner.ScanConfiguredFiles(root, scanner.NewGitIgnoreCache(root)) + if err != nil { + return 0, false + } + return len(files), true +} + +func shouldSkipHubAnalysis(fileCount int, fileCountKnown bool) bool { + return fileCountKnown && fileCount > limits.LargeRepoFileCount +} + +func shouldUseLightweightDiff(fileCount int, fileCountKnown bool) bool { + return !fileCountKnown || fileCount > limits.LargeRepoFileCount +} + func showLightweightDiffVsMain(root string) { cmd := exec.Command("git", "diff", "--name-only", "main...HEAD") cmd.Dir = root diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 866345f..01b9f3e 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -278,6 +278,37 @@ func TestShowDiffVsMainUsesLightweightPath(t *testing.T) { } } +func TestConfiguredStateFileCountDrivesSessionStartGates(t *testing.T) { + root := t.TempDir() + writeProjectConfig(t, root, config.ProjectConfig{Only: []string{"go"}}) + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("not source\n"), 0o644); err != nil { + t.Fatal(err) + } + + configuredCount := 1 + state := &watch.State{ + FileCount: limits.LargeRepoFileCount + 1, + ConfiguredFileCount: &configuredCount, + } + fileCount, known := configuredStateFileCount(root, state) + + if !known || fileCount != configuredCount { + t.Fatalf("configuredStateFileCount() = %d, %v; want %d, true", fileCount, known, configuredCount) + } + if got, want := limits.AdaptiveDepth(fileCount), limits.AdaptiveDepth(configuredCount); got != want { + t.Fatalf("adaptive depth = %d, want %d", got, want) + } + if shouldSkipHubAnalysis(fileCount, known) { + t.Fatal("expected configured small repo to keep hub analysis enabled") + } + if shouldUseLightweightDiff(fileCount, known) { + t.Fatal("expected configured small repo to keep rich diff analysis enabled") + } +} + func TestExtractFilePathAndEditHooks(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "pkg", "types.go") diff --git a/scanner/walker.go b/scanner/walker.go index 9d4e28a..0157a82 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -303,7 +303,17 @@ type Filters struct { // ScanConfiguredFiles scans using the active setup root's project filters. func ScanConfiguredFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) { cfg := config.Load(root) - return ScanFiles(root, cache, cfg.Only, cfg.Exclude) + files, err := ScanFiles(root, cache, cfg.Only, cfg.Exclude) + if err != nil { + return nil, err + } + configured := files[:0] + for _, file := range files { + if path := filepath.ToSlash(file.Path); path != ".codemap" && !strings.HasPrefix(path, ".codemap/") { + configured = append(configured, file) + } + } + return configured, nil } func filterAnalyses(analyses []FileAnalysis, filters Filters) []FileAnalysis { diff --git a/watch/daemon.go b/watch/daemon.go index 54936bf..3140042 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "codemap/config" "codemap/limits" "codemap/scanner" @@ -56,13 +57,14 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { done: make(chan struct{}), eventLog: filepath.Join(absRoot, ".codemap", "events.log"), graph: &Graph{ - Root: absRoot, - Files: make(map[string]*scanner.FileInfo), - DepCtx: make(map[string]*DepContext), - State: make(map[string]*FileState), - Events: make([]Event, 0), - WorkingSet: NewWorkingSet(), - IsGitRepo: isGitRepo, + Root: absRoot, + Files: make(map[string]*scanner.FileInfo), + ConfiguredFiles: make(map[string]struct{}), + DepCtx: make(map[string]*DepContext), + State: make(map[string]*FileState), + Events: make([]Event, 0), + WorkingSet: NewWorkingSet(), + IsGitRepo: isGitRepo, }, } @@ -84,8 +86,8 @@ func (d *Daemon) Start() error { // Compute dependency graph (best effort). Skip on very large repos to avoid // expensive startup memory/CPU spikes in background hook flows. - fileCount := d.FileCount() - if fileCount <= limits.LargeRepoFileCount { + fileCount := d.ConfiguredFileCount() + if shouldComputeDependencyGraph(fileCount) { d.computeDeps() } else if d.verbose { fmt.Printf("[watch] Skipping dependency graph for large repo (%d files)\n", fileCount) @@ -95,6 +97,11 @@ func (d *Daemon) Start() error { if err := d.addWatchDirs(); err != nil { return fmt.Errorf("failed to add watch dirs: %w", err) } + // The hidden state directory is otherwise skipped. Watch it so config edits + // can refresh the configured-file inventory; other state files stay ignored. + if err := d.watcher.Add(codemapDir); err != nil { + return fmt.Errorf("failed to watch .codemap dir: %w", err) + } // Write initial state for hooks to read immediately d.writeState() @@ -144,6 +151,19 @@ func (d *Daemon) FileCount() int { return len(d.graph.Files) } +// ConfiguredFileCount returns the number of files included by the active +// project filters. FileCount intentionally continues to report all tracked +// files for watch/activity consumers. +func (d *Daemon) ConfiguredFileCount() int { + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + return len(d.graph.ConfiguredFiles) +} + +func shouldComputeDependencyGraph(fileCount int) bool { + return fileCount <= limits.LargeRepoFileCount +} + // WriteInitialState writes state after initial scan (for hooks) func (d *Daemon) WriteInitialState() { d.writeState() @@ -157,9 +177,14 @@ func (d *Daemon) fullScan() error { if err != nil { return err } + configuredFiles, err := scanner.ScanConfiguredFiles(d.root, d.gitCache) + if err != nil { + return err + } d.graph.mu.Lock() d.graph.Files = make(map[string]*scanner.FileInfo) + d.graph.ConfiguredFiles = make(map[string]struct{}, len(configuredFiles)) d.graph.State = make(map[string]*FileState) for i := range files { f := &files[i] @@ -169,6 +194,9 @@ func (d *Daemon) fullScan() error { d.graph.State[f.Path] = &FileState{Lines: lines, Size: f.Size} } } + for _, file := range configuredFiles { + d.graph.ConfiguredFiles[file.Path] = struct{}{} + } d.graph.LastScan = time.Now() d.graph.mu.Unlock() @@ -179,6 +207,36 @@ func (d *Daemon) fullScan() error { return nil } +func (d *Daemon) isConfiguredFile(path string) bool { + cfg := config.Load(d.root) + return scanner.MatchesFilters(path, filepath.Ext(path), cfg.Only, cfg.Exclude) +} + +func (d *Daemon) refreshConfiguredFiles(resetIgnoreCache bool) error { + gitCache := d.gitCache + if resetIgnoreCache { + gitCache = scanner.NewGitIgnoreCache(d.root) + d.gitCache = gitCache + } + files, err := scanner.ScanConfiguredFiles(d.root, gitCache) + if err != nil { + return err + } + configured := make(map[string]struct{}, len(files)) + for _, file := range files { + configured[file.Path] = struct{}{} + } + d.graph.mu.Lock() + d.graph.ConfiguredFiles = configured + // Filters define dependency membership too. Do not publish the previous + // graph under a new configured-file count; rebuild lazily on restart. + d.graph.FileGraph = nil + d.graph.DepCtx = make(map[string]*DepContext) + d.graph.HasDeps = false + d.graph.mu.Unlock() + return nil +} + // computeDeps builds the file-to-file dependency graph func (d *Daemon) computeDeps() { start := time.Now() diff --git a/watch/events.go b/watch/events.go index 50271bb..ed548d3 100644 --- a/watch/events.go +++ b/watch/events.go @@ -192,6 +192,12 @@ func (d *Daemon) eventLoop() { return } now := time.Now() + if resetIgnoreCache, control := d.filterControlEvent(event.Name); control { + if err := d.refreshConfiguredFiles(resetIgnoreCache); err == nil { + d.writeState() + } + continue + } for _, pending := range debouncer.takeDueBeforeEvent(event, now) { d.handleEvent(pending) } @@ -205,9 +211,11 @@ func (d *Daemon) eventLoop() { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { // Directory create - let it through to handleEvent } else { + d.handleConfiguredMembershipEvent(event) continue } } else { + d.handleConfiguredMembershipEvent(event) continue } } @@ -240,6 +248,45 @@ func (d *Daemon) eventLoop() { } } +func (d *Daemon) filterControlEvent(path string) (resetIgnoreCache, control bool) { + clean := filepath.Clean(path) + if clean == filepath.Join(d.root, ".codemap", "config.json") { + return false, true + } + if filepath.Base(clean) == ".gitignore" { + return true, true + } + return false, false +} + +func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) { + relPath, err := filepath.Rel(d.root, event.Name) + if err != nil { + return + } + if path := filepath.ToSlash(relPath); path == ".codemap" || strings.HasPrefix(path, ".codemap/") { + return + } + present := event.Op&(fsnotify.Remove|fsnotify.Rename) == 0 + if present { + info, err := os.Stat(event.Name) + if err != nil || info.IsDir() || (d.gitCache != nil && d.gitCache.ShouldIgnore(event.Name)) || !d.isConfiguredFile(relPath) { + present = false + } + } + d.graph.mu.Lock() + _, existed := d.graph.ConfiguredFiles[relPath] + if present { + d.graph.ConfiguredFiles[relPath] = struct{}{} + } else { + delete(d.graph.ConfiguredFiles, relPath) + } + d.graph.mu.Unlock() + if present != existed { + d.writeState() + } +} + func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) debounceAction { if !debouncer.shouldSkip(event, now) { return debounceProcess @@ -339,6 +386,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { // files); if the path disappeared, clear any stale tracked entry. if os.IsNotExist(err) { delete(d.graph.Files, relPath) + delete(d.graph.ConfiguredFiles, relPath) delete(d.graph.State, relPath) } d.graph.mu.Unlock() @@ -393,6 +441,14 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { Size: info.Size(), Ext: filepath.Ext(relPath), } + if d.graph.ConfiguredFiles == nil { + d.graph.ConfiguredFiles = make(map[string]struct{}) + } + if d.isConfiguredFile(relPath) { + d.graph.ConfiguredFiles[relPath] = struct{}{} + } else { + delete(d.graph.ConfiguredFiles, relPath) + } case "REMOVE", "RENAME": // Record what was lost @@ -402,6 +458,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { event.SizeDelta = -prev.Size } delete(d.graph.Files, relPath) + delete(d.graph.ConfiguredFiles, relPath) delete(d.graph.State, relPath) } @@ -552,14 +609,19 @@ func (d *Daemon) writeState() { } eventsCopy := append([]Event(nil), events...) + configuredFileCount := len(d.graph.ConfiguredFiles) + if d.graph.ConfiguredFiles == nil { + configuredFileCount = len(d.graph.Files) + } state := State{ - UpdatedAt: time.Now(), - FileCount: len(d.graph.Files), - Hubs: []string{}, - Importers: map[string][]string{}, - Imports: map[string][]string{}, - RecentEvents: eventsCopy, - WorkingSet: d.graph.WorkingSet.Snapshot(50), + UpdatedAt: time.Now(), + FileCount: len(d.graph.Files), + ConfiguredFileCount: &configuredFileCount, + Hubs: []string{}, + Importers: map[string][]string{}, + Imports: map[string][]string{}, + RecentEvents: eventsCopy, + WorkingSet: d.graph.WorkingSet.Snapshot(50), } if d.graph.FileGraph != nil { state.Hubs = d.graph.FileGraph.HubFiles() diff --git a/watch/more_test.go b/watch/more_test.go index 660346e..d7cf1b4 100644 --- a/watch/more_test.go +++ b/watch/more_test.go @@ -2,6 +2,7 @@ package watch import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -9,7 +10,10 @@ import ( "testing" "time" + "codemap/limits" "codemap/scanner" + + "github.com/fsnotify/fsnotify" ) func waitForWatchCondition(t *testing.T, timeout time.Duration, cond func() bool) { @@ -121,6 +125,257 @@ func TestIsFileDirty(t *testing.T) { } } +func TestConfiguredFileCountTracksConfiguredFilesAcrossEvents(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"]}`), 0o644); err != nil { + t.Fatal(err) + } + goFile := filepath.Join(root, "main.go") + textFile := filepath.Join(root, "notes.txt") + if err := os.WriteFile(goFile, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(textFile, []byte("not source\n"), 0o644); err != nil { + t.Fatal(err) + } + + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.Stop() + if err := d.fullScan(); err != nil { + t.Fatal(err) + } + + if got := d.FileCount(); got <= 1 { + t.Fatalf("tracked file count = %d, want it to remain broader than configured files", got) + } + if got := d.ConfiguredFileCount(); got != 1 { + t.Fatalf("configured file count = %d, want 1", got) + } + + addedGo := filepath.Join(root, "added.go") + if err := os.WriteFile(addedGo, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + d.handleEvent(fsnotify.Event{Name: addedGo, Op: fsnotify.Create}) + if got := d.ConfiguredFileCount(); got != 2 { + t.Fatalf("configured file count after Go create = %d, want 2", got) + } + + addedText := filepath.Join(root, "added.txt") + if err := os.WriteFile(addedText, []byte("not source\n"), 0o644); err != nil { + t.Fatal(err) + } + d.handleEvent(fsnotify.Event{Name: addedText, Op: fsnotify.Create}) + if got := d.ConfiguredFileCount(); got != 2 { + t.Fatalf("configured file count after text create = %d, want 2", got) + } + + if err := os.Remove(addedGo); err != nil { + t.Fatal(err) + } + d.handleEvent(fsnotify.Event{Name: addedGo, Op: fsnotify.Remove}) + if got := d.ConfiguredFileCount(); got != 1 { + t.Fatalf("configured file count after Go remove = %d, want 1", got) + } +} + +func TestConfiguredFileCountTracksLiveFilterChanges(t *testing.T) { + root := t.TempDir() + codemapDir := filepath.Join(root, ".codemap") + if err := os.MkdirAll(codemapDir, 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(codemapDir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"only":["go"]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "query.sql"), []byte("select 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + if err := d.Start(); err != nil { + t.Fatal(err) + } + defer d.Stop() + configured := func(path string) bool { + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + _, ok := d.graph.ConfiguredFiles[path] + return ok + } + if got := d.ConfiguredFileCount(); got != 1 { + t.Fatalf("initial configured count = %d, want 1", got) + } + + if err := os.WriteFile(configPath, []byte(`{"only":["sql"]}`), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + _, configured := d.graph.ConfiguredFiles["query.sql"] + return configured + }) + if err := os.WriteFile(filepath.Join(root, "second.sql"), []byte("select 2;\n"), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return d.ConfiguredFileCount() == 2 }) + + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return configured("main.go") }) + notesPath := filepath.Join(root, "notes.txt") + if err := os.WriteFile(notesPath, []byte("notes\n"), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return configured("notes.txt") }) + if err := os.Remove(notesPath); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return !configured("notes.txt") }) + + if err := os.WriteFile(configPath, []byte(`{"exclude":["*.tmp"]}`), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return !configured("ignored.tmp") }) + if err := os.WriteFile(filepath.Join(root, "included.txt"), []byte("included\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "ignored.tmp"), []byte("ignored\n"), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return configured("included.txt") && !configured("ignored.tmp") }) + if err := os.Remove(filepath.Join(root, "included.txt")); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return !configured("included.txt") }) + + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("second.sql\n"), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return !configured("second.sql") }) + if err := os.Remove(filepath.Join(root, ".gitignore")); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { return configured("second.sql") }) +} + +func TestConfiguredFilterChangeInvalidatesDependencyState(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(root, ".codemap", "config.json") + if err := os.WriteFile(configPath, []byte(`{"only":["go"]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + if err := d.Start(); err != nil { + t.Fatal(err) + } + defer d.Stop() + d.graph.mu.Lock() + d.graph.FileGraph = &scanner.FileGraph{Importers: map[string][]string{"old.go": {"a.go", "b.go", "c.go"}}} + d.graph.DepCtx = map[string]*DepContext{"old.go": {Importers: []string{"a.go"}}} + d.graph.HasDeps = true + d.graph.mu.Unlock() + if err := os.WriteFile(configPath, []byte(`{"only":["sql"]}`), 0o644); err != nil { + t.Fatal(err) + } + waitForWatchCondition(t, 2*time.Second, func() bool { + d.graph.mu.RLock() + defer d.graph.mu.RUnlock() + return !d.graph.HasDeps && d.graph.FileGraph == nil && len(d.graph.DepCtx) == 0 + }) + state := ReadState(root) + if state == nil || len(state.Hubs) != 0 || len(state.Imports) != 0 || len(state.Importers) != 0 { + t.Fatalf("stale dependency state persisted: %#v", state) + } +} + +func TestConfiguredFileCountExcludesDaemonState(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("notes\n"), 0o644); err != nil { + t.Fatal(err) + } + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + if err := d.Start(); err != nil { + t.Fatal(err) + } + defer d.Stop() + waitForWatchCondition(t, 2*time.Second, func() bool { return d.ConfiguredFileCount() == 1 }) +} + +func TestConfiguredFileCountDrivesDependencyGraphLimit(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + for i := 0; i <= limits.LargeRepoFileCount; i++ { + path := filepath.Join(root, fmt.Sprintf("fixture-%04d.txt", i)) + if err := os.WriteFile(path, []byte("not source\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + d, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + defer d.Stop() + if err := d.fullScan(); err != nil { + t.Fatal(err) + } + + if d.FileCount() <= limits.LargeRepoFileCount { + t.Fatalf("tracked file count = %d, want a large repo", d.FileCount()) + } + if got := d.ConfiguredFileCount(); got != 1 { + t.Fatalf("configured file count = %d, want 1", got) + } + if !shouldComputeDependencyGraph(d.ConfiguredFileCount()) { + t.Fatal("expected dependency graph to use the configured source count") + } + if shouldComputeDependencyGraph(d.FileCount()) { + t.Fatal("expected tracked file count alone to exceed the dependency graph limit") + } +} + func TestDaemonStartTracksWriteEventsAndState(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") diff --git a/watch/types.go b/watch/types.go index 502996a..b510111 100644 --- a/watch/types.go +++ b/watch/types.go @@ -39,27 +39,39 @@ type DepContext struct { // Graph holds the live code graph state type Graph struct { - mu sync.RWMutex - Root string - Files map[string]*scanner.FileInfo // path -> file info - FileGraph *scanner.FileGraph // internal file-to-file dependencies - DepCtx map[string]*DepContext // path -> dependency context (precomputed) - State map[string]*FileState // path -> line/size/mtime cache for deltas - Events []Event - WorkingSet *WorkingSet // session working set - LastScan time.Time - IsGitRepo bool - HasDeps bool // whether deps were successfully computed + mu sync.RWMutex + Root string + Files map[string]*scanner.FileInfo // path -> file info + ConfiguredFiles map[string]struct{} // paths included by the active project filters + FileGraph *scanner.FileGraph // internal file-to-file dependencies + DepCtx map[string]*DepContext // path -> dependency context (precomputed) + State map[string]*FileState // path -> line/size cache for deltas + Events []Event + WorkingSet *WorkingSet // session working set + LastScan time.Time + IsGitRepo bool + HasDeps bool // whether deps were successfully computed } // State represents the daemon state that hooks can read type State struct { - UpdatedAt time.Time `json:"updated_at"` - FileCount int `json:"file_count"` - Hubs []string `json:"hubs"` - Importers map[string][]string `json:"importers"` // file -> files that import it - Imports map[string][]string `json:"imports"` // file -> files it imports - RecentEvents []Event `json:"recent_events"` // last 50 events for timeline - WorkingSet *WorkingSet `json:"working_set,omitempty"` - Coverage scanner.GraphCoverage `json:"coverage,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + ConfiguredFileCount *int `json:"configured_file_count,omitempty"` + Hubs []string `json:"hubs"` + Importers map[string][]string `json:"importers"` // file -> files that import it + Imports map[string][]string `json:"imports"` // file -> files it imports + RecentEvents []Event `json:"recent_events"` // last 50 events for timeline + WorkingSet *WorkingSet `json:"working_set,omitempty"` + Coverage scanner.GraphCoverage `json:"coverage,omitempty"` +} + +// ConfiguredCount returns the persisted count for the active project filters. +// Old state files did not include this field, so callers can distinguish them +// from a current state that legitimately contains zero configured files. +func (s *State) ConfiguredCount() (int, bool) { + if s == nil || s.ConfiguredFileCount == nil { + return 0, false + } + return *s.ConfiguredFileCount, true }