Skip to content

fix(root): Decouple project and setup selection - #94

Merged
JordanCoin merged 1 commit into
JordanCoin:mainfrom
reneleonhardt:feat/project-root-selection
Aug 3, 2026
Merged

fix(root): Decouple project and setup selection#94
JordanCoin merged 1 commit into
JordanCoin:mainfrom
reneleonhardt:feat/project-root-selection

Conversation

@reneleonhardt

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the typed root-selection contract needed to fix root coupling for agentic coding in separate worktrees and sandbox checkouts, including linked-worktree validation.

Type of change

  • Bug fix
  • New feature
  • New language support
  • Documentation
  • Other (describe below)

Checklist

  • I've tested this locally with go build && ./codemap .
  • I've read CONTRIBUTING.md (for new language support)
  • I've updated documentation if needed

Additional notes

This is the independent root-selection contract; CLI routing follows in feat/explicit-project-root-cli.

After the full stack lands, an agent can analyze a sandbox worktree while reusing the canonical checkout's setup:

codemap -C /tmp/agent-sandbox --setup-root /path/to/original context --compact

Developed with carefully directed, manually reviewed AI assistance.

Co-Authored-By: GPT-5.6 Sol codex@openai.com

@JordanCoin

Copy link
Copy Markdown
Owner

Reviewed. Separating "repo being analyzed" from "repo whose Codemap setup/state is reused" is a genuinely useful distinction for sandboxed worktrees, and validGitMarker handling the gitdir: file form means linked worktrees resolve correctly — that's the part people usually get wrong.

Main design question before this lands:

projectpath is process-global mutable state, and codemap is not a single-project process. The MCP server handles many roots in one process (list_projects, and every tool takes its own path), so a SetSetupRoot that makes SetupRoot(projectRoot) ignore its own argument is a footgun there — one project's setup root would silently apply to all of them. You already defined the right shape in this PR: InvocationRoots{Project, Setup} as a value. Threading that through beats a package singleton, and it stays correct under serve.

Two smaller notes:

  • ResolveNearestGitRoot calls filepath.EvalSymlinks unconditionally, so it errors on a path that doesn't exist yet, and on macOS it rewrites /tmp/... to /private/tmp/.... That canonicalized root then flows into user-visible output and into .codemap path construction. Intentional?
  • Nothing calls any of this yet, and CodemapDir() doesn't replace any of the ~24 existing filepath.Join(root, ".codemap") sites. I understand it's staged ahead of feat/explicit-project-root-cli — just flagging that if the follow-up slips, we carry 760 lines exercised only by their own tests. Happy to land it as-is if the CLI wiring is close behind.

Keep analyzed-project and setup-storage roots as immutable invocation values, including linked-worktree validation. Resolve Git ownership through physical ancestry while preserving caller spelling when it maps to the same repository, and accept missing descendants without process-global state.

This prepares explicit routing as a staged follow-up for agentic coding sandboxes.

Signed-off-by: GPT-5.6 Sol <codex@openai.com>
Co-Authored-By: GPT-5.6 Sol <codex@openai.com>
@reneleonhardt
reneleonhardt force-pushed the feat/project-root-selection branch from 45303f3 to 173f5d5 Compare August 2, 2026 19:02
@reneleonhardt

reneleonhardt commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Applied feedback:

  • Removed the process-global projectpath singleton.
  • Retained immutable per-invocation project/setup roots.
  • Fixed nonexistent descendant handling and preserved /tmp/logical spelling where identity permits.
  • Resolves repository ownership using physical ancestry, preventing symlinks from incorrectly borrowing another repository’s .codemap state.
  • Added coverage for dangling symlinks, cross-repository aliases, external non-repositories, linked worktrees, and missing descendants.
  • CLI/MCP wiring remains explicitly staged for the follow-up.

Deep Codemap before and after found the final delta limited to cmd/root.go and cmd/root_test.go, with no cross-package importer or remaining singleton reference.

The diff is huge though unfortunately, you have to review completely.

@reneleonhardt

Copy link
Copy Markdown
Contributor Author
diff --git a/cmd/root.go b/cmd/root.go
index ff29b3e..d6c4dc4 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -129,42 +129,75 @@ func validateCodemapStorageRoot(root string) error {
 }
 
 // ResolveNearestGitRoot returns the nearest ancestor directory that contains a
