diff --git a/cmd/context_evidence_test.go b/cmd/context_evidence_test.go index 77dad7c..56a7197 100644 --- a/cmd/context_evidence_test.go +++ b/cmd/context_evidence_test.go @@ -12,6 +12,7 @@ import ( "time" "codemap/analysis" + "codemap/internal/projectpath" "codemap/scanner" "codemap/watch" ) @@ -252,7 +253,7 @@ func TestHookPromptSubmitDoesNotClaimRiskFromCachedGraph(t *testing.T) { } }) - status, err := os.ReadFile(filepath.Join(root, ".codemap", "status")) + status, err := os.ReadFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "status")) if err != nil { t.Fatal(err) } diff --git a/cmd/context_test.go b/cmd/context_test.go index 31970e3..26d6bef 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/watch" ) @@ -92,7 +93,7 @@ func TestBuildContextEnvelopeFallsBackToConfiguredScanForLegacyState(t *testing. if err != nil { t.Fatal(err) } - mustWriteFile(t, filepath.Join(root, ".codemap", "state.json"), string(legacyState)) + mustWriteFile(t, filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), string(legacyState)) envelope := buildContextEnvelope(root, "", true) diff --git a/cmd/hooks.go b/cmd/hooks.go index ca7d66a..b18da23 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -539,7 +539,7 @@ func showLightweightDiffVsMain(root string) { // getLastSessionEvents reads events.log for previous session context func getLastSessionEvents(root string) []string { - eventsFile := filepath.Join(projectpath.RuntimeCodemapDir(root), "events.log") + eventsFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "events.log") f, err := os.Open(eventsFile) if err != nil { return nil @@ -887,7 +887,7 @@ func hookPromptSubmit(root string) error { // writeStatuslineState writes a tiny file for the statusline to read. func writeStatuslineState(root string, intent TaskIntent) { - codemapDir := projectpath.RuntimeCodemapDir(root) + codemapDir := projectpath.ProjectRuntimeDir(root) status := intent.Category if intent.RiskLevel != "low" { status += " " + intent.RiskLevel @@ -1379,7 +1379,7 @@ func showSessionProgress(root, sessionID string) { // hookPreCompact saves hub state before context compaction func hookPreCompact(root string) error { - codemapDir := projectpath.RuntimeCodemapDir(root) + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0755); err != nil { return err } @@ -1694,7 +1694,7 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti } return nil } - codemapDir := projectpath.RuntimeCodemapDir(root) + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { return err } diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index ba680e5..3bfd428 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -129,7 +129,7 @@ func mustJSONInput(t *testing.T, v any) string { func writeProjectConfig(t *testing.T, root string, cfg config.ProjectConfig) { t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { t.Fatal(err) } data, err := json.Marshal(cfg) @@ -144,14 +144,14 @@ func writeProjectConfig(t *testing.T, root string, cfg config.ProjectConfig) { func writeStateOnly(t *testing.T, root string, state watch.State) { t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { t.Fatal(err) } data, err := json.Marshal(state) if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644); err != nil { t.Fatal(err) } } @@ -180,12 +180,12 @@ func TestWaitForDaemonState(t *testing.T) { go func() { defer close(done) time.Sleep(150 * time.Millisecond) - _ = os.MkdirAll(filepath.Join(root, ".codemap"), 0o755) + _ = os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755) data, _ := json.Marshal(watch.State{ UpdatedAt: time.Now(), FileCount: 7, }) - _ = os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644) + _ = os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644) }() state := waitForDaemonState(root, time.Second) @@ -890,7 +890,7 @@ func TestHookFilesUseSetupRoot(t *testing.T) { setupRoot := t.TempDir() projectpath.SetSetupRoot(setupRoot) t.Cleanup(projectpath.ResetSetupRoot) - codemapDir := filepath.Join(setupRoot, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(projectRoot) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -921,23 +921,29 @@ func TestAutomaticLinkedWorktreeUsesLocalHookState(t *testing.T) { if err := os.MkdirAll(gitDir, 0o755); err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(primary), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(primary, ".codemap", "events.log"), []byte("primary event\n"), 0o644); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(primary), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(primary), "events.log"), []byte("primary event\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { t.Fatal(err) } linked := filepath.Join(t.TempDir(), "linked") - if err := os.MkdirAll(filepath.Join(linked, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(linked), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(linked, ".codemap", "events.log"), []byte("linked event\n"), 0o644); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(linked), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(linked), "events.log"), []byte("linked event\n"), 0o644); err != nil { t.Fatal(err) } linked, _ = filepath.EvalSymlinks(linked) @@ -947,7 +953,7 @@ func TestAutomaticLinkedWorktreeUsesLocalHookState(t *testing.T) { t.Fatalf("getLastSessionEvents() = %#v, want linked event", events) } writeStatuslineState(linked, TaskIntent{Category: "feature", RiskLevel: "low"}) - if _, err := os.Stat(filepath.Join(linked, ".codemap", "status")); err != nil { + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(linked), "status")); err != nil { t.Fatalf("linked status missing: %v", err) } if _, err := os.Stat(filepath.Join(primary, ".codemap", "status")); !os.IsNotExist(err) { @@ -956,7 +962,7 @@ func TestAutomaticLinkedWorktreeUsesLocalHookState(t *testing.T) { if err := updateSessionLease(linked, "session-1", true, time.Now(), nil); err != nil { t.Fatalf("updateSessionLease() error: %v", err) } - entries, err := os.ReadDir(filepath.Join(linked, ".codemap", "sessions")) + entries, err := os.ReadDir(filepath.Join(projectpath.ProjectRuntimeDir(linked), "sessions")) if err != nil || len(entries) != 1 { t.Fatalf("linked session lease entries = %d, err = %v", len(entries), err) } diff --git a/cmd/hooks_provenance_test.go b/cmd/hooks_provenance_test.go index a13da97..de64236 100644 --- a/cmd/hooks_provenance_test.go +++ b/cmd/hooks_provenance_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/watch" ) @@ -29,10 +30,10 @@ func writeProvenanceState(t *testing.T, root string, paths []string) { if err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644); err != nil { t.Fatal(err) } } diff --git a/cmd/hooks_test.go b/cmd/hooks_test.go index cde8d3f..7d273f3 100644 --- a/cmd/hooks_test.go +++ b/cmd/hooks_test.go @@ -13,6 +13,7 @@ import ( "codemap/config" "codemap/handoff" + "codemap/internal/projectpath" "codemap/limits" "codemap/watch" ) @@ -243,7 +244,7 @@ func TestShouldRestartDaemon(t *testing.T) { t.Run("running without state returns true", func(t *testing.T) { withOwnedDaemonProcess(t, func(string) bool { return true }) root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatal(err) } @@ -802,7 +803,7 @@ func captureOutput(f func()) string { // and a PID file pointing to the current process so IsRunning returns true. func writeWatchState(t *testing.T, root string, state watch.State) { t.Helper() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatal(err) } @@ -830,7 +831,7 @@ func TestGetLastSessionEvents(t *testing.T) { t.Run("empty file returns nil", func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatal(err) } @@ -844,7 +845,7 @@ func TestGetLastSessionEvents(t *testing.T) { t.Run("returns all lines when fewer than 20", func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) os.MkdirAll(codemapDir, 0755) lines := "ts|WRITE|a.go\nts|WRITE|b.go\nts|WRITE|c.go" os.WriteFile(filepath.Join(codemapDir, "events.log"), []byte(lines), 0644) @@ -857,7 +858,7 @@ func TestGetLastSessionEvents(t *testing.T) { t.Run("caps at 20 lines for large log (context bloat protection)", func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) os.MkdirAll(codemapDir, 0755) var sb strings.Builder @@ -881,7 +882,7 @@ func TestGetLastSessionEvents(t *testing.T) { t.Run("reads only tail of huge event log and still returns latest 20", func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) os.MkdirAll(codemapDir, 0755) var sb strings.Builder @@ -904,7 +905,7 @@ func TestGetLastSessionEvents(t *testing.T) { t.Run("skips blank lines when counting", func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) os.MkdirAll(codemapDir, 0755) // 5 real entries surrounded by blank lines content := "\nts|WRITE|a.go\n\nts|WRITE|b.go\n\n\nts|WRITE|c.go\n\nts|WRITE|d.go\nts|WRITE|e.go\n" @@ -1141,7 +1142,7 @@ func TestHookPreCompact(t *testing.T) { if out != "" { t.Errorf("expected no output when no hubs, got %q", out) } - hubsFile := filepath.Join(root, ".codemap", "hubs.txt") + hubsFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "hubs.txt") if _, err := os.Stat(hubsFile); !os.IsNotExist(err) { t.Error("expected no hubs.txt when hub list is empty") } @@ -1173,7 +1174,7 @@ func TestHookPreCompact(t *testing.T) { t.Errorf("expected hubs.txt mention, got %q", out) } - hubsFile := filepath.Join(root, ".codemap", "hubs.txt") + hubsFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "hubs.txt") content, err := os.ReadFile(hubsFile) if err != nil { t.Fatalf("expected hubs.txt to be created: %v", err) diff --git a/cmd/setup_review_test.go b/cmd/setup_review_test.go index 0db80db..a6fdc4a 100644 --- a/cmd/setup_review_test.go +++ b/cmd/setup_review_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "codemap/internal/projectpath" ) func TestEnsureClaudeMCPPreservesLargeIntegersAndUnknownKeys(t *testing.T) { @@ -219,7 +221,7 @@ func TestUpdateSessionLeaseTracksActiveSessionsAndPrunesStale(t *testing.T) { } // Age session-b's lease past the TTL; the next update must reclaim it. - leaseDir := filepath.Join(root, ".codemap", "sessions") + leaseDir := filepath.Join(projectpath.ProjectRuntimeDir(root), "sessions") entries, err := os.ReadDir(leaseDir) if err != nil { t.Fatal(err) @@ -247,7 +249,7 @@ func TestUpdateSessionLeaseTracksActiveSessionsAndPrunesStale(t *testing.T) { func TestUpdateSessionLeaseReclaimsStaleLock(t *testing.T) { root := t.TempDir() - lockPath := filepath.Join(root, ".codemap", "sessions.lock") + lockPath := filepath.Join(projectpath.ProjectRuntimeDir(root), "sessions.lock") if err := os.MkdirAll(lockPath, 0o700); err != nil { t.Fatal(err) } diff --git a/handoff/handoff_test.go b/handoff/handoff_test.go index c6825a4..0e08e9d 100644 --- a/handoff/handoff_test.go +++ b/handoff/handoff_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/watch" ) @@ -331,3 +332,54 @@ func TestMetricsLogCapped(t *testing.T) { t.Fatalf("expected %d metrics lines after cap, got %d", maxMetricsLines, len(lines)) } } + +func TestStoragePathsUseSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + + want := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), latestFilename) + if got := LatestPath(projectRoot); got != want { + t.Fatalf("LatestPath() = %q, want %q", got, want) + } +} + +func TestAutomaticLinkedWorktreesUseDistinctHandoffStorage(t *testing.T) { + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + primary := t.TempDir() + if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + makeLinked := func(name string) string { + gitDir := filepath.Join(primary, ".git", "worktrees", name) + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + linked := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(linked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + linked, _ = filepath.EvalSymlinks(linked) + return linked + } + linkedA := makeLinked("a") + linkedB := makeLinked("b") + + if got, want := LatestPath(linkedA), filepath.Join(projectpath.ProjectRuntimeDir(linkedA), latestFilename); got != want { + t.Fatalf("LatestPath(A) = %q, want %q", got, want) + } + if got, want := LatestPath(linkedB), filepath.Join(projectpath.ProjectRuntimeDir(linkedB), latestFilename); got != want { + t.Fatalf("LatestPath(B) = %q, want %q", got, want) + } + if LatestPath(linkedA) == LatestPath(linkedB) { + t.Fatal("automatic linked worktrees unexpectedly share handoff storage") + } +} diff --git a/handoff/storage.go b/handoff/storage.go index 8ca39a9..789c41c 100644 --- a/handoff/storage.go +++ b/handoff/storage.go @@ -5,6 +5,8 @@ import ( "encoding/json" "os" "path/filepath" + + "codemap/internal/projectpath" ) const ( @@ -17,38 +19,42 @@ const ( // LatestPath returns the absolute location of the latest handoff artifact. func LatestPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", latestFilename) + return filepath.Join(runtimeRoot, latestFilename) } - return filepath.Join(absRoot, ".codemap", latestFilename) + return filepath.Join(absRoot, latestFilename) } // PrefixPath returns the absolute location of the prefix snapshot. func PrefixPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", prefixFilename) + return filepath.Join(runtimeRoot, prefixFilename) } - return filepath.Join(absRoot, ".codemap", prefixFilename) + return filepath.Join(absRoot, prefixFilename) } // DeltaPath returns the absolute location of the delta snapshot. func DeltaPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", deltaFilename) + return filepath.Join(runtimeRoot, deltaFilename) } - return filepath.Join(absRoot, ".codemap", deltaFilename) + return filepath.Join(absRoot, deltaFilename) } // MetricsPath returns the absolute location of the handoff metrics log. func MetricsPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", metricsFilename) + return filepath.Join(runtimeRoot, metricsFilename) } - return filepath.Join(absRoot, ".codemap", metricsFilename) + return filepath.Join(absRoot, metricsFilename) } // ReadLatest reads the latest handoff artifact if it exists. diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go index bed3a5f..9faa613 100644 --- a/internal/projectpath/path.go +++ b/internal/projectpath/path.go @@ -2,6 +2,7 @@ package projectpath import ( + "crypto/sha256" "fmt" "io" "os" @@ -134,6 +135,47 @@ func RuntimeRoot(projectRoot string) string { return filepath.Clean(projectRoot) } +// ProjectKey returns a stable identifier for a project root, so projects that +// share a setup root get separate mutable-state paths. Subdirectories resolve +// to their nearest git root so they share the project's key. +func ProjectKey(projectRoot string) string { + root, err := canonicalProjectRoot(projectRoot) + if err != nil { + root = filepath.Clean(projectRoot) + } + if gitRoot, ok := nearestGitRoot(root); ok { + // Canonicalize so the symlink-resolved project root and a subdirectory + // resolve to the same key (e.g. macOS /var -> /private/var). + root = gitRoot + if canonical, err := canonicalProjectRoot(gitRoot); err == nil { + root = canonical + } + } + sum := sha256.Sum256([]byte(root)) + return fmt.Sprintf("%x", sum[:6]) +} + +// nearestGitRoot walks up from root to the nearest ancestor containing a .git +// entry (directory or file, e.g. linked worktrees). +func nearestGitRoot(root string) (string, bool) { + for dir := root; ; dir = filepath.Dir(dir) { + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + } +} + +// ProjectRuntimeDir returns the per-project mutable-state directory: the +// shared runtime area (e.g. a setup root) scoped to this project, so projects +// sharing a setup root never collide on pid, state, events, or handoff files. +func ProjectRuntimeDir(projectRoot string) string { + return filepath.Join(RuntimeCodemapDir(projectRoot), "projects", ProjectKey(projectRoot)) +} + // RuntimeCodemapDir returns the .codemap directory for mutable project state. func RuntimeCodemapDir(projectRoot string) string { return filepath.Join(RuntimeRoot(projectRoot), ".codemap") diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go index 629e414..5d57c66 100644 --- a/internal/projectpath/path_test.go +++ b/internal/projectpath/path_test.go @@ -372,3 +372,17 @@ func TestRuntimeRootAndCheckedRuntimeCodemapDir(t *testing.T) { t.Fatalf("RuntimeRoot() fallback = %q, want %q", got, filepath.Clean(missing)) } } + +func TestProjectKeyScopesProjectsAndSharesRepoKey(t *testing.T) { + a := filepath.Join(t.TempDir(), "projA") + if err := os.MkdirAll(filepath.Join(a, ".git"), 0o755); err != nil { + t.Fatal(err) + } + b := filepath.Join(t.TempDir(), "projB") + if ProjectKey(a) == ProjectKey(b) { + t.Fatal("distinct projects must not share a project key") + } + if ProjectKey(a) != ProjectKey(filepath.Join(a, "pkg", "sub")) { + t.Fatal("a subdirectory must share its repo root's project key") + } +} diff --git a/main_helpers_test.go b/main_helpers_test.go index e8adbeb..1d55194 100644 --- a/main_helpers_test.go +++ b/main_helpers_test.go @@ -11,6 +11,7 @@ import ( "time" "codemap/handoff" + "codemap/internal/projectpath" "codemap/scanner" "codemap/watch" ) @@ -82,7 +83,7 @@ func TestRunHandoffSubcommandLatestVariants(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644); err != nil { t.Fatal(err) } diff --git a/main_more_test.go b/main_more_test.go index 34c526b..8f689a2 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -289,14 +289,14 @@ func runGitMainTestCmd(t *testing.T, dir string, args ...string) { func writeMainWatchState(t *testing.T, root string, state watch.State, running bool) { t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { t.Fatal(err) } data, err := json.Marshal(state) if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644); err != nil { t.Fatal(err) } if running { @@ -813,7 +813,7 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { if !fake.started || !fake.stopped { t.Fatalf("expected fake daemon to start and stop, got %+v", fake) } - if _, err := os.Stat(filepath.Join(root, ".codemap", "watch.pid")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid")); !os.IsNotExist(err) { t.Fatalf("expected pid file to be removed after daemon stops, got err=%v", err) } }) diff --git a/mcp/main_test.go b/mcp/main_test.go index ad34726..a98c99e 100644 --- a/mcp/main_test.go +++ b/mcp/main_test.go @@ -10,6 +10,7 @@ import ( "time" "codemap/handoff" + "codemap/internal/projectpath" "codemap/scanner" "codemap/watch" @@ -300,7 +301,10 @@ func TestHandleGetStructureUsesStateHubs(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".codemap", "state.json"), data, 0o644); err != nil { + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json"), data, 0o644); err != nil { t.Fatal(err) } diff --git a/scanner/cancellation_test.go b/scanner/cancellation_test.go index 6d6c04b..ec53c9e 100644 --- a/scanner/cancellation_test.go +++ b/scanner/cancellation_test.go @@ -100,7 +100,8 @@ exec sleep 10 t.Fatalf("pre-cancelled ScanDirectory error = %v, want context.Canceled", err) } - deadline, stop := context.WithTimeout(context.Background(), time.Second) + const scanDeadline = 5 * time.Second + deadline, stop := context.WithTimeout(context.Background(), scanDeadline) defer stop() started := time.Now() done := make(chan error, 1) @@ -125,15 +126,16 @@ exec sleep 10 } time.Sleep(5 * time.Millisecond) } + const terminateBudget = 5 * time.Second select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("caller-deadline ScanDirectory error = %v, want context.DeadlineExceeded", err) } - case <-time.After(2 * time.Second): + case <-time.After(terminateBudget): t.Fatal("ScanDirectory did not terminate deadline-exceeded subprocess") } - if elapsed := time.Since(started); elapsed > 2*time.Second { + if elapsed := time.Since(started); elapsed > scanDeadline+terminateBudget { t.Fatalf("caller deadline did not terminate ast-grep promptly: %s", elapsed) } if err := exec.Command("/bin/kill", "-0", blockerPID).Run(); err == nil { diff --git a/scanner/git.go b/scanner/git.go index 61bc605..455b870 100644 --- a/scanner/git.go +++ b/scanner/git.go @@ -35,11 +35,19 @@ func GitDiffInfo(ctx context.Context, root, ref string) (*DiffInfo, error) { cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", ref) cmd.WaitDelay = gitContextWaitDelay cmd.Dir = root + // stdout is numstat rows only; read the error text from stderr. output, err := cmd.Output() if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } + var detail string + if exitErr, ok := err.(*exec.ExitError); ok { + detail = strings.TrimSpace(string(exitErr.Stderr)) + } + if detail != "" { + return nil, fmt.Errorf("%w: %s", err, detail) + } return nil, err } @@ -48,7 +56,8 @@ func GitDiffInfo(ctx context.Context, root, ref string) (*DiffInfo, error) { continue } parts := strings.Fields(line) - if len(parts) >= 3 { + // numstat rows are "\t\t"; reject anything else. + if len(parts) >= 3 && isNumstatCount(parts[0]) && isNumstatCount(parts[1]) { var added, removed int if parts[0] != "-" { fmt.Sscanf(parts[0], "%d", &added) @@ -86,6 +95,19 @@ func GitDiffInfo(ctx context.Context, root, ref string) (*DiffInfo, error) { return info, nil } +// isNumstatCount reports whether s is a numstat count: all digits or "-". +func isNumstatCount(s string) bool { + if s == "-" { + return true + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} + // DiffStat returns +/- line counts for changed files type DiffStat struct { Added int diff --git a/scanner/git_test.go b/scanner/git_test.go index 490d504..da61594 100644 --- a/scanner/git_test.go +++ b/scanner/git_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -223,11 +224,14 @@ func TestGitDiffStatsHelper(t *testing.T) { func TestGitDiffInfoInvalidRef(t *testing.T) { tmpDir := setupGitRepo(t) + const ref = "nonexistent-branch-xyz" // Try to diff against nonexistent ref - _, err := GitDiffInfo(context.Background(), tmpDir, "nonexistent-branch-xyz") + _, err := GitDiffInfo(context.Background(), tmpDir, ref) if err == nil { - // It's okay if this returns empty results instead of error - // but we're checking it doesn't panic + t.Fatal("expected invalid ref error") + } + if !strings.Contains(err.Error(), ref) { + t.Fatalf("expected Git stderr to identify %q, got %q", ref, err) } } @@ -264,3 +268,40 @@ func TestAnalyzeImpactEmpty(t *testing.T) { t.Errorf("Expected nil impacts for empty slice, got %v", impacts) } } + +func TestGitDiffInfoIgnoresStderrWarningsOnSuccess(t *testing.T) { + // git warns on stderr ("refname 'dup' is ambiguous.") with exit 0 when a + // branch and a tag share a name; the warning must not become a changed file. + root := setupGitRepo(t) + git := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + git("config", "user.email", "t@t") + git("config", "user.name", "t") + if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + git("add", "f.txt") + git("commit", "-qm", "one") + git("branch", "dup") + git("tag", "-m", "t", "dup") + // An uncommitted change makes `git diff dup` emit a real row alongside the warning. + if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("x\ny\n"), 0o644); err != nil { + t.Fatal(err) + } + + info, err := GitDiffInfo(context.Background(), root, "dup") + if err != nil { + t.Fatalf("GitDiffInfo: %v", err) + } + if !info.Changed["f.txt"] { + t.Fatalf("expected f.txt in changed set, got %v", info.Changed) + } + if len(info.Changed) != 1 { + t.Fatalf("changed set = %v, want exactly {f.txt} (stderr warning leaked into the parser)", info.Changed) + } +} diff --git a/scanner/numstat_test.go b/scanner/numstat_test.go new file mode 100644 index 0000000..0b1284c --- /dev/null +++ b/scanner/numstat_test.go @@ -0,0 +1,17 @@ +package scanner + +import "testing" + +func TestIsNumstatCount(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"4", true}, {"0", true}, {"-", true}, {"123", true}, + {"", false}, {"4a", false}, {"'dup' is ambiguous.", false}, {" ", false}, + } { + if got := isNumstatCount(tc.in); got != tc.want { + t.Errorf("isNumstatCount(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/skills/loader.go b/skills/loader.go index e869bc1..1635176 100644 --- a/skills/loader.go +++ b/skills/loader.go @@ -7,6 +7,7 @@ import ( "strings" "codemap/internal/projectpath" + "gopkg.in/yaml.v3" ) @@ -23,6 +24,10 @@ const ( // Later sources override earlier ones if they share the same name. func LoadSkills(root string) (*SkillIndex, error) { var all []Skill + selection, err := projectpath.Select(root) + if err != nil { + return nil, err + } // 1. Builtin skills (embedded) builtins, err := loadBuiltinSkills() @@ -32,7 +37,7 @@ func LoadSkills(root string) (*SkillIndex, error) { all = append(all, builtins...) // 2. Project-local skills - projectDir := filepath.Join(projectpath.CodemapDir(root), "skills") + projectDir := filepath.Join(selection.SetupRoot, ".codemap", "skills") projectSkills, _ := loadSkillsFromDir(projectDir, projectSource) all = append(all, projectSkills...) diff --git a/skills/loader_test.go b/skills/loader_test.go index 59e5c0f..edf2575 100644 --- a/skills/loader_test.go +++ b/skills/loader_test.go @@ -335,6 +335,30 @@ My custom instructions.` } } +func TestLoadSkillsUsesSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + + skillsDir := filepath.Join(setupRoot, ".codemap", "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + content := "---\nname: setup-only\ndescription: Loaded from setup root\n---\n\n# Setup only\n" + if err := os.WriteFile(filepath.Join(skillsDir, "setup-only.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + idx, err := LoadSkills(projectRoot) + if err != nil { + t.Fatalf("LoadSkills() error: %v", err) + } + if skill := idx.ByName["setup-only"]; skill == nil || skill.Source != projectSource { + t.Fatalf("setup-root skill = %#v, want project skill", skill) + } +} + func TestLoadSkillsUsesSelectedSetupRoot(t *testing.T) { projectpath.ResetSetupRoot() t.Cleanup(projectpath.ResetSetupRoot) diff --git a/watch/daemon.go b/watch/daemon.go index 01a8e2a..29d50f8 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -11,6 +11,7 @@ import ( "time" "codemap/config" + "codemap/internal/projectpath" "codemap/limits" "codemap/scanner" @@ -36,6 +37,10 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { if err != nil { return nil, fmt.Errorf("invalid root path: %w", err) } + // Canonicalize so d.root, the runtime dir, and fsnotify paths agree. + if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = canonical + } watcher, err := fsnotify.NewWatcher() if err != nil { @@ -56,7 +61,7 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { gitCache: gitCache, verbose: verbose, done: make(chan struct{}), - eventLog: filepath.Join(absRoot, ".codemap", "events.log"), + eventLog: filepath.Join(projectpath.ProjectRuntimeDir(absRoot), "events.log"), graph: &Graph{ Root: absRoot, Files: make(map[string]*scanner.FileInfo), @@ -74,9 +79,10 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { // Start begins watching and returns immediately func (d *Daemon) Start() error { - // Ensure .codemap directory exists - codemapDir := filepath.Join(d.root, ".codemap") - if err := os.MkdirAll(codemapDir, 0755); err != nil { + // Ensure the config directory exists; it is watched so config edits can + // refresh the configured-file inventory. + configDir := projectpath.CodemapDir(d.root) + if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create .codemap dir: %w", err) } @@ -98,9 +104,7 @@ 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 { + if err := d.watcher.Add(configDir); err != nil { return fmt.Errorf("failed to watch .codemap dir: %w", err) } diff --git a/watch/events.go b/watch/events.go index 9690b5f..5469aa4 100644 --- a/watch/events.go +++ b/watch/events.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "codemap/internal/projectpath" "codemap/limits" "codemap/scanner" @@ -176,10 +177,8 @@ func (d *Daemon) eventLoop() { } } - // Control events (config.json, any .gitignore) are coalesced separately - // from file events. Each refresh rebuilds the dependency graph, which runs - // ast-grep over the whole repo, and fsnotify routinely emits several events - // per save — so a trailing-edge debounce keeps one save to one rebuild. + // Control events are coalesced separately from file events; a trailing-edge + // debounce keeps one save to one graph rebuild. controlTimer := time.NewTimer(time.Hour) controlTimer.Stop() defer controlTimer.Stop() @@ -637,6 +636,9 @@ func (d *Daemon) logEvent(e Event) { func (d *Daemon) writeState() { d.graph.mu.RLock() defer d.graph.mu.RUnlock() + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(d.root), 0o755); err != nil { + return + } // Keep state snapshots small and deterministic for hook reads. events := d.graph.Events @@ -650,6 +652,7 @@ func (d *Daemon) writeState() { configuredFileCount = len(d.graph.Files) } state := State{ + Root: canonicalRoot(d.root), UpdatedAt: time.Now(), FileCount: len(d.graph.Files), ConfiguredFileCount: &configuredFileCount, @@ -671,7 +674,7 @@ func (d *Daemon) writeState() { return } - stateFile := filepath.Join(d.root, ".codemap", "state.json") + stateFile := filepath.Join(projectpath.ProjectRuntimeDir(d.root), "state.json") os.WriteFile(stateFile, data, 0644) } diff --git a/watch/more_test.go b/watch/more_test.go index ff2ada8..36b1583 100644 --- a/watch/more_test.go +++ b/watch/more_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/limits" "codemap/scanner" @@ -79,7 +80,7 @@ func TestGetGraphWriteInitialStateAndFindRelatedHot(t *testing.T) { } d.WriteInitialState() - data, err := os.ReadFile(filepath.Join(root, ".codemap", "state.json")) + data, err := os.ReadFile(filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json")) if err != nil { t.Fatalf("expected state file to be written: %v", err) } @@ -187,7 +188,7 @@ func TestConfiguredFileCountTracksConfiguredFilesAcrossEvents(t *testing.T) { func TestConfiguredFileCountTracksLiveFilterChanges(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.CodemapDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } diff --git a/watch/state.go b/watch/state.go index 841b189..6025443 100644 --- a/watch/state.go +++ b/watch/state.go @@ -8,24 +8,35 @@ import ( "path/filepath" "strings" "time" + + "codemap/internal/projectpath" ) -// ErrForeignDaemonPID is returned by Stop when the PID in watch.pid is alive and -// its command line was read but does NOT match this repo's watch daemon — i.e. a -// stale PID the OS reused for an unrelated process. Callers can treat it as -// "nothing of ours to stop" and safely discard the pid file. +// ErrForeignDaemonPID: the PID in watch.pid is alive but belongs to another +// process; callers treat it as nothing of ours and discard the pid file. var ErrForeignDaemonPID = errors.New("watch.pid points to a live process that is not this repo's codemap watch daemon (stale or reused PID)") -// ErrDaemonOwnershipUnknown is returned by Stop when the PID is alive but its -// ownership could not be determined (the process command line was unavailable, -// e.g. introspection was denied). We refuse to kill it AND keep the pid file, so -// a real daemon is never orphaned or an unrelated process killed. +// ErrDaemonOwnershipUnknown: the PID is alive but ownership can't be verified; +// refuse to kill and keep the pid file. var ErrDaemonOwnershipUnknown = errors.New("could not verify that watch.pid belongs to this repo's codemap watch daemon; refusing to stop it") // ReadState reads the daemon state from disk (for hooks to use). // Returns nil if state doesn't exist or if it's stale and daemon is not running. +// canonicalRoot returns root as an absolute, symlink-resolved path; on error +// it returns the absolute path unchanged. +func canonicalRoot(root string) string { + abs, err := filepath.Abs(root) + if err != nil { + return root + } + if canonical, err := filepath.EvalSymlinks(abs); err == nil { + return canonical + } + return abs +} + func ReadState(root string) *State { - stateFile := filepath.Join(root, ".codemap", "state.json") + stateFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json") data, err := os.ReadFile(stateFile) if err != nil { return nil @@ -36,6 +47,18 @@ func ReadState(root string) *State { return nil } + // Reject state owned by another project: a shared runtime dir (e.g. one + // setup root serving several sandboxes) must never serve a different + // project's daemon state as truth. Callers inside the daemon's project + // (subdirectories) still see it. + if state.Root != "" { + caller := canonicalRoot(root) + rel, err := filepath.Rel(state.Root, caller) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return nil + } + } + // If state is stale, still allow it when daemon is alive. // This avoids expensive fallback scans during idle periods. if time.Since(state.UpdatedAt) > 30*time.Second && !IsRunning(root) { @@ -47,13 +70,16 @@ func ReadState(root string) *State { // WritePID writes the daemon PID to .codemap/watch.pid func WritePID(root string) error { - pidFile := filepath.Join(root, ".codemap", "watch.pid") + if err := os.MkdirAll(projectpath.ProjectRuntimeDir(root), 0o755); err != nil { + return err + } + pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) } // ReadPID reads the daemon PID from .codemap/watch.pid func ReadPID(root string) (int, error) { - pidFile := filepath.Join(root, ".codemap", "watch.pid") + pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") data, err := os.ReadFile(pidFile) if err != nil { return 0, err @@ -65,7 +91,7 @@ func ReadPID(root string) (int, error) { // RemovePID removes the PID file func RemovePID(root string) { - pidFile := filepath.Join(root, ".codemap", "watch.pid") + pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") os.Remove(pidFile) } @@ -146,12 +172,8 @@ func Stop(root string) error { // verifies the PID belongs to this repo's daemon (guarding against a reused // stale PID) before killing, returning ErrForeignDaemonPID otherwise. if err := terminateDaemon(root, proc); err != nil { - if errors.Is(err, ErrForeignDaemonPID) { - // The recorded PID isn't our daemon (stale or reused). Clear the - // bogus pid file so status stops reporting it, but never kill a - // process we can't confirm is ours. - RemovePID(root) - } + // Never remove the pid file on ErrForeignDaemonPID: the PID is alive + // and unverified, so clearing it could orphan a real daemon. return err } // Clean up PID file diff --git a/watch/state_more_test.go b/watch/state_more_test.go index 3c7f8c6..7ce63c1 100644 --- a/watch/state_more_test.go +++ b/watch/state_more_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" "time" + + "codemap/internal/projectpath" ) func shouldSkipProcessCommandError(err error) bool { @@ -40,7 +42,7 @@ func TestReadStateMissingAndInvalid(t *testing.T) { t.Fatal("expected nil for missing state file") } - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -101,7 +103,7 @@ func TestIsOwnedDaemonMatchesCommandLine(t *testing.T) { } root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -132,7 +134,7 @@ func TestIsOwnedDaemonMatchesCommandLine(t *testing.T) { func TestReadPIDInvalidContent(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -160,7 +162,7 @@ func TestIsOwnedDaemonInvalidPIDInputs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -191,7 +193,7 @@ func TestIsRunningInvalidPIDInputs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } @@ -217,7 +219,7 @@ func TestStopWithoutPIDFileReturnsNoDaemonError(t *testing.T) { func TestStopTerminatesProcessAndRemovesPID(t *testing.T) { root := t.TempDir() - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { t.Fatal(err) } diff --git a/watch/state_test.go b/watch/state_test.go index 5aaee8e..c87f8ed 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/scanner" ) @@ -18,7 +19,7 @@ func TestReadStateStaleButRunning(t *testing.T) { } defer os.RemoveAll(tmpDir) - codemapDir := filepath.Join(tmpDir, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(tmpDir) if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatalf("Failed to create .codemap dir: %v", err) } @@ -57,7 +58,7 @@ func TestReadStateStaleAndNotRunning(t *testing.T) { } defer os.RemoveAll(tmpDir) - codemapDir := filepath.Join(tmpDir, ".codemap") + codemapDir := projectpath.ProjectRuntimeDir(tmpDir) if err := os.MkdirAll(codemapDir, 0755); err != nil { t.Fatalf("Failed to create .codemap dir: %v", err) } @@ -146,6 +147,84 @@ func TestWriteInitialStateWritesReadableState(t *testing.T) { } } +func TestWatchStorageUsesSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + if err := os.MkdirAll(filepath.Join(setupRoot, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + + if err := WritePID(projectRoot); err != nil { + t.Fatalf("WritePID() error: %v", err) + } + wantPID := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), "watch.pid") + if _, err := os.Stat(wantPID); err != nil { + t.Fatalf("setup-root PID missing: %v", err) + } + + d, err := NewDaemon(projectRoot, false) + if err != nil { + t.Fatalf("NewDaemon() error: %v", err) + } + defer d.watcher.Close() + wantLog := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), "events.log") + if d.eventLog != wantLog { + t.Fatalf("eventLog = %q, want %q", d.eventLog, wantLog) + } +} + +func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) { + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + primary := t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "agent") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + linked := filepath.Join(t.TempDir(), "linked") + if err := os.MkdirAll(filepath.Join(linked, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + linked, _ = filepath.EvalSymlinks(linked) + + if err := WritePID(linked); err != nil { + t.Fatalf("WritePID() error: %v", err) + } + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(linked), "watch.pid")); err != nil { + t.Fatalf("linked-worktree PID missing: %v", err) + } + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(primary), "watch.pid")); !os.IsNotExist(err) { + t.Fatalf("primary PID unexpectedly created: %v", err) + } + + d, err := NewDaemon(linked, false) + if err != nil { + t.Fatalf("NewDaemon() error: %v", err) + } + defer d.watcher.Close() + if want := filepath.Join(projectpath.ProjectRuntimeDir(linked), "events.log"); d.eventLog != want { + t.Fatalf("eventLog = %q, want %q", d.eventLog, want) + } + d.WriteInitialState() + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(linked), "state.json")); err != nil { + t.Fatalf("linked-worktree state missing: %v", err) + } + if _, err := os.Stat(filepath.Join(primary, ".codemap", "state.json")); !os.IsNotExist(err) { + t.Fatalf("primary state unexpectedly created: %v", err) + } +} + func TestProcessAliveDetectsLiveAndDeadPIDs(t *testing.T) { if !processAlive(os.Getpid()) { t.Fatal("current process should be reported alive") @@ -166,3 +245,60 @@ func TestProcessAliveDetectsLiveAndDeadPIDs(t *testing.T) { t.Fatalf("exited process %d should not be reported alive", pid) } } + +func TestReadStateRejectsForeignRootAndAcceptsDescendants(t *testing.T) { + // A shared setup root makes every project read the same runtime dir, so + // the State.Root check is what separates one project's state from another. + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + if err := os.MkdirAll(filepath.Join(setupRoot, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + d := &Daemon{root: root, graph: &Graph{ + Files: map[string]*scanner.FileInfo{"main.go": {Path: "main.go", Ext: ".go"}}, + Events: []Event{{Time: time.Now(), Op: "WRITE", Path: "main.go"}}, + }} + d.WriteInitialState() + + if got := ReadState(root); got == nil || got.FileCount != 1 { + t.Fatalf("ReadState(own root) = %v, want the state", got) + } + subdir := filepath.Join(root, "pkg", "x") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + if got := ReadState(subdir); got == nil { + t.Fatal("ReadState(subdirectory) = nil, want the state (same project)") + } + foreign := t.TempDir() + if got := ReadState(foreign); got != nil { + t.Fatalf("ReadState(foreign root) = %v, want nil (never serve another project's state)", got) + } +} + +func TestTwoProjectsSharingSetupRootDoNotCollideOnPID(t *testing.T) { + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + projA := filepath.Join(t.TempDir(), "projA") + projB := filepath.Join(t.TempDir(), "projB") + for _, p := range []string{projA, projB} { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + if pidA, pidB := projectpath.ProjectRuntimeDir(projA), projectpath.ProjectRuntimeDir(projB); pidA == pidB { + t.Fatalf("projects under one setup root collide on the pid path: %s", pidA) + } + if err := WritePID(projA); err != nil { + t.Fatal(err) + } + if _, err := ReadPID(projB); err == nil { + t.Fatal("projB read projA's pid — the collision is not fixed") + } +} diff --git a/watch/types.go b/watch/types.go index b510111..b4ee08a 100644 --- a/watch/types.go +++ b/watch/types.go @@ -55,6 +55,7 @@ type Graph struct { // State represents the daemon state that hooks can read type State struct { + Root string `json:"root,omitempty"` UpdatedAt time.Time `json:"updated_at"` FileCount int `json:"file_count"` ConfiguredFileCount *int `json:"configured_file_count,omitempty"`