Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,11 @@ func showLightweightDiffVsMain(root string) {

// getLastSessionEvents reads events.log for previous session context
func getLastSessionEvents(root string) []string {
eventsFile := filepath.Join(projectpath.ProjectRuntimeDir(root), "events.log")
codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return nil
}
eventsFile := filepath.Join(codemapDir, "events.log")
f, err := os.Open(eventsFile)
if err != nil {
return nil
Expand Down Expand Up @@ -887,7 +891,10 @@ func hookPromptSubmit(root string) error {

// writeStatuslineState writes a tiny file for the statusline to read.
func writeStatuslineState(root string, intent TaskIntent) {
codemapDir := projectpath.ProjectRuntimeDir(root)
codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return
}
status := intent.Category
if intent.RiskLevel != "low" {
status += " " + intent.RiskLevel
Expand Down Expand Up @@ -1379,7 +1386,10 @@ func showSessionProgress(root, sessionID string) {

// hookPreCompact saves hub state before context compaction
func hookPreCompact(root string) error {
codemapDir := projectpath.ProjectRuntimeDir(root)
codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return err
}
if err := os.MkdirAll(codemapDir, 0755); err != nil {
return err
}
Expand Down Expand Up @@ -1694,7 +1704,10 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti
}
return nil
}
codemapDir := projectpath.ProjectRuntimeDir(root)
codemapDir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return err
}
if err := os.MkdirAll(codemapDir, 0o755); err != nil {
return err
}
Expand Down
29 changes: 25 additions & 4 deletions cmd/hooks_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ func withHookRuntimeStubs(
})
}

func TestHookMutableStateFailsClosedOnRuntimeIdentityMismatch(t *testing.T) {
root, setup := t.TempDir(), t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
projectpath.SetSetupRoot(setup)
t.Cleanup(projectpath.ResetSetupRoot)
selection, err := projectpath.SelectRuntime(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil {
t.Fatal(err)
}

if err := updateSessionLease(root, "session-a", true, time.Now(), nil); err == nil {
t.Fatal("session lease accepted mismatched runtime identity")
}
writeStatuslineState(root, TaskIntent{Category: "test", RiskLevel: "low"})
if _, err := os.Stat(filepath.Join(root, ".codemap")); !os.IsNotExist(err) {
t.Fatalf("unsafe project-local state exists: %v", err)
}
}

func captureOutputAndError(t *testing.T, fn func()) (string, string) {
t.Helper()

Expand Down Expand Up @@ -611,10 +635,7 @@ func TestFindChildReposAndSessionStartVariants(t *testing.T) {
"pkg/types.go": {"a.go", "b.go", "c.go"},
},
})
if err := watch.WritePID(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { watch.RemovePID(root) })
writeOwnedWatchPID(t, root)

if err := handoff.WriteLatest(root, &handoff.Artifact{
SchemaVersion: handoff.SchemaVersion,
Expand Down
40 changes: 32 additions & 8 deletions cmd/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
Expand All @@ -27,6 +28,35 @@ func withOwnedDaemonProcess(t *testing.T, fn func(string) bool) {
})
}

func TestOwnedWatchDaemonHelperProcess(t *testing.T) {
if os.Getenv("CODEMAP_CMD_WATCH_HELPER") != "1" {
return
}
time.Sleep(time.Minute)
}

func writeOwnedWatchPID(t *testing.T, root string) {
t.Helper()
canonical, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
process := exec.Command(os.Args[0], "-test.run=TestOwnedWatchDaemonHelperProcess", "--", "watch", "daemon", canonical)
process.Env = append(os.Environ(), "CODEMAP_CMD_WATCH_HELPER=1")
if err := process.Start(); err != nil {
t.Fatal(err)
}
if err := watch.WriteProcessPID(root, process.Process.Pid); err != nil {
_ = process.Process.Kill()
t.Fatal(err)
}
t.Cleanup(func() {
_ = process.Process.Kill()
_, _ = process.Process.Wait()
watch.RemovePID(root)
})
}