-// .git entry. It accepts both .git directories and .git files used by linked
-// worktrees. When no repository root exists, it returns the absolute input
-// path and found=false.
+// .git entry. It accepts missing descendants and both .git directories and
+// .git files used by linked worktrees. When no repository root exists, it
+// returns the absolute input path and found=false without resolving symlinks.
 func ResolveNearestGitRoot(path string) (resolved string, found bool, err error) {
 	absPath, err := filepath.Abs(path)
 	if err != nil {
 		return "", false, err
 	}
-	canonicalPath, err := filepath.EvalSymlinks(absPath)
-	if err != nil {
-		return "", false, err
+	absPath = filepath.Clean(absPath)
+	var existingPath string
+	for current := absPath; ; current = filepath.Dir(current) {
+		info, statErr := os.Stat(current)
+		if statErr == nil {
+			if !info.IsDir() {
+				return "", false, fmt.Errorf("%q is not a directory", path)
+			}
+			existingPath = current
+			break
+		}
+		if !os.IsNotExist(statErr) {
+			return "", false, statErr
+		}
+		if _, lstatErr := os.Lstat(current); lstatErr == nil {
+			return "", false, fmt.Errorf("%q is not a directory", path)
+		} else if !os.IsNotExist(lstatErr) {
+			return "", false, lstatErr
+		}
+		if filepath.Dir(current) == current {
+			break
+		}
 	}
-	info, err := os.Stat(canonicalPath)
+
+	physicalPath, err := filepath.EvalSymlinks(existingPath)
 	if err != nil {
 		return "", false, err
 	}
-	if !info.IsDir() {
-		return "", false, fmt.Errorf("%q is not a directory", path)
-	}
-
-	for current := canonicalPath; ; current = filepath.Dir(current) {
+	physicalPath = filepath.Clean(physicalPath)
+	for current := physicalPath; ; current = filepath.Dir(current) {
 		valid, err := validGitMarker(current)
 		if err != nil {
 			return "", false, err
 		}
 		if valid {
-			return current, true, nil
+			return logicalRootForPhysical(existingPath, physicalPath, current), true, nil
 		}
 
 		parent := filepath.Dir(current)
 		if parent == current {
-			return canonicalPath, false, nil
+			return absPath, false, nil
 		}
 	}
 }
 
+func logicalRootForPhysical(logicalPath, physicalPath, physicalRoot string) string {
+	rel, err := filepath.Rel(physicalRoot, physicalPath)
+	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		return physicalRoot
+	}
+	logicalRoot := logicalPath
+	for remaining := rel; remaining != "."; remaining = filepath.Dir(remaining) {
+		logicalRoot = filepath.Dir(logicalRoot)
+	}
+	resolved, err := filepath.EvalSymlinks(logicalRoot)
+	if err == nil && filepath.Clean(resolved) == physicalRoot {
+		return logicalRoot
+	}
+	return physicalRoot
+}
+
 func validGitMarker(root string) (bool, error) {
 	marker := filepath.Join(root, ".git")
 	info, err := os.Lstat(marker)
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 1dd6054..741e6c5 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -27,12 +27,40 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if !found {
 			t.Fatal("expected repository root to be found")
 		}
-		want := canonicalTestPath(t, root)
+		want := root
 		if got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, want %q", got, want)
 		}
 	})
 
+	t.Run("missing descendant resolves repository root", func(t *testing.T) {
+		root := t.TempDir()
+		if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		missing := filepath.Join(root, "future", "feature")
+
+		got, found, err := ResolveNearestGitRoot(missing)
+		if err != nil {
+			t.Fatalf("ResolveNearestGitRoot() error: %v", err)
+		}
+		if !found || got != root {
+			t.Fatalf("ResolveNearestGitRoot() = %q, %t; want %q, true", got, found, root)
+		}
+	})
+
+	t.Run("missing non repository preserves absolute input", func(t *testing.T) {
+		missing := filepath.Join(t.TempDir(), "future", "feature")
+
+		got, found, err := ResolveNearestGitRoot(missing)
+		if err != nil {
+			t.Fatalf("ResolveNearestGitRoot() error: %v", err)
+		}
+		if found || got != missing {
+			t.Fatalf("ResolveNearestGitRoot() = %q, %t; want %q, false", got, found, missing)
+		}
+	})
+
 	t.Run("git worktree file resolves repository root", func(t *testing.T) {
 		root := t.TempDir()
 		gitDir := filepath.Join(t.TempDir(), "worktrees", "example")
@@ -54,13 +82,13 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if !found {
 			t.Fatal("expected worktree root to be found")
 		}
-		want := canonicalTestPath(t, root)
+		want := root
 		if got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, want %q", got, want)
 		}
 	})
 
