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
8 changes: 6 additions & 2 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand Down Expand Up @@ -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)
}

Expand Down
28 changes: 28 additions & 0 deletions cmd/context_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package cmd

import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"time"

"codemap/watch"
)

func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 25 additions & 6 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions cmd/hooks_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 11 additions & 1 deletion scanner/walker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 67 additions & 9 deletions watch/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sync"
"time"

"codemap/config"
"codemap/limits"
"codemap/scanner"

Expand Down Expand Up @@ -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,
},
}

Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand All @@ -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()

Expand All @@ -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()
Expand Down
Loading
Loading