// TestHubInfoIsHub tests the hub detection threshold (3+ importers)
func TestHubInfoIsHub(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -248,10 +278,7 @@ func TestShouldRestartDaemon(t *testing.T) {
if err := os.MkdirAll(codemapDir, 0755); err != nil {
t.Fatal(err)
}
if err := watch.WritePID(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { watch.RemovePID(root) })
writeOwnedWatchPID(t, root)

if !shouldRestartDaemon(root, time.Now()) {
t.Fatal("expected true when daemon pid exists but state is missing")
Expand Down Expand Up @@ -814,10 +841,7 @@ func writeWatchState(t *testing.T, root string, state watch.State) {
if err := os.WriteFile(filepath.Join(codemapDir, "state.json"), data, 0644); err != nil {
t.Fatal(err)
}
if err := watch.WritePID(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { watch.RemovePID(root) })
writeOwnedWatchPID(t, root)
}

// TestGetLastSessionEvents verifies that the 20-line budget is enforced when
Expand Down
26 changes: 26 additions & 0 deletions handoff/handoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ func contains(items []string, value string) bool {
return false
}

func TestWriteLatestFailsClosedOnRuntimeIdentityMismatch(t *testing.T) {
root, setup := t.TempDir(), t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
projectpath.SetSetupRoot(setup)
t.Cleanup(projectpath.ResetSetupRoot)
selection, err := projectpath.SelectRuntime(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(selection.RuntimeDir, "project.json"), []byte(`{"canonical_root":"/other"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := WriteLatest(root, &Artifact{SchemaVersion: SchemaVersion}); err == nil {
t.Fatal("WriteLatest accepted mismatched runtime identity")
}
if _, err := os.Stat(filepath.Join(root, ".codemap", latestFilename)); !os.IsNotExist(err) {
t.Fatalf("unsafe fallback artifact exists: %v", err)
}
}

func TestBuildWriteRead(t *testing.T) {
root := t.TempDir()

Expand Down Expand Up @@ -335,6 +357,7 @@ func TestMetricsLogCapped(t *testing.T) {

func TestStoragePathsUseSetupRoot(t *testing.T) {
projectRoot := t.TempDir()
otherProject := t.TempDir()
setupRoot := t.TempDir()
projectpath.SetSetupRoot(setupRoot)
t.Cleanup(projectpath.ResetSetupRoot)
Expand All @@ -343,6 +366,9 @@ func TestStoragePathsUseSetupRoot(t *testing.T) {
if got := LatestPath(projectRoot); got != want {
t.Fatalf("LatestPath() = %q, want %q", got, want)
}
if LatestPath(projectRoot) == LatestPath(otherProject) {
t.Fatal("explicit setup-root projects share handoff storage")
}
}

func TestAutomaticLinkedWorktreesUseDistinctHandoffStorage(t *testing.T) {
Expand Down
53 changes: 34 additions & 19 deletions handoff/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"

"codemap/internal/projectpath"
"codemap/internal/runtimefile"
)

const (
Expand Down Expand Up @@ -60,7 +61,10 @@ func MetricsPath(root string) string {
// ReadLatest reads the latest handoff artifact if it exists.
// Returns (nil, nil) when no artifact is present.
func ReadLatest(root string) (*Artifact, error) {
path := LatestPath(root)
path, err := runtimePath(root, latestFilename)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
Expand All @@ -82,36 +86,52 @@ func ReadLatest(root string) (*Artifact, error) {
func WriteLatest(root string, artifact *Artifact) error {
normalizeArtifact(artifact)

path := LatestPath(root)
runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return err
}
path := filepath.Join(runtimeDir, latestFilename)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}

if err := writeJSONAtomic(path, artifact); err != nil {
return err
}
if err := writeJSONAtomic(PrefixPath(root), artifact.Prefix); err != nil {
if err := writeJSONAtomic(filepath.Join(runtimeDir, prefixFilename), artifact.Prefix); err != nil {
return err
}
if err := writeJSONAtomic(DeltaPath(root), artifact.Delta); err != nil {
if err := writeJSONAtomic(filepath.Join(runtimeDir, deltaFilename), artifact.Delta); err != nil {
return err
}
return appendMetrics(root, artifact)
return appendMetricsAt(filepath.Join(runtimeDir, metricsFilename), artifact)
}

func runtimePath(root, name string) (string, error) {
dir, err := projectpath.CheckedRuntimeCodemapDir(root)
if err != nil {
return "", err
}
return filepath.Join(dir, name), nil
}

func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return runtimefile.WriteAtomic(path, data, 0o644)
}

func appendMetrics(root string, artifact *Artifact) error {
path, err := runtimePath(root, metricsFilename)
if err != nil {
return err
}
return os.Rename(tmpPath, path)
return appendMetricsAt(path, artifact)
}

func appendMetrics(root string, artifact *Artifact) error {
func appendMetricsAt(path string, artifact *Artifact) error {
entry := struct {
GeneratedAt string `json:"generated_at"`
Branch string `json:"branch"`
Expand All @@ -135,7 +155,7 @@ func appendMetrics(root string, artifact *Artifact) error {
return err
}

f, err := os.OpenFile(MetricsPath(root), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
f, err := runtimefile.OpenAppend(path, 0o644)
if err != nil {
return err
}
Expand All @@ -144,7 +164,7 @@ func appendMetrics(root string, artifact *Artifact) error {
if _, err := f.Write(append(data, '\n')); err != nil {
return err
}
return capMetricsLog(root, maxMetricsLines)
return capMetricsLogAt(path, maxMetricsLines)
}

func normalizeArtifact(artifact *Artifact) {
Expand Down Expand Up @@ -225,13 +245,12 @@ func backfillHashes(artifact *Artifact) {
}
}

func capMetricsLog(root string, maxLines int) error {
func capMetricsLogAt(path string, maxLines int) error {
if maxLines <= 0 {
return nil
}

path := MetricsPath(root)
data, err := os.ReadFile(path)
data, err := runtimefile.Read(path)
if err != nil {
if os.IsNotExist(err) {
return nil
Expand All @@ -250,9 +269,5 @@ func capMetricsLog(root string, maxLines int) error {

trimmed := bytes.Join(lines[len(lines)-maxLines:], []byte("\n"))
trimmed = append(trimmed, '\n')
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, trimmed, 0644); err != nil {
return err
}
return os.Rename(tmpPath, path)
return runtimefile.WriteAtomic(path, trimmed, 0o644)
}
19 changes: 15 additions & 4 deletions internal/projectpath/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,6 @@ func CodemapDir(projectRoot string) string {

// RuntimeRoot returns the root for mutable state associated with a project.
func RuntimeRoot(projectRoot string) string {
if explicit := ConfiguredSetupRoot(); explicit != "" {
return filepath.Clean(explicit)
}
selection, err := Select(projectRoot)
if err == nil {
return selection.RuntimeRoot
Expand Down Expand Up @@ -178,7 +175,21 @@ func ProjectRuntimeDir(projectRoot string) string {

// RuntimeCodemapDir returns the .codemap directory for mutable project state.
func RuntimeCodemapDir(projectRoot string) string {
return filepath.Join(RuntimeRoot(projectRoot), ".codemap")
selection, err := SelectRuntime(projectRoot)
if err == nil {
return selection.RuntimeDir
}
return filepath.Join(filepath.Clean(projectRoot), ".codemap")
}

// CheckedRuntimeCodemapDir returns the validated mutable-state directory.
// Stateful callers must use this form so selection failures cannot fall back.
func CheckedRuntimeCodemapDir(projectRoot string) (string, error) {
selection, err := SelectRuntime(projectRoot)
if err != nil {
return "", err
}
return filepath.Join(selection.RuntimeDir, "projects", ProjectKey(selection.ProjectRoot)), nil
}

func canonicalProjectRoot(root string) (string, error) {
Expand Down
Loading
Loading