-	t.Run("symlinked nested directory resolves canonical repository root", func(t *testing.T) {
+	t.Run("symlinked nested directory preserves repository alias", func(t *testing.T) {
 		if runtime.GOOS == "windows" {
 			t.Skip("directory symlinks may require elevated privileges")
 		}
@@ -81,12 +109,95 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if err != nil {
 			t.Fatalf("ResolveNearestGitRoot() error: %v", err)
 		}
-		want := canonicalTestPath(t, root)
+		want := link
 		if !found || got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, %t; want %q, true", got, found, want)
 		}
 	})
 
+	t.Run("repository-local symlink to external directory does not inherit repository", func(t *testing.T) {
+		if runtime.GOOS == "windows" {
+			t.Skip("directory symlinks may require elevated privileges")
+		}
+		root := t.TempDir()
+		if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		external := t.TempDir()
+		link := filepath.Join(root, "external")
+		if err := os.Symlink(external, link); err != nil {
+			t.Fatal(err)
+		}
+
+		for _, input := range []string{link, filepath.Join(link, "future", "feature")} {
+			got, found, err := ResolveNearestGitRoot(input)
+			if err != nil {
+				t.Fatalf("ResolveNearestGitRoot(%q) error: %v", input, err)
+			}
+			if found || got != input {
+				t.Fatalf("ResolveNearestGitRoot(%q) = %q, %t; want %q, false", input, got, found, input)
+			}
+		}
+	})
+
+	t.Run("repository-local symlink into another repository uses physical repository", func(t *testing.T) {
+		if runtime.GOOS == "windows" {
+			t.Skip("directory symlinks may require elevated privileges")
+		}
+		outer := t.TempDir()
+		if err := os.Mkdir(filepath.Join(outer, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		other := t.TempDir()
+		if err := os.Mkdir(filepath.Join(other, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		nested := filepath.Join(other, "pkg")
+		if err := os.Mkdir(nested, 0o755); err != nil {
+			t.Fatal(err)
+		}
+		link := filepath.Join(outer, "other-pkg")
+		if err := os.Symlink(nested, link); err != nil {
+			t.Fatal(err)
+		}
+
+		for _, input := range []string{link, filepath.Join(link, "future", "feature")} {
+			got, found, err := ResolveNearestGitRoot(input)
+			if err != nil {
+				t.Fatalf("ResolveNearestGitRoot(%q) error: %v", input, err)
+			}
+			physicalOther, err := filepath.EvalSymlinks(other)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if !found || got != physicalOther {
+				t.Fatalf("ResolveNearestGitRoot(%q) = %q, %t; want physical root %q, true", input, got, found, physicalOther)
+			}
+		}
+	})
+
+	t.Run("macOS tmp spelling is preserved", func(t *testing.T) {
+		if runtime.GOOS != "darwin" {
+			t.Skip("macOS exposes /tmp through /private/tmp")
+		}
+		root, err := os.MkdirTemp("/tmp", "codemap-root-")
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Cleanup(func() { _ = os.RemoveAll(root) })
+		if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+
+		got, found, err := ResolveNearestGitRoot(root)
+		if err != nil {
+			t.Fatalf("ResolveNearestGitRoot() error: %v", err)
+		}
+		if !found || got != root {
+			t.Fatalf("ResolveNearestGitRoot() = %q, %t; want %q, true", got, found, root)
+		}
+	})
+
 	t.Run("regular file input is rejected", func(t *testing.T) {
 		root := t.TempDir()
 		file := filepath.Join(root, "input")
@@ -98,6 +209,55 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		}
 	})
 
+	t.Run("symlink to file input is rejected", func(t *testing.T) {
+		if runtime.GOOS == "windows" {
+			t.Skip("symlinks may require elevated privileges")
+		}
+		root := t.TempDir()
+		target := filepath.Join(root, "target")
+		if err := os.WriteFile(target, []byte("not a directory"), 0o644); err != nil {
+			t.Fatal(err)
+		}
+		link := filepath.Join(root, "link")
+		if err := os.Symlink(target, link); err != nil {
+			t.Fatal(err)
+		}
+		if _, _, err := ResolveNearestGitRoot(link); err == nil {
+			t.Fatal("expected symlink to a file to be rejected")
+		}
+	})
+
+	t.Run("dangling symlink input is rejected", func(t *testing.T) {
+		if runtime.GOOS == "windows" {
+			t.Skip("symlinks may require elevated privileges")
+		}
+		root := t.TempDir()
+		link := filepath.Join(root, "link")
+		if err := os.Symlink(filepath.Join(root, "missing"), link); err != nil {
+			t.Fatal(err)
+		}
+		if _, _, err := ResolveNearestGitRoot(link); err == nil {
+			t.Fatal("expected dangling symlink to be rejected")
+		}
+	})
+
+	t.Run("missing descendant below dangling symlink is rejected", func(t *testing.T) {
+		if runtime.GOOS == "windows" {
+			t.Skip("symlinks may require elevated privileges")
+		}
+		root := t.TempDir()
+		if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		link := filepath.Join(root, "link")
+		if err := os.Symlink(filepath.Join(root, "missing"), link); err != nil {
+			t.Fatal(err)
+		}
+		if _, _, err := ResolveNearestGitRoot(filepath.Join(link, "child")); err == nil {
+			t.Fatal("expected a missing descendant below a dangling symlink to be rejected")
+		}
+	})
+
 	t.Run("device file input is rejected", func(t *testing.T) {
 		if _, err := os.Stat(os.DevNull); err != nil {
 			t.Skipf("device file unavailable: %v", err)
@@ -130,7 +290,7 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if err != nil {
 			t.Fatalf("ResolveNearestGitRoot() error: %v", err)
 		}
-		if want := canonicalTestPath(t, root); !found || got != want {
+		if want := root; !found || got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, %t; want %q, true", got, found, want)
 		}
 	})
@@ -175,7 +335,7 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if !found {
 			t.Fatal("expected repository root to be found")
 		}
-		want := canonicalTestPath(t, root)
+		want := root
 		if got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, want %q", got, want)
 		}
@@ -194,7 +354,7 @@ func TestResolveNearestGitRoot(t *testing.T) {
 		if found {
 			t.Fatal("expected no repository root to be found")
 		}
-		want := canonicalTestPath(t, root)
+		want := root
 		if got != want {
 			t.Fatalf("ResolveNearestGitRoot() = %q, want %q", got, want)
 		}
@@ -273,7 +433,7 @@ func TestParseGlobalRootOptions(t *testing.T) {
 }
 
 func TestResolveGlobalRoots(t *testing.T) {
-	launchDir := canonicalTestPath(t, t.TempDir())
+	launchDir := t.TempDir()
 	projectRoot := filepath.Join(launchDir, "worktree")
 	setupRoot := filepath.Join(launchDir, "original")
 	for _, root := range []string{projectRoot, setupRoot} {
@@ -319,7 +479,7 @@ func TestResolveGlobalRoots(t *testing.T) {
 	})
 
 	t.Run("setup root alone preserves non repository project", func(t *testing.T) {
-		project := canonicalTestPath(t, t.TempDir())
+		project := t.TempDir()
 		roots, err := ResolveGlobalRoots(GlobalRootOptions{SetupRoot: setupNested}, project)
 		if err != nil {
 			t.Fatalf("ResolveGlobalRoots() error: %v", err)
@@ -424,12 +584,3 @@ func TestResolveGlobalRoots(t *testing.T) {
 		}
 	})
 }
-
-func canonicalTestPath(t *testing.T, path string) string {
-	t.Helper()
-	resolved, err := filepath.EvalSymlinks(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-	return resolved
-}
diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go
deleted file mode 100644
index 8aeec0c..0000000
--- a/internal/projectpath/path.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// Package projectpath separates the analyzed project from its Codemap setup.
-package projectpath
-
-import (
-	"path/filepath"
-	"strings"
-	"sync"
-)
-
-var configuredSetupRoot struct {
-	sync.RWMutex
-	path string
-}
-
-// SetupRoot returns the configured setup root or the analyzed project root.
-func SetupRoot(projectRoot string) string {
-	if root := ConfiguredSetupRoot(); root != "" {
-		return filepath.Clean(root)
-	}
-	return projectRoot
-}
-
-// ConfiguredSetupRoot returns the invocation-wide override, if any.
-func ConfiguredSetupRoot() string {
-	configuredSetupRoot.RLock()
-	defer configuredSetupRoot.RUnlock()
-	return configuredSetupRoot.path
-}
-
-// SetSetupRoot configures the validated setup root for this process.
-func SetSetupRoot(root string) {
-	configuredSetupRoot.Lock()
-	defer configuredSetupRoot.Unlock()
-	root = strings.TrimSpace(root)
-	if root == "" {
-		configuredSetupRoot.path = ""
-		return
-	}
-	configuredSetupRoot.path = filepath.Clean(root)
-}
-
-// ResetSetupRoot clears invocation-scoped setup state.
-func ResetSetupRoot() {
-	SetSetupRoot("")
-}
-
-// PrependSetupRootArgs preserves setup selection across Codemap subprocesses.
-func PrependSetupRootArgs(args ...string) []string {
-	root := ConfiguredSetupRoot()
-	result := make([]string, 0, len(args)+2)
-	if root != "" {
-		result = append(result, "--setup-root", root)
-	}
-	return append(result, args...)
-}
-
-// CodemapDir returns the .codemap directory associated with a project.
-func CodemapDir(projectRoot string) string {
-	return filepath.Join(SetupRoot(projectRoot), ".codemap")
-}
diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go
deleted file mode 100644
index 944193d..0000000
--- a/internal/projectpath/path_test.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package projectpath
-
-import (
-	"path/filepath"
-	"testing"
-)
-
-func TestSetupRoot(t *testing.T) {
-	projectRoot := filepath.Join(t.TempDir(), "project")
-	setupRoot := filepath.Join(t.TempDir(), "setup")
-
-	t.Run("defaults to project root", func(t *testing.T) {
-		ResetSetupRoot()
-		t.Cleanup(ResetSetupRoot)
-		t.Setenv("CODEMAP_SETUP_ROOT", setupRoot)
-		if got := SetupRoot(projectRoot); got != projectRoot {
-			t.Fatalf("SetupRoot() = %q, want %q", got, projectRoot)
-		}
-		if got := ConfiguredSetupRoot(); got != "" {
-			t.Fatalf("ConfiguredSetupRoot() = %q, want empty despite inherited environment", got)
-		}
-	})
-
-	t.Run("uses configured setup root", func(t *testing.T) {
-		SetSetupRoot(setupRoot)
-		t.Cleanup(ResetSetupRoot)
-		if got := SetupRoot(projectRoot); got != setupRoot {
-			t.Fatalf("SetupRoot() = %q, want %q", got, setupRoot)
-		}
-		want := filepath.Join(setupRoot, ".codemap")
-		if got := CodemapDir(projectRoot); got != want {
-			t.Fatalf("CodemapDir() = %q, want %q", got, want)
-		}
-	})
-
-	t.Run("clears configured setup root", func(t *testing.T) {
-		SetSetupRoot(setupRoot)
-		ResetSetupRoot()
-		if got := SetupRoot(projectRoot); got != projectRoot {
-			t.Fatalf("SetupRoot() = %q, want %q", got, projectRoot)
-		}
-	})
-}
-
-func TestPrependSetupRootArgs(t *testing.T) {
-	setupRoot := filepath.Join(t.TempDir(), "setup")
-	projectRoot := filepath.Join(t.TempDir(), "project")
-	SetSetupRoot(setupRoot)
-	t.Cleanup(ResetSetupRoot)
-
-	got := PrependSetupRootArgs("watch", "start", projectRoot)
-	want := []string{"--setup-root", setupRoot, "watch", "start", projectRoot}
-	if len(got) != len(want) {
-		t.Fatalf("PrependSetupRootArgs() = %#v, want %#v", got, want)
-	}
-	for i := range want {
-		if got[i] != want[i] {
-			t.Fatalf("PrependSetupRootArgs() = %#v, want %#v", got, want)
-		}
-	}
-}

@JordanCoin
JordanCoin merged commit bfeb99f into JordanCoin:main Aug 3, 2026
12 checks passed
@reneleonhardt
reneleonhardt deleted the feat/project-root-selection branch August 3, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants