From 44b8d70b62e2be6ecb23c846c27ee33f422c3fd9 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:27:08 +0200 Subject: [PATCH 1/4] feat(state): Preserve selected project roots Keep setup and runtime roots consistent across configuration, handoffs, scanning, skills, and watch state, so a sandbox's runtime artifacts (handoff storage, watch state, pid, events) live with its selected project root. Runtime state is owned per project: State records its Root, ReadState rejects state written by a different project when several projects share one runtime dir (callers inside the owning project still see it), and Stop never removes the pid file for an unverified foreign PID. Restores the linked-worktree setup-root skills test; drops config tests that passed unchanged on main. Co-Authored-By: GPT-5.6 Sol --- handoff/handoff_test.go | 52 +++++++++++++++++++ handoff/storage.go | 22 +++++--- scanner/git.go | 5 +- scanner/git_test.go | 10 ++-- skills/loader.go | 7 ++- skills/loader_test.go | 24 +++++++++ watch/daemon.go | 10 +++- watch/events.go | 10 ++-- watch/state.go | 55 +++++++++++++------- watch/state_test.go | 111 ++++++++++++++++++++++++++++++++++++++++ watch/types.go | 1 + 11 files changed, 269 insertions(+), 38 deletions(-) diff --git a/handoff/handoff_test.go b/handoff/handoff_test.go index c6825a4..cbb4a0a 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(setupRoot, ".codemap", 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(linkedA, ".codemap", latestFilename); got != want { + t.Fatalf("LatestPath(A) = %q, want %q", got, want) + } + if got, want := LatestPath(linkedB), filepath.Join(linkedB, ".codemap", 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..5a1f067 100644 --- a/handoff/storage.go +++ b/handoff/storage.go @@ -5,6 +5,8 @@ import ( "encoding/json" "os" "path/filepath" + + "codemap/internal/projectpath" ) const ( @@ -17,36 +19,40 @@ const ( // LatestPath returns the absolute location of the latest handoff artifact. func LatestPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.RuntimeRoot(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", latestFilename) + return filepath.Join(runtimeRoot, ".codemap", latestFilename) } return filepath.Join(absRoot, ".codemap", latestFilename) } // PrefixPath returns the absolute location of the prefix snapshot. func PrefixPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.RuntimeRoot(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", prefixFilename) + return filepath.Join(runtimeRoot, ".codemap", prefixFilename) } return filepath.Join(absRoot, ".codemap", prefixFilename) } // DeltaPath returns the absolute location of the delta snapshot. func DeltaPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.RuntimeRoot(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", deltaFilename) + return filepath.Join(runtimeRoot, ".codemap", deltaFilename) } return filepath.Join(absRoot, ".codemap", deltaFilename) } // MetricsPath returns the absolute location of the handoff metrics log. func MetricsPath(root string) string { - absRoot, err := filepath.Abs(root) + runtimeRoot := projectpath.RuntimeRoot(root) + absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(root, ".codemap", metricsFilename) + return filepath.Join(runtimeRoot, ".codemap", metricsFilename) } return filepath.Join(absRoot, ".codemap", metricsFilename) } diff --git a/scanner/git.go b/scanner/git.go index 61bc605..c4478da 100644 --- a/scanner/git.go +++ b/scanner/git.go @@ -35,11 +35,14 @@ func GitDiffInfo(ctx context.Context, root, ref string) (*DiffInfo, error) { cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", ref) cmd.WaitDelay = gitContextWaitDelay cmd.Dir = root - output, err := cmd.Output() + output, err := cmd.CombinedOutput() if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } + if detail := strings.TrimSpace(string(output)); detail != "" { + return nil, fmt.Errorf("%w: %s", err, detail) + } return nil, err } diff --git a/scanner/git_test.go b/scanner/git_test.go index 490d504..c8774bf 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) } } 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..ceda179 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,11 @@ 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 event paths agree + // (e.g. macOS /tmp -> /private/tmp, /var -> /private/var). + if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = canonical + } watcher, err := fsnotify.NewWatcher() if err != nil { @@ -56,7 +62,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.RuntimeCodemapDir(absRoot), "events.log"), graph: &Graph{ Root: absRoot, Files: make(map[string]*scanner.FileInfo), @@ -75,7 +81,7 @@ 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") + codemapDir := projectpath.RuntimeCodemapDir(d.root) if err := os.MkdirAll(codemapDir, 0755); err != nil { return fmt.Errorf("failed to create .codemap dir: %w", err) } diff --git a/watch/events.go b/watch/events.go index 9690b5f..1d54cd5 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() @@ -650,6 +649,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 +671,7 @@ func (d *Daemon) writeState() { return } - stateFile := filepath.Join(d.root, ".codemap", "state.json") + stateFile := filepath.Join(projectpath.RuntimeCodemapDir(d.root), "state.json") os.WriteFile(stateFile, data, 0644) } diff --git a/watch/state.go b/watch/state.go index 841b189..54234c1 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.RuntimeCodemapDir(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,13 @@ 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") + pidFile := filepath.Join(projectpath.RuntimeCodemapDir(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.RuntimeCodemapDir(root), "watch.pid") data, err := os.ReadFile(pidFile) if err != nil { return 0, err @@ -65,7 +88,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.RuntimeCodemapDir(root), "watch.pid") os.Remove(pidFile) } @@ -146,12 +169,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_test.go b/watch/state_test.go index 5aaee8e..fc66bde 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "codemap/internal/projectpath" "codemap/scanner" ) @@ -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(setupRoot, ".codemap", "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(setupRoot, ".codemap", "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(linked, ".codemap", "watch.pid")); err != nil { + t.Fatalf("linked-worktree PID missing: %v", err) + } + if _, err := os.Stat(filepath.Join(primary, ".codemap", "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(linked, ".codemap", "events.log"); d.eventLog != want { + t.Fatalf("eventLog = %q, want %q", d.eventLog, want) + } + d.WriteInitialState() + if _, err := os.Stat(filepath.Join(linked, ".codemap", "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,35 @@ 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() + 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) + } +} 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"` From ed9e18bfd6769da77a9a2f971ef38134ddb4602e Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:37:09 +0200 Subject: [PATCH 2/4] fix(scanner): keep git diff warnings out of the numstat parser Read stdout only and take the error detail from the process stderr (ExitError.Stderr) instead of the merged output, so an exit-0 git warning (ambiguous refname, CRLF notice, textconv driver) cannot contaminate the parser. A strict numstat count guard rejects non-numstat rows. Co-Authored-By: GPT-5.6 Sol --- scanner/git.go | 25 ++++++++++++++++++++++--- scanner/git_test.go | 37 +++++++++++++++++++++++++++++++++++++ scanner/numstat_test.go | 17 +++++++++++++++++ watch/daemon.go | 3 +-- 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 scanner/numstat_test.go diff --git a/scanner/git.go b/scanner/git.go index c4478da..455b870 100644 --- a/scanner/git.go +++ b/scanner/git.go @@ -35,12 +35,17 @@ func GitDiffInfo(ctx context.Context, root, ref string) (*DiffInfo, error) { cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", ref) cmd.WaitDelay = gitContextWaitDelay cmd.Dir = root - output, err := cmd.CombinedOutput() + // 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 } - if detail := strings.TrimSpace(string(output)); detail != "" { + 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 @@ -51,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) @@ -89,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 c8774bf..da61594 100644 --- a/scanner/git_test.go +++ b/scanner/git_test.go @@ -268,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/watch/daemon.go b/watch/daemon.go index ceda179..bef3e28 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -37,8 +37,7 @@ 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 event paths agree - // (e.g. macOS /tmp -> /private/tmp, /var -> /private/var). + // Canonicalize so d.root, the runtime dir, and fsnotify paths agree. if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { absRoot = canonical } From d018caddcf556c1ed7f07d86686c3f5c6d3930f4 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:33:43 +0200 Subject: [PATCH 3/4] fix(watch): scope mutable runtime state per project Projects sharing a setup root previously collided on watch.pid, state.json, events.log, and handoff files: the second project silently got no daemon and stopping it killed the first project's daemon. Mutable state now lives under a per-project key (projectpath.ProjectRuntimeDir) derived from the canonical project root, so subdirectories share the key and distinct projects never do. The daemon still watches the config directory for configured-file changes. Co-Authored-By: GPT-5.6 Sol --- cmd/context_evidence_test.go | 3 ++- cmd/context_test.go | 3 ++- cmd/hooks.go | 8 +++--- cmd/hooks_more_test.go | 30 +++++++++++++--------- cmd/hooks_provenance_test.go | 5 ++-- cmd/hooks_test.go | 19 +++++++------- cmd/setup_review_test.go | 6 +++-- handoff/handoff_test.go | 6 ++--- handoff/storage.go | 24 +++++++++--------- internal/projectpath/path.go | 42 +++++++++++++++++++++++++++++++ internal/projectpath/path_test.go | 14 +++++++++++ main_helpers_test.go | 3 ++- main_more_test.go | 6 ++--- mcp/main_test.go | 6 ++++- watch/daemon.go | 13 +++++----- watch/events.go | 5 +++- watch/more_test.go | 5 ++-- watch/state.go | 11 +++++--- watch/state_more_test.go | 14 ++++++----- watch/state_test.go | 41 ++++++++++++++++++++++++------ 20 files changed, 185 insertions(+), 79 deletions(-) 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 cbb4a0a..0e08e9d 100644 --- a/handoff/handoff_test.go +++ b/handoff/handoff_test.go @@ -339,7 +339,7 @@ func TestStoragePathsUseSetupRoot(t *testing.T) { projectpath.SetSetupRoot(setupRoot) t.Cleanup(projectpath.ResetSetupRoot) - want := filepath.Join(setupRoot, ".codemap", latestFilename) + want := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), latestFilename) if got := LatestPath(projectRoot); got != want { t.Fatalf("LatestPath() = %q, want %q", got, want) } @@ -373,10 +373,10 @@ func TestAutomaticLinkedWorktreesUseDistinctHandoffStorage(t *testing.T) { linkedA := makeLinked("a") linkedB := makeLinked("b") - if got, want := LatestPath(linkedA), filepath.Join(linkedA, ".codemap", latestFilename); got != want { + 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(linkedB, ".codemap", latestFilename); 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) { diff --git a/handoff/storage.go b/handoff/storage.go index 5a1f067..789c41c 100644 --- a/handoff/storage.go +++ b/handoff/storage.go @@ -19,42 +19,42 @@ const ( // LatestPath returns the absolute location of the latest handoff artifact. func LatestPath(root string) string { - runtimeRoot := projectpath.RuntimeRoot(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(runtimeRoot, ".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 { - runtimeRoot := projectpath.RuntimeRoot(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(runtimeRoot, ".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 { - runtimeRoot := projectpath.RuntimeRoot(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(runtimeRoot, ".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 { - runtimeRoot := projectpath.RuntimeRoot(root) + runtimeRoot := projectpath.ProjectRuntimeDir(root) absRoot, err := filepath.Abs(runtimeRoot) if err != nil { - return filepath.Join(runtimeRoot, ".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/watch/daemon.go b/watch/daemon.go index bef3e28..29d50f8 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -61,7 +61,7 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { gitCache: gitCache, verbose: verbose, done: make(chan struct{}), - eventLog: filepath.Join(projectpath.RuntimeCodemapDir(absRoot), "events.log"), + eventLog: filepath.Join(projectpath.ProjectRuntimeDir(absRoot), "events.log"), graph: &Graph{ Root: absRoot, Files: make(map[string]*scanner.FileInfo), @@ -79,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 := projectpath.RuntimeCodemapDir(d.root) - 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) } @@ -103,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 1d54cd5..5469aa4 100644 --- a/watch/events.go +++ b/watch/events.go @@ -636,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 @@ -671,7 +674,7 @@ func (d *Daemon) writeState() { return } - stateFile := filepath.Join(projectpath.RuntimeCodemapDir(d.root), "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 54234c1..6025443 100644 --- a/watch/state.go +++ b/watch/state.go @@ -36,7 +36,7 @@ func canonicalRoot(root string) string { } func ReadState(root string) *State { - stateFile := filepath.Join(projectpath.RuntimeCodemapDir(root), "state.json") + stateFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "state.json") data, err := os.ReadFile(stateFile) if err != nil { return nil @@ -70,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(projectpath.RuntimeCodemapDir(root), "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(projectpath.RuntimeCodemapDir(root), "watch.pid") + pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") data, err := os.ReadFile(pidFile) if err != nil { return 0, err @@ -88,7 +91,7 @@ func ReadPID(root string) (int, error) { // RemovePID removes the PID file func RemovePID(root string) { - pidFile := filepath.Join(projectpath.RuntimeCodemapDir(root), "watch.pid") + pidFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "watch.pid") os.Remove(pidFile) } 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 fc66bde..c87f8ed 100644 --- a/watch/state_test.go +++ b/watch/state_test.go @@ -19,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) } @@ -58,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) } @@ -159,7 +159,7 @@ func TestWatchStorageUsesSetupRoot(t *testing.T) { if err := WritePID(projectRoot); err != nil { t.Fatalf("WritePID() error: %v", err) } - wantPID := filepath.Join(setupRoot, ".codemap", "watch.pid") + wantPID := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), "watch.pid") if _, err := os.Stat(wantPID); err != nil { t.Fatalf("setup-root PID missing: %v", err) } @@ -169,7 +169,7 @@ func TestWatchStorageUsesSetupRoot(t *testing.T) { t.Fatalf("NewDaemon() error: %v", err) } defer d.watcher.Close() - wantLog := filepath.Join(setupRoot, ".codemap", "events.log") + wantLog := filepath.Join(projectpath.ProjectRuntimeDir(projectRoot), "events.log") if d.eventLog != wantLog { t.Fatalf("eventLog = %q, want %q", d.eventLog, wantLog) } @@ -201,10 +201,10 @@ func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) { if err := WritePID(linked); err != nil { t.Fatalf("WritePID() error: %v", err) } - if _, err := os.Stat(filepath.Join(linked, ".codemap", "watch.pid")); err != nil { + 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(primary, ".codemap", "watch.pid")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(primary), "watch.pid")); !os.IsNotExist(err) { t.Fatalf("primary PID unexpectedly created: %v", err) } @@ -213,11 +213,11 @@ func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) { t.Fatalf("NewDaemon() error: %v", err) } defer d.watcher.Close() - if want := filepath.Join(linked, ".codemap", "events.log"); d.eventLog != want { + 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(linked, ".codemap", "state.json")); err != nil { + 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) { @@ -250,6 +250,9 @@ 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) @@ -277,3 +280,25 @@ func TestReadStateRejectsForeignRootAndAcceptsDescendants(t *testing.T) { 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") + } +} From 594849ab7a1b6f4abb8ad9c3ec8aee6ddce4edbe Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:35:48 +0200 Subject: [PATCH 4/4] test(scanner): decouple cancellation test from subprocess timing Give the fake ast-grep spawn and deadline-termination generous 5s budgets instead of racing 1s/2s windows, so loaded CI runners don't flake. Co-Authored-By: GPT-5.6 Sol --- scanner/cancellation_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 {